Compare commits

...

21 Commits

Author SHA1 Message Date
f310283899 Try 16 core machine for playwright ubuntu 2024-07-02 11:37:10 -04:00
e42a891df8 Add nix flake and direnv config (#2694) 2024-06-18 11:32:08 -04:00
98200565bf Add a dismiss button to the command bar (#2647)
* Remove tab hotkey from selection input

* Add dismiss button to the command bar

* update Cargo.lock

* tweak close button styles

* Switch from padding to margin for positioning without messing up focus outline

* Revert "update Cargo.lock"

This reverts commit 862a6897ba.

* Restore Cargo.lock I hate VSCode sometimes

* Update Cargo.lock in src-tauri, fix clippy

---------

Co-authored-by: Adam Chalmers <adam.chalmers@zoo.dev>
2024-06-18 09:06:46 -04:00
570fd827ed fix zoom issues with sketch mode (#2664)
* cam stuff start

* more progress

* mostly done

* fix snapshot tests

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

* fix

* fix ubuntu

* more tweaks fixes

* add test

* more FE fixes

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-06-18 16:08:41 +10:00
0add26cf61 Cut release v0.22.2 (#2685) 2024-06-17 17:44:30 -04:00
b54fc534c2 Patterning a pattern should always work (#2680)
* patterning a pattern should alwayus work

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* add images;

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* std lib

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* bu,mp

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* fix tests

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* update lock

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* bump

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* fixes

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* fixes

Signed-off-by: Jess Frazelle <github@jessfraz.com>

---------

Signed-off-by: Jess Frazelle <github@jessfraz.com>
2024-06-17 13:35:44 -07:00
c66f851a3f add shell (#2683)
* add shell

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* add shell

Signed-off-by: Jess Frazelle <github@jessfraz.com>

---------

Signed-off-by: Jess Frazelle <github@jessfraz.com>
2024-06-17 13:10:40 -07:00
13b8ab71d8 Bump tokio-tungstenite from 0.23.0 to 0.23.1 in /src/wasm-lib (#2663)
Bumps [tokio-tungstenite](https://github.com/snapview/tokio-tungstenite) from 0.23.0 to 0.23.1.
- [Changelog](https://github.com/snapview/tokio-tungstenite/blob/master/CHANGELOG.md)
- [Commits](https://github.com/snapview/tokio-tungstenite/commits)

---
updated-dependencies:
- dependency-name: tokio-tungstenite
  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-06-17 14:36:56 -05:00
bdeab4f87d Bump clap from 4.5.4 to 4.5.7 in /src/wasm-lib (#2643)
Bumps [clap](https://github.com/clap-rs/clap) from 4.5.4 to 4.5.7.
- [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.4...v4.5.7)

---
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-06-17 14:16:10 -05:00
05ccf5e2f4 Chamfer is just a fancy fillet so easy to add (#2681)
* add chamfer

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* generate docs

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* generate docs

Signed-off-by: Jess Frazelle <github@jessfraz.com>

---------

Signed-off-by: Jess Frazelle <github@jessfraz.com>
2024-06-17 12:13:19 -07:00
7ab015d783 Ensure settings are persisted before we navigate for onboarding dismissal (#2678)
* Nicer types on OnboardingPaths

* Update persistSettings to be a service
2024-06-17 15:10:30 -04:00
3d6cfa980f Release kcl-lib 0.1.59 (#2679) 2024-06-17 13:29:32 -05:00
9f5f1eb8c3 Bump kittycad libs (#2665)
* Bump kittycad.rs lib

* Bump kittycad.ts lib

* Update cargo.lock again

* Bump lib again, and fix fillet typing

* Update kittycad.rs to v0.3.5

* Revert schemars to v0.8.17

* Update to kcl spec
2024-06-17 18:01:45 +00:00
50fcdff879 Prevent stale Cargo.lock (#2652)
Sometimes the `src-tauri/` project gets out of date Cargo.lock. This
adds a CI check to prevent it.

This can happen because `src-tauri` is a separate Cargo project from
`src/wasm-lib`, but the former includes the latter as a dependency. So,
when wasm-lib updates a dep (e.g. bump databake from 1.7 to 1.8), the
former will, upon recompilation, pull in the newer databake dep. But
programmers in the wasm-lib repo don't usually work in the src-tauri repo
and so the src-tauri repo doesn't get updated.
2024-06-14 11:48:31 +02:00
efaae2b193 Bump bson from 2.10.0 to 2.11.0 in /src/wasm-lib (#2614)
Bumps [bson](https://github.com/mongodb/bson-rust) from 2.10.0 to 2.11.0.
- [Release notes](https://github.com/mongodb/bson-rust/releases)
- [Commits](https://github.com/mongodb/bson-rust/compare/v2.10.0...v2.11.0)

---
updated-dependencies:
- dependency-name: bson
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-13 15:42:47 -04:00
7e4ebacb72 Stale Cargo.lock, clippy 1.79 fixes (#2651)
* Update Cargo.lock in src-tauri, fix clippy

* Update circular pattern 3d test
2024-06-13 15:42:21 -04:00
72482506c3 add lint playwright test (#2646)
add lint test

Signed-off-by: Jess Frazelle <github@jessfraz.com>
2024-06-11 17:05:25 -07:00
a51b5b09a3 Add in a prototype KCL linter (#2521)
* Add in a prototype KCL linter

This is a fork-and-replce of an experimental project I hacked up called
"kcl-vet", which was mostly the same code. This integrates kcl-vet into
the kcl_lib crate, which will let us use this from the zoo cli, as well
as via wasm in the lsp. this contains the intial integration with the
lsp, adding all lints as informational to start.

I need to go back and clean some of this up (and merge some of this back
into other parts of kcl_lib); but this has some pretty good progress
already.

Co-authored-by: jess@zoo.dev
Signed-off-by: Paul R. Tagliamonte <paul@zoo.dev>

* ty clippy :)

* add in a lint test

* add in some docstrings

* whoops

* sigh

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

* uno reverse card

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

* wtf stop it robot fuck

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

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

This reverts commit 5b18f3c035.

* hurm

* try harder to type slower

* try harder? this all passes locally.

* try this now

* simplify, add debugging for trace

* fix enter use

* re-order again

* reorder a bit more

* enter

* ok fine no other enters?

* nerd

* wip

* move control of clearing to typescript

* move result out

* err check

* remove log

* remove clear

* remove add to diag

* THERE CAN BE ONLY ONE

* _err

* dedupe

* Revert "dedupe"

This reverts commit f66de88200.

* attempt to dedupe

* clear diagnostics on mock execute, too

* handle dupe diagnostics

* fmt

* dedupe tsc

* == vs ===

* fix dedupe

* return this to the wasm for now

* clear the map every go around

this is different than the old code isnce it won't republish

---------

Signed-off-by: Paul R. Tagliamonte <paul@zoo.dev>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-06-11 16:23:35 -07:00
53ccc1ed6c Swap out icons for bug and refresh, tweak tooltip appearance (#2641)
* add bug icon, swap out refresh icon

* remove lame theme color outline from tooltips

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

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-06-11 14:37:15 -04:00
8106749ccf Bump databake from 0.1.7 to 0.1.8 in /src/wasm-lib (#2545)
Bumps [databake](https://github.com/unicode-org/icu4x) from 0.1.7 to 0.1.8.
- [Release notes](https://github.com/unicode-org/icu4x/releases)
- [Changelog](https://github.com/unicode-org/icu4x/blob/main/CHANGELOG.md)
- [Commits](https://github.com/unicode-org/icu4x/commits)

---
updated-dependencies:
- dependency-name: databake
  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>
Co-authored-by: Jess Frazelle <jessfraz@users.noreply.github.com>
2024-06-11 07:49:30 -05:00
081e34a600 Bump regex from 1.10.4 to 1.10.5 in /src/wasm-lib (#2637)
Bumps [regex](https://github.com/rust-lang/regex) from 1.10.4 to 1.10.5.
- [Release notes](https://github.com/rust-lang/regex/releases)
- [Changelog](https://github.com/rust-lang/regex/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rust-lang/regex/compare/1.10.4...1.10.5)

---
updated-dependencies:
- dependency-name: regex
  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-06-10 17:29:24 -07:00
80 changed files with 11808 additions and 3524 deletions

View File

@ -1,3 +1,3 @@
[codespell]
ignore-words-list: crate,everytime,inout,co-ordinate,ot,nwo,absolutey,atleast
ignore-words-list: crate,everytime,inout,co-ordinate,ot,nwo,absolutey,atleast,ue
skip: **/target,node_modules,build,**/Cargo.lock,./docs/kcl/*.md,./src-tauri/gen/schemas

1
.envrc Normal file
View File

@ -0,0 +1 @@
use flake .

View File

@ -54,3 +54,8 @@ jobs:
run: |
cd "${{ matrix.dir }}"
cargo clippy --all --tests --benches -- -D warnings
# If this fails, run "cargo check" to update Cargo.lock,
# then add Cargo.lock to the PR.
- name: Check Cargo.lock doesn't need updating
run: |
cargo check --locked || echo "Pls run cargo check and commit the changed Cargo.lock"

View File

@ -35,7 +35,7 @@ jobs:
playwright-ubuntu:
timeout-minutes: 60
runs-on: ubuntu-latest-8-cores
runs-on: ubuntu-latest-16-cores
needs: check-rust-changes
steps:
- uses: actions/checkout@v4

1
.gitignore vendored
View File

@ -17,6 +17,7 @@
.env.development.local
.env.test.local
.env.production.local
.direnv
npm-debug.log*
yarn-debug.log*

323
docs/kcl/chamfer.md Normal file

File diff suppressed because one or more lines are too long

View File

@ -23,6 +23,7 @@ layout: manual
* [`atan`](kcl/atan)
* [`bezierCurve`](kcl/bezierCurve)
* [`ceil`](kcl/ceil)
* [`chamfer`](kcl/chamfer)
* [`circle`](kcl/circle)
* [`close`](kcl/close)
* [`cos`](kcl/cos)
@ -64,6 +65,7 @@ layout: manual
* [`segEndX`](kcl/segEndX)
* [`segEndY`](kcl/segEndY)
* [`segLen`](kcl/segLen)
* [`shell`](kcl/shell)
* [`sin`](kcl/sin)
* [`sqrt`](kcl/sqrt)
* [`startProfileAt`](kcl/startProfileAt)

View File

@ -9,7 +9,7 @@ A circular pattern on a 2D sketch.
```js
patternCircular2d(data: CircularPattern2dData, sketch_group: SketchGroup) -> [SketchGroup]
patternCircular2d(data: CircularPattern2dData, sketch_group_set: SketchGroupSet) -> [SketchGroup]
```
### Examples
@ -48,7 +48,7 @@ const example = extrude(1, exampleSketch)
rotateDuplicates: string,
}
```
* `sketch_group`: `SketchGroup` - A sketch group is a collection of paths. (REQUIRED)
* `sketch_group_set`: `SketchGroupSet` - A sketch group or a group of sketch groups. (REQUIRED)
```js
{
// The plane id or face id of the sketch group.
@ -129,6 +129,7 @@ const example = extrude(1, exampleSketch)
// The to point.
to: [number, number],
},
type: "sketchGroup",
// The paths in the sketch group.
value: [{
// The from point.
@ -212,6 +213,9 @@ const example = extrude(1, exampleSketch)
y: number,
z: number,
},
} |
{
type: "sketchGroups",
}
```

View File

@ -9,7 +9,7 @@ A circular pattern on a 3D model.
```js
patternCircular3d(data: CircularPattern3dData, extrude_group: ExtrudeGroup) -> [ExtrudeGroup]
patternCircular3d(data: CircularPattern3dData, extrude_group_set: ExtrudeGroupSet) -> [ExtrudeGroup]
```
### Examples
@ -47,7 +47,7 @@ const example = extrude(-5, exampleSketch)
rotateDuplicates: string,
}
```
* `extrude_group`: `ExtrudeGroup` - An extrude group is a collection of extrude surfaces. (REQUIRED)
* `extrude_group_set`: `ExtrudeGroupSet` - A extrude group or a group of extrude groups. (REQUIRED)
```js
{
// The id of the extrusion end cap
@ -127,6 +127,7 @@ const example = extrude(-5, exampleSketch)
}],
// The id of the extrusion start cap
startCapId: uuid,
type: "extrudeGroup",
// The extrude surfaces.
value: [{
// The face id for the extrude plane.
@ -176,6 +177,9 @@ const example = extrude(-5, exampleSketch)
y: number,
z: number,
},
} |
{
type: "extrudeGroups",
}
```

View File

@ -9,7 +9,7 @@ A linear pattern on a 3D model.
```js
patternLinear3d(data: LinearPattern3dData, extrude_group: ExtrudeGroup) -> [ExtrudeGroup]
patternLinear3d(data: LinearPattern3dData, extrude_group_set: ExtrudeGroupSet) -> [ExtrudeGroup]
```
### Examples
@ -45,7 +45,7 @@ const example = extrude(1, exampleSketch)
repetitions: number,
}
```
* `extrude_group`: `ExtrudeGroup` - An extrude group is a collection of extrude surfaces. (REQUIRED)
* `extrude_group_set`: `ExtrudeGroupSet` - A extrude group or a group of extrude groups. (REQUIRED)
```js
{
// The id of the extrusion end cap
@ -125,6 +125,7 @@ const example = extrude(1, exampleSketch)
}],
// The id of the extrusion start cap
startCapId: uuid,
type: "extrudeGroup",
// The extrude surfaces.
value: [{
// The face id for the extrude plane.
@ -174,6 +175,9 @@ const example = extrude(1, exampleSketch)
y: number,
z: number,
},
} |
{
type: "extrudeGroups",
}
```

311
docs/kcl/shell.md Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@ -38,9 +38,9 @@ document.addEventListener('mousemove', (e) =>
const deg = (Math.PI * 2) / 360
const commonPoints = {
startAt: '[9.06, -12.22]',
num1: 9.14,
num2: 18.2,
startAt: '[7.19, -9.7]',
num1: 7.25,
num2: 14.44,
// num1: 9.64,
// num2: 19.19,
}
@ -99,7 +99,7 @@ test('Basic sketch', async ({ page }) => {
)
await u.closeDebugPanel()
await page.waitForTimeout(300) // TODO detect animation ending, or disable animation
await page.waitForTimeout(500) // TODO detect animation ending, or disable animation
const startXPx = 600
await page.mouse.click(startXPx + PUR * 10, 500 - PUR * 10)
@ -118,13 +118,13 @@ test('Basic sketch', async ({ page }) => {
await expect(u.codeLocator).toHaveText(`const sketch001 = startSketchOn('XZ')
|> startProfileAt(${commonPoints.startAt}, %)
|> line([${commonPoints.num1}, 0], %)
|> line([0, ${commonPoints.num1}], %)`)
|> line([0, ${commonPoints.num1 + 0.01}], %)`)
await page.waitForTimeout(100)
await page.mouse.click(startXPx, 500 - PUR * 20)
await expect(u.codeLocator).toHaveText(`const sketch001 = startSketchOn('XZ')
|> startProfileAt(${commonPoints.startAt}, %)
|> line([${commonPoints.num1}, 0], %)
|> line([0, ${commonPoints.num1}], %)
|> line([0, ${commonPoints.num1 + 0.01}], %)
|> line([-${commonPoints.num2}, 0], %)`)
// deselect line tool
@ -154,31 +154,113 @@ test('Basic sketch', async ({ page }) => {
await expect(u.codeLocator).toHaveText(`const sketch001 = startSketchOn('XZ')
|> startProfileAt(${commonPoints.startAt}, %)
|> line([${commonPoints.num1}, 0], %, 'seg01')
|> line([0, ${commonPoints.num1}], %)
|> line([0, ${commonPoints.num1 + 0.01}], %)
|> angledLine([180, segLen('seg01', %)], %)`)
})
test('Can moving camera', async ({ page, context }) => {
test.skip(process.platform === 'darwin', 'Can moving camera')
const u = await getUtils(page)
await page.setViewportSize({ width: 1200, height: 500 })
await page.goto('/')
await u.waitForAuthSkipAppStart()
await u.openAndClearDebugPanel()
await u.closeKclCodePanel()
test.describe('Testing Camera Movement', () => {
test('Can moving camera', async ({ page, context }) => {
test.skip(process.platform === 'darwin', 'Can moving camera')
const u = await getUtils(page)
await page.setViewportSize({ width: 1200, height: 500 })
await page.goto('/')
await u.waitForAuthSkipAppStart()
await u.openAndClearDebugPanel()
await u.closeKclCodePanel()
const camPos: [number, number, number] = [0, 85, 85]
const bakeInRetries = async (
mouseActions: any,
xyz: [number, number, number],
cnt = 0
) => {
// hack that we're implemented our own retry instead of using retries built into playwright.
// however each of these camera drags can be flaky, because of udp
// and so putting them together means only one needs to fail to make this test extra flaky.
// this way we can retry within the test
// We could break them out into separate tests, but the longest past of the test is waiting
// for the stream to start, so it can be good to bundle related things together.
const camPos: [number, number, number] = [0, 85, 85]
const bakeInRetries = async (
mouseActions: any,
xyz: [number, number, number],
cnt = 0
) => {
// hack that we're implemented our own retry instead of using retries built into playwright.
// however each of these camera drags can be flaky, because of udp
// and so putting them together means only one needs to fail to make this test extra flaky.
// this way we can retry within the test
// We could break them out into separate tests, but the longest past of the test is waiting
// for the stream to start, so it can be good to bundle related things together.
const camCommand: EngineCommand = {
type: 'modeling_cmd_req',
cmd_id: uuidv4(),
cmd: {
type: 'default_camera_look_at',
center: { x: 0, y: 0, z: 0 },
vantage: { x: camPos[0], y: camPos[1], z: camPos[2] },
up: { x: 0, y: 0, z: 1 },
},
}
const updateCamCommand: EngineCommand = {
type: 'modeling_cmd_req',
cmd_id: uuidv4(),
cmd: {
type: 'default_camera_get_settings',
},
}
await u.sendCustomCmd(camCommand)
await page.waitForTimeout(100)
await u.sendCustomCmd(updateCamCommand)
await page.waitForTimeout(100)
// rotate
await u.closeDebugPanel()
await page.getByRole('button', { name: 'Start Sketch' }).click()
await page.waitForTimeout(100)
// const yo = page.getByTestId('cam-x-position').inputValue()
await u.doAndWaitForImageDiff(async () => {
await mouseActions()
await u.openAndClearDebugPanel()
await u.closeDebugPanel()
await page.waitForTimeout(100)
}, 300)
await u.openAndClearDebugPanel()
await page.getByTestId('cam-x-position').isVisible()
const vals = await Promise.all([
page.getByTestId('cam-x-position').inputValue(),
page.getByTestId('cam-y-position').inputValue(),
page.getByTestId('cam-z-position').inputValue(),
])
const xError = Math.abs(Number(vals[0]) + xyz[0])
const yError = Math.abs(Number(vals[1]) + xyz[1])
const zError = Math.abs(Number(vals[2]) + xyz[2])
let shouldRetry = false
if (xError > 5 || yError > 5 || zError > 5) {
if (cnt > 2) {
console.log('xVal', vals[0], 'xError', xError)
console.log('yVal', vals[1], 'yError', yError)
console.log('zVal', vals[2], 'zError', zError)
throw new Error('Camera position not as expected')
}
shouldRetry = true
}
await page.getByRole('button', { name: 'Exit Sketch' }).click()
await page.waitForTimeout(100)
if (shouldRetry) await bakeInRetries(mouseActions, xyz, cnt + 1)
}
await bakeInRetries(async () => {
await page.mouse.move(700, 200)
await page.mouse.down({ button: 'right' })
await page.mouse.move(600, 303)
await page.mouse.up({ button: 'right' })
}, [4, -10.5, -120])
await bakeInRetries(async () => {
await page.keyboard.down('Shift')
await page.mouse.move(600, 200)
await page.mouse.down({ button: 'right' })
await page.mouse.move(700, 200, { steps: 2 })
await page.mouse.up({ button: 'right' })
await page.keyboard.up('Shift')
}, [-19, -85, -85])
const camCommand: EngineCommand = {
type: 'modeling_cmd_req',
@ -202,122 +284,182 @@ test('Can moving camera', async ({ page, context }) => {
await u.sendCustomCmd(updateCamCommand)
await page.waitForTimeout(100)
// rotate
await u.clearCommandLogs()
await u.closeDebugPanel()
await page.getByRole('button', { name: 'Start Sketch' }).click()
await page.waitForTimeout(100)
// const yo = page.getByTestId('cam-x-position').inputValue()
await page.waitForTimeout(200)
// zoom
await u.doAndWaitForImageDiff(async () => {
await mouseActions()
await page.keyboard.down('Control')
await page.mouse.move(700, 400)
await page.mouse.down({ button: 'right' })
await page.mouse.move(700, 300)
await page.mouse.up({ button: 'right' })
await page.keyboard.up('Control')
await u.openAndClearDebugPanel()
await u.openDebugPanel()
await page.waitForTimeout(300)
await u.clearCommandLogs()
await u.closeDebugPanel()
await page.waitForTimeout(100)
}, 300)
// zoom with scroll
await u.openAndClearDebugPanel()
await page.getByTestId('cam-x-position').isVisible()
// TODO, it appears we don't get the cam setting back from the engine when the interaction is zoom into `backInRetries` once the information is sent back on zoom
// await expect(Math.abs(Number(await page.getByTestId('cam-x-position').inputValue()) + 12)).toBeLessThan(1.5)
// await expect(Math.abs(Number(await page.getByTestId('cam-y-position').inputValue()) - 85)).toBeLessThan(1.5)
// await expect(Math.abs(Number(await page.getByTestId('cam-z-position').inputValue()) - 85)).toBeLessThan(1.5)
const vals = await Promise.all([
page.getByTestId('cam-x-position').inputValue(),
page.getByTestId('cam-y-position').inputValue(),
page.getByTestId('cam-z-position').inputValue(),
])
const xError = Math.abs(Number(vals[0]) + xyz[0])
const yError = Math.abs(Number(vals[1]) + xyz[1])
const zError = Math.abs(Number(vals[2]) + xyz[2])
let shouldRetry = false
if (xError > 5 || yError > 5 || zError > 5) {
if (cnt > 2) {
console.log('xVal', vals[0], 'xError', xError)
console.log('yVal', vals[1], 'yError', yError)
console.log('zVal', vals[2], 'zError', zError)
throw new Error('Camera position not as expected')
}
shouldRetry = true
}
await page.getByRole('button', { name: 'Exit Sketch' }).click()
await page.waitForTimeout(100)
if (shouldRetry) await bakeInRetries(mouseActions, xyz, cnt + 1)
}
await bakeInRetries(async () => {
await page.mouse.move(700, 200)
await page.mouse.down({ button: 'right' })
await page.mouse.move(600, 303)
await page.mouse.up({ button: 'right' })
}, [4, -10.5, -120])
await bakeInRetries(async () => {
await bakeInRetries(async () => {
await page.mouse.move(700, 400)
await page.mouse.wheel(0, -100)
}, [1, -68, -68])
})
test('Zoom should be consistent when exiting or entering sketches', async ({
page,
}) => {
// start new sketch pan and zoom before exiting, when exiting the sketch should stay in the same place
// than zoom and pan outside of sketch mode and enter again and it should not change from where it is
// than again for sketching
test.skip(process.platform !== 'darwin', 'Zoom should be consistent')
const u = await getUtils(page)
await page.setViewportSize({ width: 1200, height: 500 })
await page.goto('/')
await u.waitForAuthSkipAppStart()
await u.openDebugPanel()
await expect(
page.getByRole('button', { name: 'Start Sketch' })
).not.toBeDisabled()
await expect(
page.getByRole('button', { name: 'Start Sketch' })
).toBeVisible()
// click on "Start Sketch" button
await u.clearCommandLogs()
await page.getByRole('button', { name: 'Start Sketch' }).click()
await page.waitForTimeout(100)
// select a plane
await page.mouse.click(700, 325)
let code = `const sketch001 = startSketchOn('XY')`
await expect(u.codeLocator).toHaveText(code)
await u.closeDebugPanel()
await page.waitForTimeout(500) // TODO detect animation ending, or disable animation
// move the camera slightly
await page.keyboard.down('Shift')
await page.mouse.move(600, 200)
await page.mouse.move(700, 300)
await page.mouse.down({ button: 'right' })
await page.mouse.move(700, 200, { steps: 2 })
await page.mouse.move(800, 200)
await page.mouse.up({ button: 'right' })
await page.keyboard.up('Shift')
}, [-19, -85, -85])
const camCommand: EngineCommand = {
type: 'modeling_cmd_req',
cmd_id: uuidv4(),
cmd: {
type: 'default_camera_look_at',
center: { x: 0, y: 0, z: 0 },
vantage: { x: camPos[0], y: camPos[1], z: camPos[2] },
up: { x: 0, y: 0, z: 1 },
},
}
const updateCamCommand: EngineCommand = {
type: 'modeling_cmd_req',
cmd_id: uuidv4(),
cmd: {
type: 'default_camera_get_settings',
},
}
await u.sendCustomCmd(camCommand)
await page.waitForTimeout(100)
await u.sendCustomCmd(updateCamCommand)
await page.waitForTimeout(100)
let y = 350,
x = 948
await u.clearCommandLogs()
await u.closeDebugPanel()
await u.canvasLocator.click({ position: { x: 783, y } })
code += `\n |> startProfileAt([8.12, -12.98], %)`
// await expect(u.codeLocator).toHaveText(code)
await u.canvasLocator.click({ position: { x, y } })
code += `\n |> line([11.18, 0], %)`
// await expect(u.codeLocator).toHaveText(code)
await u.canvasLocator.click({ position: { x, y: 275 } })
code += `\n |> line([0, 6.99], %)`
// await expect(u.codeLocator).toHaveText(code)
await page.getByRole('button', { name: 'Start Sketch' }).click()
await page.waitForTimeout(200)
// click the line button
await page.getByRole('button', { name: 'Line' }).click()
// zoom
await u.doAndWaitForImageDiff(async () => {
await page.keyboard.down('Control')
await page.mouse.move(700, 400)
await page.mouse.down({ button: 'right' })
await page.mouse.move(700, 300)
await page.mouse.up({ button: 'right' })
await page.keyboard.up('Control')
const hoverOverNothing = async () => {
// await u.canvasLocator.hover({position: {x: 700, y: 325}})
await page.mouse.move(700, 325)
await page.waitForTimeout(100)
await expect(page.getByTestId('hover-highlight')).not.toBeVisible()
}
await u.openDebugPanel()
await page.waitForTimeout(300)
await u.clearCommandLogs()
await expect(page.getByTestId('hover-highlight')).not.toBeVisible()
await u.closeDebugPanel()
}, 300)
await page.waitForTimeout(100)
// hover over horizontal line
await u.canvasLocator.hover({ position: { x: 800, y } })
await expect(page.getByTestId('hover-highlight')).toBeVisible()
// zoom with scroll
await u.openAndClearDebugPanel()
// TODO, it appears we don't get the cam setting back from the engine when the interaction is zoom into `backInRetries` once the information is sent back on zoom
// await expect(Math.abs(Number(await page.getByTestId('cam-x-position').inputValue()) + 12)).toBeLessThan(1.5)
// await expect(Math.abs(Number(await page.getByTestId('cam-y-position').inputValue()) - 85)).toBeLessThan(1.5)
// await expect(Math.abs(Number(await page.getByTestId('cam-z-position').inputValue()) - 85)).toBeLessThan(1.5)
await hoverOverNothing()
// hover over vertical line
await u.canvasLocator.hover({ position: { x, y: 325 } })
await expect(page.getByTestId('hover-highlight')).toBeVisible()
await page.getByRole('button', { name: 'Exit Sketch' }).click()
await hoverOverNothing()
await bakeInRetries(async () => {
await page.mouse.move(700, 400)
await page.mouse.wheel(0, -100)
}, [1, -68, -68])
// click exit sketch
await page.getByRole('button', { name: 'Exit Sketch' }).click()
await page.waitForTimeout(400)
await hoverOverNothing()
await page.waitForTimeout(100)
// hover over horizontal line
await page.mouse.move(858, y, { steps: 5 })
await expect(page.getByTestId('hover-highlight')).toBeVisible()
await hoverOverNothing()
// hover over vertical line
await page.mouse.move(x, 325)
await expect(page.getByTestId('hover-highlight')).toBeVisible()
await hoverOverNothing()
// hover over vertical line
await page.mouse.move(857, y)
await expect(page.getByTestId('hover-highlight')).toBeVisible()
// now click it
await page.mouse.click(857, y)
await expect(
page.getByRole('button', { name: 'Edit Sketch' })
).toBeVisible()
await page.getByRole('button', { name: 'Edit Sketch' }).click()
await page.waitForTimeout(400)
await hoverOverNothing()
x = 975
y = 468
await page.waitForTimeout(100)
await page.mouse.move(x, 419, { steps: 5 })
await expect(page.getByTestId('hover-highlight')).toBeVisible()
await hoverOverNothing()
await page.mouse.move(855, y)
await expect(page.getByTestId('hover-highlight').first()).toBeVisible()
await hoverOverNothing()
await page.getByRole('button', { name: 'Exit Sketch' }).click()
await page.waitForTimeout(400)
await hoverOverNothing()
await page.mouse.move(x, 419)
await expect(page.getByTestId('hover-highlight')).toBeVisible()
await hoverOverNothing()
await page.mouse.move(855, y)
await expect(page.getByTestId('hover-highlight').first()).toBeVisible()
})
})
test('if you click the format button it formats your code', async ({
@ -411,6 +553,47 @@ test('ensure the Zoo logo is not a link in browser app', async ({ page }) => {
await expect(zooLogo).not.toHaveAttribute('href')
})
test('if you write kcl with lint errors you get lints', async ({ page }) => {
const u = await getUtils(page)
await page.setViewportSize({ width: 1000, height: 500 })
await page.goto('/')
await u.waitForAuthSkipAppStart()
// check no error to begin with
await expect(page.locator('.cm-lint-marker-info')).not.toBeVisible()
await u.codeLocator.click()
await page.keyboard.type('const my_snake_case_var = 5')
await page.keyboard.press('Enter')
await page.keyboard.type('const myCamelCaseVar = 5')
await page.keyboard.press('Enter')
// press arrows to clear autocomplete
await page.keyboard.press('ArrowLeft')
await page.keyboard.press('ArrowRight')
// error in guter
await expect(page.locator('.cm-lint-marker-info')).toBeVisible()
// error text on hover
await page.hover('.cm-lint-marker-info')
await expect(
page.getByText('Identifiers must be lowerCamelCase')
).toBeVisible()
// select the line that's causing the error and delete it
await page.getByText('const my_snake_case_var = 5').click()
await page.keyboard.press('End')
await page.keyboard.down('Shift')
await page.keyboard.press('Home')
await page.keyboard.up('Shift')
await page.keyboard.press('Backspace')
// wait for .cm-lint-marker-info not to be visible
await expect(page.locator('.cm-lint-marker-info')).not.toBeVisible()
})
test('if you write invalid kcl you get inlined errors', async ({ page }) => {
const u = await getUtils(page)
await page.setViewportSize({ width: 1000, height: 500 })
@ -421,8 +604,8 @@ test('if you write invalid kcl you get inlined errors', async ({ page }) => {
// check no error to begin with
await expect(page.locator('.cm-lint-marker-error')).not.toBeVisible()
/* add the following code to the editor (# error is not a valid line)
# error
/* add the following code to the editor ($ error is not a valid line)
$ error
const topAng = 30
const bottomAng = 25
*/
@ -463,6 +646,8 @@ test('if you write invalid kcl you get inlined errors', async ({ page }) => {
await page.keyboard.type("// Let's define the same thing twice")
await page.keyboard.press('Enter')
await page.keyboard.type('const topAng = 42')
await page.keyboard.press('ArrowLeft')
await page.keyboard.press('ArrowRight')
await expect(page.locator('.cm-lint-marker-error')).toBeVisible()
await expect(page.locator('.cm-lintRange.cm-lintRange-error')).toBeVisible()
@ -701,7 +886,7 @@ const sketchOnPlaneAndBackSideTest = async (
}
const code = `const sketch001 = startSketchOn('${plane}')
|> startProfileAt([1.14, -1.54], %)`
|> startProfileAt([0.9, -1.22], %)`
await u.openDebugPanel()
@ -1202,28 +1387,25 @@ test.describe('Testing selections', () => {
.toHaveText(`const sketch001 = startSketchOn('XZ')
|> startProfileAt(${commonPoints.startAt}, %)
|> line([${commonPoints.num1}, 0], %)
|> line([0, ${commonPoints.num1}], %)`)
|> line([0, ${commonPoints.num1 + 0.01}], %)`)
await page.waitForTimeout(100)
await page.mouse.click(startXPx, 500 - PUR * 20)
await expect(page.locator('.cm-content'))
.toHaveText(`const sketch001 = startSketchOn('XZ')
|> startProfileAt(${commonPoints.startAt}, %)
|> line([${commonPoints.num1}, 0], %)
|> line([0, ${commonPoints.num1}], %)
|> line([0, ${commonPoints.num1 + 0.01}], %)
|> line([-${commonPoints.num2}, 0], %)`)
// deselect line tool
await page.getByRole('button', { name: 'Line' }).click()
await u.closeDebugPanel()
const selectionSequence = async (isSecondTime = false) => {
const selectionSequence = async () => {
await expect(page.getByTestId('hover-highlight')).not.toBeVisible()
await page.waitForTimeout(100)
await page.mouse.move(
startXPx + PUR * 15,
isSecondTime ? 430 : 500 - PUR * 10
)
await page.mouse.move(startXPx + PUR * 15, 500 - PUR * 10)
await expect(page.getByTestId('hover-highlight')).toBeVisible()
// bg-yellow-200 is more brittle than hover-highlight, but is closer to the user experience
@ -1233,10 +1415,7 @@ test.describe('Testing selections', () => {
// check mousing off, than mousing onto another line
await page.mouse.move(startXPx + PUR * 10, 500 - PUR * 15) // mouse off
await expect(page.getByTestId('hover-highlight')).not.toBeVisible()
await page.mouse.move(
startXPx + PUR * 10,
isSecondTime ? 295 : 500 - PUR * 20
) // mouse onto another line
await page.mouse.move(startXPx + PUR * 10, 500 - PUR * 20) // mouse onto another line
await expect(page.getByTestId('hover-highlight').first()).toBeVisible()
// now check clicking works including axis
@ -1333,8 +1512,33 @@ test.describe('Testing selections', () => {
await page.waitForTimeout(300) // wait for animation
await u.openAndClearDebugPanel()
await u.sendCustomCmd({
type: 'modeling_cmd_req',
cmd_id: uuidv4(),
cmd: {
type: 'default_camera_look_at',
center: { x: 0, y: 0, z: 0 },
vantage: { x: 0, y: -1378.01, z: 0 },
up: { x: 0, y: 0, z: 1 },
},
})
await page.waitForTimeout(100)
await u.sendCustomCmd({
type: 'modeling_cmd_req',
cmd_id: uuidv4(),
cmd: {
type: 'default_camera_get_settings',
},
})
await page.waitForTimeout(100)
await emptySpaceClick()
await u.closeDebugPanel()
// hover again and check it works
await selectionSequence(true)
await selectionSequence()
})
test('Hovering over 3d features highlights code', async ({ page }) => {
@ -1718,6 +1922,7 @@ test('Can add multiple sketches', async ({ page }) => {
await expect(u.codeLocator).toHaveText(codeStr)
await click00r(50, 0)
await page.waitForTimeout(100)
codeStr += ` |> line(${toSU([50, 0])}, %)`
await expect(u.codeLocator).toHaveText(codeStr)
@ -1742,26 +1947,26 @@ test('Can add multiple sketches', async ({ page }) => {
// when exiting the sketch above the camera is still looking down at XY,
// so selecting the plane again is a bit easier.
await page.mouse.click(center.x + 30, center.y)
await page.waitForTimeout(500) // TODO detect animation ending, or disable animation
await page.mouse.click(center.x + 200, center.y + 100)
await page.waitForTimeout(600) // TODO detect animation ending, or disable animation
codeStr += "const sketch002 = startSketchOn('XY')"
await expect(u.codeLocator).toHaveText(codeStr)
await u.closeDebugPanel()
await click00r(30, 0)
codeStr += ` |> startProfileAt(${toSU([30, 0])}, %)`
codeStr += ` |> startProfileAt([1.53, 0], %)`
await expect(u.codeLocator).toHaveText(codeStr)
await click00r(30, 0)
codeStr += ` |> line(${toSU([30 + 0.1 /* imprecision */, 0])}, %)`
codeStr += ` |> line([1.53, 0], %)`
await expect(u.codeLocator).toHaveText(codeStr)
await click00r(0, 30)
codeStr += ` |> line(${toSU([0, 30])}, %)`
codeStr += ` |> line([0, -1.53], %)`
await expect(u.codeLocator).toHaveText(codeStr)
await click00r(-30, 0)
codeStr += ` |> line(${toSU([-30 - 0.1, 0])}, %)`
codeStr += ` |> line([-1.53, 0], %)`
await expect(u.codeLocator).toHaveText(codeStr)
click00r(undefined, undefined)
@ -1769,7 +1974,6 @@ test('Can add multiple sketches', async ({ page }) => {
await page.getByRole('button', { name: 'Exit Sketch' }).click()
await u.expectCmdLog('[data-message-type="execution-done"]')
await u.updateCamPosition([100, 100, 100])
await page.waitForTimeout(250)
await u.clearCommandLogs()
})
@ -2174,9 +2378,9 @@ const doSnapAtDifferentScales = async (
await u.openDebugPanel()
const code = `const sketch001 = startSketchOn('-XZ')
|> startProfileAt([${roundOff(scale * 87.68)}, ${roundOff(scale * 43.84)}], %)
|> line([${roundOff(scale * 175.36)}, 0], %)
|> line([0, -${roundOff(scale * 175.36) + fudge}], %)
|> startProfileAt([${roundOff(scale * 69.6)}, ${roundOff(scale * 34.8)}], %)
|> line([${roundOff(scale * 139.19)}, 0], %)
|> line([0, -${roundOff(scale * 139.2)}], %)
|> lineTo([profileStartX(%), profileStartY(%)], %)
|> close(%)`
@ -2206,6 +2410,7 @@ const doSnapAtDifferentScales = async (
const pointC = [900, 400]
// draw three lines
await page.waitForTimeout(500)
await page.mouse.click(pointA[0], pointA[1])
await page.waitForTimeout(100)
await expect(page.locator('.cm-content')).not.toHaveText(prevContent)
@ -2341,11 +2546,8 @@ test('Sketch on face', async ({ page }) => {
await page.getByText('startProfileAt([-12.94, 6.6], %)').click()
await expect(page.getByRole('button', { name: 'Edit Sketch' })).toBeVisible()
await u.doAndWaitForCmd(
() => page.getByRole('button', { name: 'Edit Sketch' }).click(),
'default_camera_get_settings',
true
)
await page.getByRole('button', { name: 'Edit Sketch' }).click()
await page.waitForTimeout(400)
await page.waitForTimeout(150)
await page.setViewportSize({ width: 1200, height: 1200 })
await u.openAndClearDebugPanel()
@ -4615,13 +4817,15 @@ test('Basic default modeling and sketch hotkeys work', async ({ page }) => {
await expect(
page.getByRole('button', { name: 'Exit Sketch' })
).not.toBeVisible()
await page.waitForTimeout(400)
// Extrude
await page.mouse.click(750, 150)
await expect(extrudeButton).not.toBeDisabled()
await page.keyboard.press('e')
await page.mouse.move(730, 230, { steps: 5 })
await page.mouse.click(730, 230)
await page.waitForTimeout(100)
await page.mouse.move(900, 200, { steps: 5 })
await page.mouse.click(900, 200)
await page.waitForTimeout(100)
await page.getByRole('button', { name: 'Continue' }).click()
await page.getByRole('button', { name: 'Submit command' }).click()
@ -4725,7 +4929,7 @@ test('Engine disconnect & reconnect in sketch mode', async ({ page }) => {
)
await u.closeDebugPanel()
await page.waitForTimeout(300) // TODO detect animation ending, or disable animation
await page.waitForTimeout(500) // TODO detect animation ending, or disable animation
const startXPx = 600
await page.mouse.click(startXPx + PUR * 10, 500 - PUR * 10)
@ -4804,15 +5008,15 @@ test('Engine disconnect & reconnect in sketch mode', async ({ page }) => {
.toHaveText(`const sketch001 = startSketchOn('XZ')
|> startProfileAt(${commonPoints.startAt}, %)
|> line([${commonPoints.num1}, 0], %)
|> line([-11.64, 11.11], %)`)
|> line([-9.16, 8.81], %)`)
await page.waitForTimeout(100)
await page.mouse.click(startXPx, 500 - PUR * 20)
await expect(page.locator('.cm-content'))
.toHaveText(`const sketch001 = startSketchOn('XZ')
|> startProfileAt(${commonPoints.startAt}, %)
|> line([${commonPoints.num1}, 0], %)
|> line([-11.64, 11.11], %)
|> line([-6.56, 0], %)`)
|> line([-9.16, 8.81], %)
|> line([-5.28, 0], %)`)
// Unequip line tool
await page.keyboard.press('Escape')

View File

@ -405,17 +405,16 @@ test('Draft segments should look right', async ({ page, context }) => {
// select a plane
await page.mouse.click(700, 200)
await expect(page.locator('.cm-content')).toHaveText(
`const sketch001 = startSketchOn('XZ')`
)
let code = `const sketch001 = startSketchOn('XZ')`
await expect(page.locator('.cm-content')).toHaveText(code)
await page.waitForTimeout(300) // TODO detect animation ending, or disable animation
await page.waitForTimeout(700) // TODO detect animation ending, or disable animation
const startXPx = 600
await page.mouse.click(startXPx + PUR * 10, 500 - PUR * 10)
await expect(page.locator('.cm-content'))
.toHaveText(`const sketch001 = startSketchOn('XZ')
|> startProfileAt([9.06, -12.22], %)`)
code += `
|> startProfileAt([7.19, -9.7], %)`
await expect(page.locator('.cm-content')).toHaveText(code)
await page.waitForTimeout(100)
await u.closeDebugPanel()
@ -427,10 +426,9 @@ test('Draft segments should look right', async ({ page, context }) => {
await page.mouse.click(startXPx + PUR * 20, 500 - PUR * 10)
await page.waitForTimeout(100)
await expect(page.locator('.cm-content'))
.toHaveText(`const sketch001 = startSketchOn('XZ')
|> startProfileAt([9.06, -12.22], %)
|> line([9.14, 0], %)`)
code += `
|> line([7.25, 0], %)`
await expect(page.locator('.cm-content')).toHaveText(code)
await page.getByRole('button', { name: 'Tangential Arc' }).click()
@ -513,17 +511,16 @@ test.describe('Client side scene scale should match engine scale', () => {
// select a plane
await page.mouse.click(700, 200)
await expect(page.locator('.cm-content')).toHaveText(
`const sketch001 = startSketchOn('XZ')`
)
let code = `const sketch001 = startSketchOn('XZ')`
await expect(page.locator('.cm-content')).toHaveText(code)
await page.waitForTimeout(300) // TODO detect animation ending, or disable animation
await page.waitForTimeout(600) // TODO detect animation ending, or disable animation
const startXPx = 600
await page.mouse.click(startXPx + PUR * 10, 500 - PUR * 10)
await expect(page.locator('.cm-content'))
.toHaveText(`const sketch001 = startSketchOn('XZ')
|> startProfileAt([9.06, -12.22], %)`)
code += `
|> startProfileAt([7.19, -9.7], %)`
await expect(u.codeLocator).toHaveText(code)
await page.waitForTimeout(100)
await u.closeDebugPanel()
@ -531,21 +528,18 @@ test.describe('Client side scene scale should match engine scale', () => {
await page.mouse.click(startXPx + PUR * 20, 500 - PUR * 10)
await page.waitForTimeout(100)
await expect(page.locator('.cm-content'))
.toHaveText(`const sketch001 = startSketchOn('XZ')
|> startProfileAt([9.06, -12.22], %)
|> line([9.14, 0], %)`)
code += `
|> line([7.25, 0], %)`
await expect(u.codeLocator).toHaveText(code)
await page.getByRole('button', { name: 'Tangential Arc' }).click()
await page.waitForTimeout(100)
await page.mouse.click(startXPx + PUR * 30, 500 - PUR * 20)
await expect(page.locator('.cm-content'))
.toHaveText(`const sketch001 = startSketchOn('XZ')
|> startProfileAt([9.06, -12.22], %)
|> line([9.14, 0], %)
|> tangentialArcTo([27.34, -3.08], %)`)
code += `
|> tangentialArcTo([21.7, -2.44], %)`
await expect(u.codeLocator).toHaveText(code)
// click tangential arc tool again to unequip it
await page.getByRole('button', { name: 'Tangential Arc' }).click()
@ -616,17 +610,16 @@ test.describe('Client side scene scale should match engine scale', () => {
// select a plane
await page.mouse.click(700, 200)
await expect(page.locator('.cm-content')).toHaveText(
`const sketch001 = startSketchOn('XZ')`
)
let code = `const sketch001 = startSketchOn('XZ')`
await expect(u.codeLocator).toHaveText(code)
await page.waitForTimeout(300) // TODO detect animation ending, or disable animation
await page.waitForTimeout(600) // TODO detect animation ending, or disable animation
const startXPx = 600
await page.mouse.click(startXPx + PUR * 10, 500 - PUR * 10)
await expect(page.locator('.cm-content'))
.toHaveText(`const sketch001 = startSketchOn('XZ')
|> startProfileAt([230.03, -310.32], %)`)
code += `
|> startProfileAt([182.59, -246.32], %)`
await expect(u.codeLocator).toHaveText(code)
await page.waitForTimeout(100)
await u.closeDebugPanel()
@ -634,21 +627,18 @@ test.describe('Client side scene scale should match engine scale', () => {
await page.mouse.click(startXPx + PUR * 20, 500 - PUR * 10)
await page.waitForTimeout(100)
await expect(page.locator('.cm-content'))
.toHaveText(`const sketch001 = startSketchOn('XZ')
|> startProfileAt([230.03, -310.32], %)
|> line([232.2, 0], %)`)
code += `
|> line([184.3, 0], %)`
await expect(u.codeLocator).toHaveText(code)
await page.getByRole('button', { name: 'Tangential Arc' }).click()
await page.waitForTimeout(100)
await page.mouse.click(startXPx + PUR * 30, 500 - PUR * 20)
await expect(page.locator('.cm-content'))
.toHaveText(`const sketch001 = startSketchOn('XZ')
|> startProfileAt([230.03, -310.32], %)
|> line([232.2, 0], %)
|> tangentialArcTo([694.43, -78.12], %)`)
code += `
|> tangentialArcTo([551.2, -62.01], %)`
await expect(u.codeLocator).toHaveText(code)
await page.getByRole('button', { name: 'Tangential Arc' }).click()
await page.waitForTimeout(100)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 36 KiB

View File

@ -132,8 +132,8 @@ export const getMovementUtils = (opts: any) => {
// NOTE: these pretty much can't be perfect because of screen scaling.
// Handle on a case-by-case.
const toU = (x: number, y: number) => [
kcRound(x * 0.0854),
kcRound(-y * 0.0854), // Y is inverted in our coordinate system
kcRound(x * 0.0678),
kcRound(-y * 0.0678), // Y is inverted in our coordinate system
]
// Turn the array into a string with specific formatting
@ -226,6 +226,7 @@ export async function getUtils(page: Page) {
.boundingBox()
.then((box) => ({ ...box, x: box?.x || 0, y: box?.y || 0 })),
codeLocator: page.locator('.cm-content'),
canvasLocator: page.getByTestId('client-side-scene'),
doAndWaitForCmd: async (
fn: () => Promise<void>,
commandType: string,

62
flake.lock generated Normal file
View File

@ -0,0 +1,62 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1718470082,
"narHash": "sha256-u2F0MMYE+Efc+ocruTbtU/wWHuYHWcJafp5zJ++n/YE=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "3027ba73dfef68eb555fc2fa97aed4e999e74f97",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs_2": {
"locked": {
"lastModified": 1718428119,
"narHash": "sha256-WdWDpNaq6u1IPtxtYHHWpl5BmabtpmLnMAx0RdJ/vo8=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "e6cea36f83499eb4e9cd184c8a8e823296b50ad5",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs",
"rust-overlay": "rust-overlay"
}
},
"rust-overlay": {
"inputs": {
"nixpkgs": "nixpkgs_2"
},
"locked": {
"lastModified": 1718681902,
"narHash": "sha256-E/T7Ge6ayEQe7FVKMJqDBoHyLhRhjc6u9CmU8MyYfy0=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "16c8ad83297c278eebe740dea5491c1708960dd1",
"type": "github"
},
"original": {
"owner": "oxalica",
"repo": "rust-overlay",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

70
flake.nix Normal file
View File

@ -0,0 +1,70 @@
{
description = "modeling-app development environment";
# Flake inputs
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
rust-overlay.url = "github:oxalica/rust-overlay"; # A helper for Rust + Nix
};
# Flake outputs
outputs = { self, nixpkgs, rust-overlay }:
let
# Overlays enable you to customize the Nixpkgs attribute set
overlays = [
# Makes a `rust-bin` attribute available in Nixpkgs
(import rust-overlay)
# Provides a `rustToolchain` attribute for Nixpkgs that we can use to
# create a Rust environment
(self: super: {
rustToolchain = super. rust-bin.stable.latest.default.override {
targets = [ "wasm32-unknown-unknown" ];
extensions = [ "rustfmt" "llvm-tools-preview" ];
};
})
];
# Systems supported
allSystems = [
"x86_64-linux" # 64-bit Intel/AMD Linux
"aarch64-linux" # 64-bit ARM Linux
"x86_64-darwin" # 64-bit Intel macOS
"aarch64-darwin" # 64-bit ARM macOS
];
# Helper to provide system-specific attributes
forAllSystems = f: nixpkgs.lib.genAttrs allSystems (system: f {
pkgs = import nixpkgs { inherit overlays system; };
});
in
{
# Development environment output
devShells = forAllSystems ({ pkgs }: {
default = pkgs.mkShell {
# The Nix packages provided in the environment
packages = (with pkgs; [
# The package provided by our custom overlay. Includes cargo, Clippy, cargo-fmt,
# rustdoc, rustfmt, and other tools.
rustToolchain
cargo-llvm-cov
cargo-nextest
just
postgresql.lib
openssl
pkg-config
nodejs_22
]) ++ pkgs.lib.optionals pkgs.stdenv.isDarwin (with pkgs; [
libiconv
darwin.apple_sdk.frameworks.Security
]);
TARGET_CC = "${pkgs.stdenv.cc}/bin/${pkgs.stdenv.cc.targetPrefix}cc";
LIBCLANG_PATH = "${pkgs.libclang.lib}/lib";
};
});
};
}

View File

@ -1,6 +1,6 @@
{
"name": "untitled-app",
"version": "0.22.1",
"version": "0.22.2",
"private": true,
"dependencies": {
"@codemirror/autocomplete": "^6.16.0",
@ -10,7 +10,7 @@
"@fortawesome/react-fontawesome": "^0.2.0",
"@headlessui/react": "^1.7.19",
"@headlessui/tailwindcss": "^0.2.0",
"@kittycad/lib": "^0.0.64",
"@kittycad/lib": "^0.0.67",
"@lezer/javascript": "^1.4.9",
"@open-rpc/client-js": "^1.8.1",
"@react-hook/resize-observer": "^2.0.1",

1666
src-tauri/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -16,11 +16,11 @@ tauri-build = { version = "2.0.0-beta.13", features = [] }
[dependencies]
anyhow = "1"
kcl-lib = { version = "0.1.53", path = "../src/wasm-lib/kcl" }
kittycad = "0.3.0"
kittycad = "0.3.5"
log = "0.4.21"
oauth2 = "4.4.2"
serde_json = "1.0"
tauri = { version = "2.0.0-beta.15", features = [ "devtools", "unstable"] }
tauri = { version = "2.0.0-beta.22", features = [ "devtools", "unstable"] }
tauri-plugin-cli = { version = "2.0.0-beta.3" }
tauri-plugin-deep-link = { version = "2.0.0-beta.3" }
tauri-plugin-dialog = { version = "2.0.0-beta.6" }

View File

@ -63,16 +63,17 @@
"subcommands": {}
},
"deep-link": {
"domains": [
{
"host": "app.zoo.dev"
}
]
"mobile": [],
"desktop": {
"schemes": [
"app.zoo.dev"
]
}
},
"shell": {
"open": true
}
},
"productName": "Zoo Modeling App",
"version": "0.22.1"
"version": "0.22.2"
}

View File

@ -127,7 +127,7 @@ export function App() {
/>
<ModalContainer />
<ModelingSidebar paneOpacity={paneOpacity} />
<Stream className="absolute inset-0 z-0" />
<Stream />
{/* <CamToggle /> */}
<LowerRightControls>
<Gizmo />

View File

@ -174,41 +174,6 @@ export class CameraControls {
}
}
throttledUpdateEngineFov = throttle(
(vals: {
position: Vector3
quaternion: Quaternion
zoom: number
fov: number
target: Vector3
}) => {
const cmd: EngineCommand = {
type: 'modeling_cmd_req',
cmd_id: uuidv4(),
cmd: {
type: 'default_camera_perspective_settings',
...convertThreeCamValuesToEngineCam({
...vals,
isPerspective: true,
}),
fov_y: vals.fov,
...calculateNearFarFromFOV(vals.fov),
},
}
this.engineCommandManager.sendSceneCommand(cmd)
this.lastPerspectiveCmd = cmd
this.lastPerspectiveCmdTime = Date.now()
if (this.lastPerspectiveCmdTimeoutId !== null) {
clearTimeout(this.lastPerspectiveCmdTimeoutId)
}
this.lastPerspectiveCmdTimeoutId = setTimeout(
this.sendLastPerspectiveReliableChannel,
lastCmdDelay
) as any as number
},
1000 / 30
)
constructor(
isOrtho = false,
domElement: HTMLCanvasElement,
@ -534,26 +499,28 @@ export class CameraControls {
direction.normalize()
this.camera.position.copy(this.target).addScaledVector(direction, distance)
}
usePerspectiveCamera = () => {
usePerspectiveCamera = async () => {
this._usePerspectiveCamera()
this.engineCommandManager.sendSceneCommand({
type: 'modeling_cmd_req',
cmd_id: uuidv4(),
cmd: {
type: 'default_camera_set_perspective',
parameters: {
fov_y:
this.camera instanceof PerspectiveCamera ? this.camera.fov : 45,
...calculateNearFarFromFOV(this.lastPerspectiveFov),
if (this.syncDirection === 'clientToEngine') {
await this.engineCommandManager.sendSceneCommand({
type: 'modeling_cmd_req',
cmd_id: uuidv4(),
cmd: {
type: 'default_camera_set_perspective',
parameters: {
fov_y:
this.camera instanceof PerspectiveCamera ? this.camera.fov : 45,
...calculateNearFarFromFOV(this.lastPerspectiveFov),
},
},
},
})
})
}
this.onCameraChange()
this.update()
return this.camera
}
dollyZoom = (newFov: number) => {
dollyZoom = async (newFov: number, splitEngineCalls = false) => {
if (!(this.camera instanceof PerspectiveCamera)) {
console.warn('Dolly zoom is only applicable to perspective cameras.')
return
@ -604,13 +571,52 @@ export class CameraControls {
this.camera.near = z_near
this.camera.far = z_far
this.throttledUpdateEngineFov({
fov: newFov,
position: newPosition,
quaternion: this.camera.quaternion,
zoom: this.camera.zoom,
target: this.target,
})
if (splitEngineCalls) {
await this.engineCommandManager.sendSceneCommand({
type: 'modeling_cmd_req',
cmd_id: uuidv4(),
cmd: {
type: 'default_camera_look_at',
...convertThreeCamValuesToEngineCam({
isPerspective: true,
position: newPosition,
quaternion: this.camera.quaternion,
zoom: this.camera.zoom,
target: this.target,
}),
},
})
await this.engineCommandManager.sendSceneCommand({
type: 'modeling_cmd_req',
cmd_id: uuidv4(),
cmd: {
type: 'default_camera_set_perspective',
parameters: {
fov_y: newFov,
z_near: 0.01,
z_far: 1000,
},
},
})
} else {
await this.engineCommandManager.sendSceneCommand({
type: 'modeling_cmd_req',
cmd_id: uuidv4(),
cmd: {
type: 'default_camera_perspective_settings',
...convertThreeCamValuesToEngineCam({
isPerspective: true,
position: newPosition,
quaternion: this.camera.quaternion,
zoom: this.camera.zoom,
target: this.target,
}),
fov_y: newFov,
z_near: 0.01,
z_far: 1000,
},
})
}
}
update = (forceUpdate = false) => {
@ -1015,6 +1021,29 @@ export class CameraControls {
.onComplete(onComplete)
.start()
})
snapToPerspectiveBeforeHandingBackControlToEngine = async (
targetCamUp = new Vector3(0, 0, 1)
) => {
if (this.syncDirection === 'engineToClient') {
console.warn(
'animate To Perspective not design to work with engineToClient syncDirection.'
)
}
this.isFovAnimationInProgress = true
const targetFov = this.fovBeforeOrtho // Target FOV for perspective
this.lastPerspectiveFov = 4
let currentFov = 4
const initialCameraUp = this.camera.up.clone()
this.usePerspectiveCamera()
const tempVec = new Vector3()
currentFov = this.lastPerspectiveFov + (targetFov - this.lastPerspectiveFov)
const currentUp = tempVec.lerpVectors(initialCameraUp, targetCamUp, 1)
this.camera.up.copy(currentUp)
await this.dollyZoom(currentFov, true)
this.isFovAnimationInProgress = false
}
get reactCameraProperties(): ReactCameraProperties {
return {
@ -1087,7 +1116,7 @@ function calculateNearFarFromFOV(fov: number) {
// const nearFarRatio = (fov - 3) / (45 - 3)
// const z_near = 0.1 + nearFarRatio * (5 - 0.1)
// const z_far = 1000 + nearFarRatio * (100000 - 1000)
return { z_near: 0.1, z_far: 1000 }
return { z_near: 0.01, z_far: 1000 }
}
function convertThreeCamValuesToEngineCam({
@ -1106,11 +1135,6 @@ function convertThreeCamValuesToEngineCam({
// leaving for now since it's working but maybe revisit later
const euler = new Euler().setFromQuaternion(quaternion, 'XYZ')
const lookAtVector = new Vector3(0, 0, -1)
.applyEuler(euler)
.normalize()
.add(position)
const upVector = new Vector3(0, 1, 0).applyEuler(euler).normalize()
if (isPerspective) {
return {
@ -1119,6 +1143,10 @@ function convertThreeCamValuesToEngineCam({
vantage: position,
}
}
const lookAtVector = new Vector3(0, 0, -1)
.applyEuler(euler)
.normalize()
.add(position)
const fudgeFactor2 = zoom * 0.9979224466814468 - 0.03473692325839295
const zoomFactor = (-ZOOM_MAGIC_NUMBER + fudgeFactor2) / zoom
const direction = lookAtVector.clone().sub(position).normalize()

View File

@ -136,6 +136,7 @@ export const ClientSideScene = ({
<div
ref={canvasRef}
style={{ cursor: cursor }}
data-testid="client-side-scene"
className={`absolute inset-0 h-full w-full transition-all duration-300 ${
hideClient ? 'opacity-0' : 'opacity-100'
} ${hideServer ? 'bg-chalkboard-10 dark:bg-chalkboard-100' : ''} ${

View File

@ -1329,13 +1329,6 @@ export class SceneEntities {
to,
})
}
async animateAfterSketch() {
// if (isReducedMotion()) {
// sceneInfra.camControls.usePerspectiveCamera()
// return
// }
await sceneInfra.camControls.animateToPerspective()
}
removeSketchGrid() {
if (this.axisGroup) this.scene.remove(this.axisGroup)
}
@ -1399,114 +1392,135 @@ export class SceneEntities {
selected.material.color = defaultPlaneColor(type)
},
onClick: async (args) => {
const checkExtrudeFaceClick = async (): Promise<
['face' | 'plane' | 'other', string]
> => {
const { streamDimensions } = useStore.getState()
const { entity_id } = await sendSelectEventToEngine(
args?.mouseEvent,
document.getElementById('video-stream') as HTMLVideoElement,
streamDimensions
)
if (!entity_id) return ['other', '']
if (
engineCommandManager.defaultPlanes?.xy === entity_id ||
engineCommandManager.defaultPlanes?.xz === entity_id ||
engineCommandManager.defaultPlanes?.yz === entity_id
) {
return ['plane', entity_id]
const { streamDimensions } = useStore.getState()
const { entity_id, ...rest } = await sendSelectEventToEngine(
args?.mouseEvent,
document.getElementById('video-stream') as HTMLVideoElement,
streamDimensions
)
let _entity_id = entity_id
console.log('things', _entity_id, rest)
if (!_entity_id) return
if (
engineCommandManager.defaultPlanes?.xy === _entity_id ||
engineCommandManager.defaultPlanes?.xz === _entity_id ||
engineCommandManager.defaultPlanes?.yz === _entity_id ||
engineCommandManager.defaultPlanes?.negXy === _entity_id ||
engineCommandManager.defaultPlanes?.negXz === _entity_id ||
engineCommandManager.defaultPlanes?.negYz === _entity_id
) {
const defaultPlaneStrMap: Record<string, DefaultPlaneStr> = {
[engineCommandManager.defaultPlanes.xy]: 'XY',
[engineCommandManager.defaultPlanes.xz]: 'XZ',
[engineCommandManager.defaultPlanes.yz]: 'YZ',
[engineCommandManager.defaultPlanes.negXy]: '-XY',
[engineCommandManager.defaultPlanes.negXz]: '-XZ',
[engineCommandManager.defaultPlanes.negYz]: '-YZ',
}
const artifact = this.engineCommandManager.artifactMap[entity_id]
// If we clicked on an extrude wall, we climb up the parent Id
// to get the sketch profile's face ID. If we clicked on an endcap,
// we already have it.
const targetId =
'additionalData' in artifact &&
artifact.additionalData?.type === 'cap'
? entity_id
: artifact.parentId
// TODO can we get this information from rust land when it creates the default planes?
// maybe returned from make_default_planes (src/wasm-lib/src/wasm.rs)
let zAxis: [number, number, number] = [0, 0, 1]
let yAxis: [number, number, number] = [0, 1, 0]
// tsc cannot infer that target can have extrusions
// from the commandType (why?) so we need to cast it
const target = this.engineCommandManager.artifactMap?.[
targetId || ''
] as ArtifactMapCommand & { extrusions?: string[] }
// get unit vector from camera position to target
const camVector = sceneInfra.camControls.camera.position
.clone()
.sub(sceneInfra.camControls.target)
// TODO: We get the first extrusion command ID,
// which is fine while backend systems only support one extrusion.
// but we need to more robustly handle resolving to the correct extrusion
// if there are multiple.
const extrusions =
this.engineCommandManager.artifactMap?.[
target?.extrusions?.[0] || ''
]
if (artifact?.commandType !== 'solid3d_get_extrusion_face_info')
return ['other', entity_id]
const faceInfo = await getFaceDetails(entity_id)
if (!faceInfo?.origin || !faceInfo?.z_axis || !faceInfo?.y_axis)
return ['other', entity_id]
const { z_axis, y_axis, origin } = faceInfo
const sketchPathToNode = getNodePathFromSourceRange(
kclManager.ast,
artifact.range
)
const extrudePathToNode = extrusions?.range
? getNodePathFromSourceRange(kclManager.ast, extrusions.range)
: []
if (engineCommandManager.defaultPlanes?.xy === _entity_id) {
console.log('XY')
zAxis = [0, 0, 1]
yAxis = [0, 1, 0]
if (camVector.z < 0) {
zAxis = [0, 0, -1]
_entity_id = engineCommandManager.defaultPlanes?.negXy || ''
}
} else if (engineCommandManager.defaultPlanes?.yz === _entity_id) {
console.log('YZ')
zAxis = [1, 0, 0]
yAxis = [0, 0, 1]
if (camVector.x < 0) {
zAxis = [-1, 0, 0]
_entity_id = engineCommandManager.defaultPlanes?.negYz || ''
}
} else if (engineCommandManager.defaultPlanes?.xz === _entity_id) {
console.log('XZ')
zAxis = [0, 1, 0]
yAxis = [0, 0, 1]
_entity_id = engineCommandManager.defaultPlanes?.negXz || ''
if (camVector.y < 0) {
zAxis = [0, -1, 0]
_entity_id = engineCommandManager.defaultPlanes?.xz || ''
}
}
sceneInfra.modelingSend({
type: 'Select default plane',
data: {
type: 'extrudeFace',
zAxis: [z_axis.x, z_axis.y, z_axis.z],
yAxis: [y_axis.x, y_axis.y, y_axis.z],
position: [origin.x, origin.y, origin.z].map(
(num) => num / sceneInfra._baseUnitMultiplier
) as [number, number, number],
sketchPathToNode,
extrudePathToNode,
cap:
artifact?.additionalData?.type === 'cap'
? artifact.additionalData.info
: 'none',
faceId: entity_id,
type: 'defaultPlane',
planeId: _entity_id,
plane: defaultPlaneStrMap[_entity_id],
zAxis,
yAxis,
},
})
return ['face', entity_id]
return
}
const artifact = this.engineCommandManager.artifactMap[_entity_id]
// If we clicked on an extrude wall, we climb up the parent Id
// to get the sketch profile's face ID. If we clicked on an endcap,
// we already have it.
const targetId =
'additionalData' in artifact &&
artifact.additionalData?.type === 'cap'
? _entity_id
: artifact.parentId
const faceResult = await checkExtrudeFaceClick()
if (faceResult[0] === 'face') return
// tsc cannot infer that target can have extrusions
// from the commandType (why?) so we need to cast it
const target = this.engineCommandManager.artifactMap?.[
targetId || ''
] as ArtifactMapCommand & { extrusions?: string[] }
// TODO: We get the first extrusion command ID,
// which is fine while backend systems only support one extrusion.
// but we need to more robustly handle resolving to the correct extrusion
// if there are multiple.
const extrusions =
this.engineCommandManager.artifactMap?.[target?.extrusions?.[0] || '']
if (artifact?.commandType !== 'solid3d_get_extrusion_face_info') return
const faceInfo = await getFaceDetails(_entity_id)
if (!faceInfo?.origin || !faceInfo?.z_axis || !faceInfo?.y_axis) return
const { z_axis, y_axis, origin } = faceInfo
const sketchPathToNode = getNodePathFromSourceRange(
kclManager.ast,
artifact.range
)
const extrudePathToNode = extrusions?.range
? getNodePathFromSourceRange(kclManager.ast, extrusions.range)
: []
if (!args || !args.intersects?.[0]) return
if (args.mouseEvent.which !== 1) return
const { intersects } = args
const type = intersects?.[0].object.name || ''
const posNorm = Number(intersects?.[0]?.normal?.z) > 0
let planeString: DefaultPlaneStr = posNorm ? 'XY' : '-XY'
let zAxis: [number, number, number] = posNorm ? [0, 0, 1] : [0, 0, -1]
let yAxis: [number, number, number] = [0, 1, 0]
if (type === YZ_PLANE) {
planeString = posNorm ? 'YZ' : '-YZ'
zAxis = posNorm ? [1, 0, 0] : [-1, 0, 0]
yAxis = [0, 0, 1]
} else if (type === XZ_PLANE) {
planeString = posNorm ? '-XZ' : 'XZ'
zAxis = posNorm ? [0, 1, 0] : [0, -1, 0]
yAxis = [0, 0, 1]
}
sceneInfra.modelingSend({
type: 'Select default plane',
data: {
type: 'defaultPlane',
plane: planeString,
zAxis,
yAxis,
planeId: faceResult[1],
type: 'extrudeFace',
zAxis: [z_axis.x, z_axis.y, z_axis.z],
yAxis: [y_axis.x, y_axis.y, y_axis.z],
position: [origin.x, origin.y, origin.z].map(
(num) => num / sceneInfra._baseUnitMultiplier
) as [number, number, number],
sketchPathToNode,
extrudePathToNode,
cap:
artifact?.additionalData?.type === 'cap'
? artifact.additionalData.info
: 'none',
faceId: _entity_id,
},
})
return
},
})
}

View File

@ -6,6 +6,8 @@ import CommandComboBox from '../CommandComboBox'
import CommandBarReview from './CommandBarReview'
import { useLocation } from 'react-router-dom'
import useHotkeyWrapper from 'lib/hotkeyWrapper'
import { CustomIcon } from 'components/CustomIcon'
import Tooltip from 'components/Tooltip'
export const CommandBar = () => {
const { pathname } = useLocation()
@ -103,7 +105,7 @@ export const CommandBar = () => {
leaveTo="opacity-0 scale-95"
>
<WrapperComponent.Panel
className="relative z-50 pointer-events-auto w-full max-w-xl py-2 mx-auto border rounded shadow-lg bg-chalkboard-10 dark:bg-chalkboard-100 dark:border-chalkboard-70"
className="relative z-50 pointer-events-auto w-full max-w-xl py-2 mx-auto border rounded rounded-tl-none shadow-lg bg-chalkboard-10 dark:bg-chalkboard-100 dark:border-chalkboard-70"
as="div"
data-testid="command-bar"
>
@ -116,6 +118,19 @@ export const CommandBar = () => {
<CommandBarReview stepBack={stepBack} />
)
)}
<button
onClick={() => commandBarSend({ type: 'Close' })}
className="group block !absolute left-auto right-full top-[-3px] m-2.5 p-0 border-none bg-transparent hover:bg-transparent"
>
<CustomIcon
name="close"
className="w-5 h-5 rounded-sm bg-destroy-10 text-destroy-80 dark:bg-destroy-80 dark:text-destroy-10 group-hover:brightness-110"
/>
<Tooltip position="bottom" delay={500}>
Cancel{' '}
<kbd className="hotkey ml-4 dark:!bg-chalkboard-80">esc</kbd>
</Tooltip>
</button>
</WrapperComponent.Panel>
</Transition.Child>
</WrapperComponent>

View File

@ -7,10 +7,8 @@ import {
getSelectionType,
getSelectionTypeDisplayText,
} from 'lib/selections'
import { kclManager } from 'lib/singletons'
import { modelingMachine } from 'machines/modelingMachine'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useHotkeys } from 'react-hotkeys-hook'
import { StateFrom } from 'xstate'
const selectionSelector = (snapshot: StateFrom<typeof modelingMachine>) =>
@ -41,12 +39,6 @@ function CommandBarSelectionInput({
canSubmitSelectionArg(selectionsByType, arg)
)
useHotkeys('tab', () => onSubmit(selection), {
enableOnFormTags: true,
enableOnContentEditable: true,
keyup: true,
})
useEffect(() => {
inputRef.current?.focus()
}, [selection, inputRef])

View File

@ -71,6 +71,16 @@ const CustomIconMap = {
/>
</svg>
),
bug: (
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M10.8209 5.99884C10.6403 5.73962 10.3399 5.57001 10 5.57001C9.65984 5.57001 9.35936 5.73984 9.17871 5.99935C9.43724 5.95129 9.71142 5.92578 10.0012 5.92578C10.29 5.92578 10.5633 5.95111 10.8209 5.99884ZM10 4.57001C8.9459 4.57001 8.08227 5.38548 8.00554 6.41997C7.58916 6.65398 7.23724 6.95989 6.95014 7.31304L5.85355 6.21645L5.14645 6.92356L6.40931 8.18642C6.20774 8.62503 6.08043 9.09624 6.0278 9.57001H5V10.57H6.01946C6.06396 11.1581 6.1867 11.8173 6.4071 12.4558L5.14645 13.7165L5.85355 14.4236L6.8408 13.4363C7.46354 14.555 8.47307 15.4258 10.0012 15.4258C11.529 15.4258 12.5378 14.5554 13.16 13.4371L14.1464 14.4236L14.8536 13.7165L13.5934 12.4563C13.8136 11.8177 13.9362 11.1583 13.9806 10.57H15V9.57001H13.9722C13.9197 9.0961 13.7925 8.62474 13.5911 8.18602L14.8536 6.92356L14.1464 6.21645L13.0505 7.31239C12.7633 6.95894 12.4112 6.65285 11.9944 6.41883C11.9171 5.38488 11.0537 4.57001 10 4.57001ZM10.5 14.3801V8.57001H9.5V14.3796C8.72105 14.2298 8.15885 13.7245 7.7428 12.9999C7.22316 12.095 7 10.937 7 10.07C7 8.46381 8.04281 6.92578 10.0012 6.92578C11.9589 6.92578 13 8.4629 13 10.07C13 10.9373 12.7773 12.0954 12.2582 13.0003C11.8422 13.7254 11.2799 14.2309 10.5 14.3801Z"
fill="currentColor"
/>
</svg>
),
checkmark: (
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path

View File

@ -33,7 +33,7 @@ export function LowerRightControls(props: React.PropsWithChildren) {
rel="noopener noreferrer"
>
<CustomIcon
name="exclamationMark"
name="bug"
className={`w-5 h-5 ${linkOverrideClassName}`}
/>
<Tooltip position="top">Report a bug</Tooltip>

View File

@ -76,6 +76,7 @@ import { useSearchParams } from 'react-router-dom'
import { letEngineAnimateAndSyncCamAfter } from 'clientSideScene/CameraControls'
import { getVarNameModal } from 'hooks/useToolbarGuards'
import useHotkeyWrapper from 'lib/hotkeyWrapper'
import { uuidv4 } from 'lib/utils'
type MachineContext<T extends AnyStateMachine> = {
state: StateFrom<T>
@ -141,7 +142,41 @@ export const ModelingMachineProvider = ({
{
actions: {
'sketch exit execute': () => {
kclManager.executeCode(true)
;(async () => {
await sceneInfra.camControls.snapToPerspectiveBeforeHandingBackControlToEngine()
sceneInfra.camControls.syncDirection = 'engineToClient'
const settings: Models['CameraSettings_type'] = (
await engineCommandManager.sendSceneCommand({
type: 'modeling_cmd_req',
cmd_id: uuidv4(),
cmd: {
type: 'default_camera_get_settings',
},
})
)?.data?.data?.settings
if (settings.up.z !== 1) {
// workaround for gimbal lock situation
await engineCommandManager.sendSceneCommand({
type: 'modeling_cmd_req',
cmd_id: uuidv4(),
cmd: {
type: 'default_camera_look_at',
center: settings.center,
vantage: {
...settings.pos,
y:
settings.pos.y +
(settings.center.z - settings.pos.z > 0 ? 2 : -2),
},
up: { x: 0, y: 0, z: 1 },
},
})
}
kclManager.executeCode(true)
})()
},
'Set mouse state': assign({
mouseState: (_, event) => event.data,
@ -464,7 +499,7 @@ export const ModelingMachineProvider = ({
engineCommandManager,
data.faceId
)
sceneInfra.camControls.syncDirection = 'clientToEngine'
return {
sketchPathToNode: pathToNewSketchNode,
zAxis: data.zAxis,
@ -478,8 +513,10 @@ export const ModelingMachineProvider = ({
)
await kclManager.updateAst(modifiedAst, false)
sceneInfra.camControls.syncDirection = 'clientToEngine'
const quat = await getSketchQuaternion(pathToNode, data.zAxis)
await sceneInfra.camControls.tweenCameraToQuaternion(quat)
await letEngineAnimateAndSyncCamAfter(
engineCommandManager,
data.planeId
)
return {
sketchPathToNode: pathToNode,
zAxis: data.zAxis,

View File

@ -24,9 +24,9 @@ export function RefreshButton() {
return (
<button
onClick={refresh}
className="p-1 m-0 bg-chalkboard-10/80 dark:bg-chalkboard-100/50 hover:bg-chalkboard-10 dark:hover:bg-chalkboard-100 rounded-full border border-solid border-chalkboard-10 dark:border-chalkboard-100"
className="p-1 m-0 bg-chalkboard-10/80 dark:bg-chalkboard-100/50 hover:bg-chalkboard-10 dark:hover:bg-chalkboard-100 rounded-full border border-solid border-chalkboard-20 dark:border-chalkboard-90"
>
<CustomIcon name="arrowRotateRight" className="w-5 h-5" />
<CustomIcon name="exclamationMark" className="w-5 h-5" />
<Tooltip position="bottom-right">
<span>Refresh and report</span>
<br />

View File

@ -171,7 +171,9 @@ export const SettingsAuthProviderBase = ({
})
},
'Execute AST': () => kclManager.executeCode(true, true),
persistSettings: (context) =>
},
services: {
'Persist settings': (context) =>
saveSettings(context, loadedProject?.project?.path),
},
}

View File

@ -126,8 +126,8 @@ export const Stream = ({ className = '' }: { className?: string }) => {
return (
<div
id="stream"
className={className}
className="absolute inset-0 z-0"
data-testid="stream"
onMouseUp={handleMouseUp}
onMouseDown={handleMouseDown}
onContextMenu={(e) => e.preventDefault()}
@ -142,7 +142,6 @@ export const Stream = ({ className = '' }: { className?: string }) => {
onMouseMoveCapture={handleMouseMove}
className="w-full cursor-pointer h-full"
disablePictureInPicture
style={{ transitionDuration: '200ms', transitionProperty: 'filter' }}
id="video-stream"
/>
<ClientSideScene

View File

@ -11,30 +11,8 @@
--_p-inline: calc(50% + calc(var(--isRTL) * var(--_triangle-width) / 2));
--_p-block: 4px;
--_bg: var(--chalkboard-10);
--_shadow-alpha: 5%;
--_shadow-alpha: 8%;
--_theme-alpha: 0.15;
--_theme-outline: drop-shadow(
0 1px 0
oklch(
var(--primary-lightness) var(--primary-chroma) var(--primary-hue) /
var(--_theme-alpha)
)
)
drop-shadow(
0 -1px 0 oklch(var(--primary-lightness) var(--primary-chroma)
var(--primary-hue) / var(--_theme-alpha))
)
drop-shadow(
1px 0 0
oklch(
var(--primary-lightness) var(--primary-chroma) var(--primary-hue) /
var(--_theme-alpha)
)
)
drop-shadow(
-1px 0 0 oklch(var(--primary-lightness) var(--primary-chroma)
var(--primary-hue) / var(--_theme-alpha))
);
pointer-events: none;
user-select: none;
@ -61,16 +39,15 @@
background: var(--_bg);
@apply text-chalkboard-110;
will-change: filter;
filter: drop-shadow(0 1px 3px hsl(0 0% 0% / var(--_shadow-alpha)))
drop-shadow(0 4px 8px hsl(0 0% 0% / var(--_shadow-alpha)))
var(--_theme-outline);
filter: drop-shadow(0 1px 2px hsl(0 0% 0% / var(--_shadow-alpha)))
drop-shadow(0 4px 6px hsl(0 0% 0% / calc(var(--_shadow-alpha) / 2)));
}
:global(.dark) .tooltip {
--_bg: var(--chalkboard-110);
--_bg: var(--chalkboard-90);
--_theme-alpha: 40%;
--_shadow-alpha: 16%;
@apply text-chalkboard-10;
filter: var(--_theme-outline);
}
.tooltip:dir(rtl) {
@ -109,7 +86,7 @@
}
/* Sometimes there's no visible label,
* so we'll use the tooltip as the label
* so we'll use the tooltip as the label
*/
.tooltip:only-child::before {
content: 'Tooltip:';

View File

@ -7,7 +7,11 @@ import { Selections, processCodeMirrorRanges, Selection } from 'lib/selections'
import { undo, redo } from '@codemirror/commands'
import { CommandBarMachineEvent } from 'machines/commandBarMachine'
import { addLineHighlight } from './highlightextension'
import { setDiagnostics, Diagnostic } from '@codemirror/lint'
import { forEachDiagnostic, setDiagnostics, Diagnostic } from '@codemirror/lint'
function diagnosticIsEqual(d1: Diagnostic, d2: Diagnostic): boolean {
return d1.from === d2.from && d1.to === d2.to && d1.message === d2.message
}
export default class EditorManager {
private _editorView: EditorView | null = null
@ -91,11 +95,38 @@ export default class EditorManager {
}
}
clearDiagnostics(): void {
if (!this.editorView) return
this.editorView.dispatch(setDiagnostics(this.editorView.state, []))
}
setDiagnostics(diagnostics: Diagnostic[]): void {
if (!this.editorView) return
this.editorView.dispatch(setDiagnostics(this.editorView.state, diagnostics))
}
addDiagnostics(diagnostics: Diagnostic[]): void {
if (!this.editorView) return
forEachDiagnostic(this.editorView.state, function (diag) {
diagnostics.push(diag)
})
const uniqueDiagnostics = new Set<Diagnostic>()
diagnostics.forEach((diagnostic) => {
for (const knownDiagnostic of uniqueDiagnostics.values()) {
if (diagnosticIsEqual(diagnostic, knownDiagnostic)) {
return
}
}
uniqueDiagnostics.add(diagnostic)
})
this.editorView.dispatch(
setDiagnostics(this.editorView.state, [...uniqueDiagnostics])
)
}
undo() {
if (this._editorView) {
undo(this._editorView)

View File

@ -382,9 +382,14 @@ export class LanguageServerPlugin implements PluginValue {
try {
switch (notification.method) {
case 'textDocument/publishDiagnostics':
//const params = notification.params as PublishDiagnosticsParams
console.log(
'[lsp] [window/publishDiagnostics]',
this.client.getName(),
notification.params
)
const params = notification.params as PublishDiagnosticsParams
// this is sometimes slower than our actual typing.
//this.processDiagnostics(params)
this.processDiagnostics(params)
break
case 'window/logMessage':
console.log(

View File

@ -249,3 +249,10 @@ code {
.cm-ghostText * {
color: rgb(120, 120, 120, 0.8) !important;
}
@layer components {
kbd.hotkey {
@apply font-mono text-xs inline-block px-1 py-0.5 rounded-sm;
@apply bg-chalkboard-20 dark:bg-chalkboard-90;
}
}

View File

@ -89,9 +89,10 @@ export class KclManager {
return this._kclErrors
}
set kclErrors(kclErrors) {
console.log('[lsp] not lsp, actually typescript: ', kclErrors)
this._kclErrors = kclErrors
let diagnostics = kclErrorsToDiagnostics(kclErrors)
editorManager.setDiagnostics(diagnostics)
editorManager.addDiagnostics(diagnostics)
this._kclErrorsCallBack(kclErrors)
}
@ -185,6 +186,11 @@ export class KclManager {
const currentExecutionId = executionId || Date.now()
this._cancelTokens.set(currentExecutionId, false)
// here we're going to clear diagnostics since we're the first
// one in. We're the only location where diagnostics are cleared;
// everything from here on out should be *appending*.
editorManager.clearDiagnostics()
this.isExecuting = true
await this.ensureWasmInit()
const { logs, errors, programMemory } = await executeAst({
@ -234,6 +240,7 @@ export class KclManager {
} = { updates: 'none' }
) {
await this.ensureWasmInit()
const newCode = recast(ast)
const newAst = this.safeParse(newCode)
if (!newAst) return
@ -243,6 +250,11 @@ export class KclManager {
await this?.engineCommandManager?.waitForReady
this._ast = { ...newAst }
// here we're going to clear diagnostics since we're the first
// one in. We're the only location where diagnostics are cleared;
// everything from here on out should be *appending*.
editorManager.clearDiagnostics()
const { logs, errors, programMemory } = await executeAst({
ast: newAst,
engineCommandManager: this.engineCommandManager,
@ -352,18 +364,55 @@ export class KclManager {
return this?.engineCommandManager?.defaultPlanes
}
showPlanes() {
if (!this.defaultPlanes) return
void this.engineCommandManager.setPlaneHidden(this.defaultPlanes.xy, false)
void this.engineCommandManager.setPlaneHidden(this.defaultPlanes.yz, false)
void this.engineCommandManager.setPlaneHidden(this.defaultPlanes.xz, false)
showPlanes(all = false) {
if (!this.defaultPlanes) return Promise.all([])
const thePromises = [
this.engineCommandManager.setPlaneHidden(this.defaultPlanes.xy, false),
this.engineCommandManager.setPlaneHidden(this.defaultPlanes.yz, false),
this.engineCommandManager.setPlaneHidden(this.defaultPlanes.xz, false),
]
if (all) {
thePromises.push(
this.engineCommandManager.setPlaneHidden(
this.defaultPlanes.negXy,
false
)
)
thePromises.push(
this.engineCommandManager.setPlaneHidden(
this.defaultPlanes.negYz,
false
)
)
thePromises.push(
this.engineCommandManager.setPlaneHidden(
this.defaultPlanes.negXz,
false
)
)
}
return Promise.all(thePromises)
}
hidePlanes() {
if (!this.defaultPlanes) return
void this.engineCommandManager.setPlaneHidden(this.defaultPlanes.xy, true)
void this.engineCommandManager.setPlaneHidden(this.defaultPlanes.yz, true)
void this.engineCommandManager.setPlaneHidden(this.defaultPlanes.xz, true)
hidePlanes(all = false) {
if (!this.defaultPlanes) return Promise.all([])
const thePromises = [
this.engineCommandManager.setPlaneHidden(this.defaultPlanes.xy, true),
this.engineCommandManager.setPlaneHidden(this.defaultPlanes.yz, true),
this.engineCommandManager.setPlaneHidden(this.defaultPlanes.xz, true),
]
if (all) {
thePromises.push(
this.engineCommandManager.setPlaneHidden(this.defaultPlanes.negXy, true)
)
thePromises.push(
this.engineCommandManager.setPlaneHidden(this.defaultPlanes.negYz, true)
)
thePromises.push(
this.engineCommandManager.setPlaneHidden(this.defaultPlanes.negXz, true)
)
}
return Promise.all(thePromises)
}
defaultSelectionFilter() {
defaultSelectionFilter(this.programMemory, this.engineCommandManager)

View File

@ -17,15 +17,17 @@ const prependRoutes =
)
}
type OnboardingPaths = {
[K in keyof typeof onboardingPaths]: `/onboarding${(typeof onboardingPaths)[K]}`
}
export const paths = {
INDEX: '/',
HOME: '/home',
FILE: '/file',
SETTINGS: '/settings',
SIGN_IN: '/signin',
ONBOARDING: prependRoutes(onboardingPaths)(
'/onboarding'
) as typeof onboardingPaths,
ONBOARDING: prependRoutes(onboardingPaths)('/onboarding') as OnboardingPaths,
} as const
export const BROWSER_PATH = `%2F${BROWSER_PROJECT_NAME}%2F${BROWSER_FILE_NAME}${FILE_EXT}`

View File

@ -540,7 +540,7 @@ function codeToIdSelections(
.filter(Boolean) as any
}
export function sendSelectEventToEngine(
export async function sendSelectEventToEngine(
e: MouseEvent | React.MouseEvent<HTMLDivElement, MouseEvent>,
el: HTMLVideoElement,
streamDimensions: { streamWidth: number; streamHeight: number }
@ -551,7 +551,7 @@ export function sendSelectEventToEngine(
el,
...streamDimensions,
})
const result: Promise<Models['SelectWithPoint_type']> = engineCommandManager
const result: Models['SelectWithPoint_type'] = await engineCommandManager
.sendSceneCommand({
type: 'modeling_cmd_req',
cmd: {

File diff suppressed because one or more lines are too long

View File

@ -11,98 +11,98 @@ import {
export const settingsMachine = createMachine(
{
/** @xstate-layout N4IgpgJg5mDOIC5QGUwBc0EsB2VYDpMIAbMAYlnXwEMAHW-Ae2wCNHqAnCHKZNatAFdYAbQAMAXUShajWJizNpIAB6IAbAFZN+AOwAWAIwAOYwE4AzGYBM+-ZosAaEAE9Eh62LP51ls+v0LMWt1awMAX3DnVAweAiJSCio6BjQACzAAWzAAYUZiRg5xKSQQWXlFbGU1BA9vQ0N1CwCxdVbdY1DnNwQzPp8zTTFje1D1QwtjSOj0LFx4knJKNHxMxggwYh58DYAzakFiNABVbAVi5XKFTCVSmotNY3w7YysHRuNDTXV1bvdG7w-IKTcbaazWCzTEAxOZ4QiLJIrFL4dJZMAXUpXSrVDTGazPMQPR4GXRBAx-XoGfDWIadMx4n6EqEwuLwxLLVbrTbbNKYKBpLb8tAAUWgcAxMjk11uoHumn0+DEw2sJkMulCgWsFL6YnwfX0ELsYg61jMumZs1ZCXIACU4OgAATLWGiSSXKXYu4aLz4UwWBr6DqBYYUzSePXqUlBLxmyZmC2xeZs8gxB3UYjEJ2W+YSsoem5VL0IKy6z6EsJifQ2Czq0MTHzq8Yjfz6MQmBMu5NkABUuaxBZxCDD+DD5lJgUjxssFP0xl0I+0pm06uMmg8kSiIGwXPgpRZ83dFQHRYAtA0LCO2tZjGIHJNdB5fq5EK3dc1OsNGkrA+oO1bFoe0qFrKiAnlol7BDed5zo+FK+Doc7qhCNaVv4UwbkAA */
/** @xstate-layout N4IgpgJg5mDOIC5QGUwBc0EsB2VYDpMIAbMAYlnXwEMAHW-Ae2wCNHqAnCHKZNatAFdYAbQAMAXUShajWJizNpIAB6IALAFYAnPgBMARgDsBsQDY969QGYjmzQBoQAT0SnrADnwePY61r0PAwNtMyMAX3CnVAweAiJSCio6BjQACzAAWzAAYUZiRg5xKSQQWXlFbGU1BD1PfFtfE3UzTUNNaydXBCD1b209PTEPTTMtdQNNSOj0LFx4knJKNHxMxggwYh58DYAzakFiNABVbAVi5XKFTCVSmsGxfCMPM08PQaDNU0cXRG1tLwedTaKxif7+UJTKIgGJzPCERZJFYpfDpLJgC6lK6VaqIEx6fBmCw2Do2IJ6MxdRDvTT4MRDdRGEzWbQ6ELTGGzOIIxLLVbrTbbNKYKBpLaitAAUWgcExMjk11uoBqVgM3jMYhsAIMrVs6ipPWChOeYhC9KMFhGHNh3IS5AASnB0AACZZw0SSS4KnF3PFafADTV1YZ2IxiH7dNpGfCaIzAgE+IzWMzBa1c+Y88gxZ3UYjEV3pvBysrem5VX0IFq0y3aTXWOp6JmU34IKMxuz0joGEYWsxp2IZu1kABUxexZdxtRG+EmQMZmne3dNBs0jKewLBsbCwI81n77vwtDAHHksDhBYHeDIEGYYEI2AAbowANZ3o8nzBnm3zMelpWqRAAFp62sJ4jEsZ4AT0UJGwjPFzH6cwNW0AwWXpbRImhbABXgUpvzwL0KgnCtgJMMCII8KCYLsA11EGOkXneDxmXMCk92hfCFlIQjFXLZUgLjddWhaFkgRCaxOhbEYzBnXwXkmOjAjjfduXfU9zzdOIeJ9fiEEA6ckwMClQ2BFpmJXMF9DjYI6hZfxmMw8IgA */
id: 'Settings',
predictableActionArguments: true,
context: {} as ReturnType<typeof createSettings>,
initial: 'idle',
states: {
idle: {
entry: ['setThemeClass', 'setClientSideSceneUnits', 'persistSettings'],
entry: ['setThemeClass', 'setClientSideSceneUnits'],
on: {
'*': {
target: 'idle',
internal: true,
actions: ['setSettingAtLevel', 'toastSuccess', 'persistSettings'],
target: 'persisting settings',
actions: ['setSettingAtLevel', 'toastSuccess'],
},
'set.app.onboardingStatus': {
target: 'idle',
internal: true,
actions: ['setSettingAtLevel', 'persistSettings'], // No toast
target: 'persisting settings',
// No toast
actions: ['setSettingAtLevel'],
},
'set.app.themeColor': {
target: 'idle',
internal: true,
actions: ['setSettingAtLevel', 'persistSettings'], // No toast
target: 'persisting settings',
// No toast
actions: ['setSettingAtLevel'],
},
'set.modeling.defaultUnit': {
target: 'idle',
internal: true,
target: 'persisting settings',
actions: [
'setSettingAtLevel',
'toastSuccess',
'setClientSideSceneUnits',
'Execute AST',
'persistSettings',
],
},
'set.app.theme': {
target: 'idle',
internal: true,
target: 'persisting settings',
actions: [
'setSettingAtLevel',
'toastSuccess',
'setThemeClass',
'setEngineTheme',
'persistSettings',
'setClientTheme',
],
},
'set.modeling.highlightEdges': {
target: 'idle',
internal: true,
actions: [
'setSettingAtLevel',
'toastSuccess',
'setEngineEdges',
'persistSettings',
],
target: 'persisting settings',
actions: ['setSettingAtLevel', 'toastSuccess', 'setEngineEdges'],
},
'Reset settings': {
target: 'idle',
internal: true,
target: 'persisting settings',
actions: [
'resetSettings',
'setThemeClass',
'setEngineTheme',
'setClientSideSceneUnits',
'Execute AST',
'persistSettings',
'setClientTheme',
],
},
'Set all settings': {
target: 'idle',
internal: true,
target: 'persisting settings',
actions: [
'setAllSettings',
'setThemeClass',
'setEngineTheme',
'setClientSideSceneUnits',
'Execute AST',
'persistSettings',
'setClientTheme',
],
},
},
},
'persisting settings': {
invoke: {
src: 'Persist settings',
id: 'persistSettings',
onDone: 'idle',
},
},
},
tsTypes: {} as import('./settingsMachine.typegen').Typegen0,
schema: {

View File

@ -3,7 +3,7 @@ import { Outlet, useNavigate } from 'react-router-dom'
import Introduction from './Introduction'
import Camera from './Camera'
import Sketching from './Sketching'
import { useCallback } from 'react'
import { useCallback, useEffect } from 'react'
import makeUrlPathRelative from '../../lib/makeUrlPathRelative'
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
import Streaming from './Streaming'
@ -94,17 +94,31 @@ export function useNextClick(newStatus: string) {
export function useDismiss() {
const filePath = useAbsoluteFilePath()
const {
settings: { send },
settings: { state, send },
} = useSettingsAuthContext()
const navigate = useNavigate()
return useCallback(() => {
const settingsCallback = useCallback(() => {
send({
type: 'set.app.onboardingStatus',
data: { level: 'user', value: 'dismissed' },
})
navigate(filePath)
}, [send, navigate, filePath])
}, [send])
/**
* A "listener" for the XState to return to "idle" state
* when the user dismisses the onboarding, using the callback above
*/
useEffect(() => {
if (
state.context.app.onboardingStatus.user === 'dismissed' &&
state.matches('idle')
) {
navigate(filePath)
}
}, [filePath, navigate, state])
return settingsCallback
}
// Get the 1-indexed step number of the current onboarding step

View File

@ -297,9 +297,9 @@ dependencies = [
[[package]]
name = "bson"
version = "2.10.0"
version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4d43b38e074cc0de2957f10947e376a1d88b9c4dbab340b590800cc1b2e066b2"
checksum = "d8a88e82b9106923b5c4d6edfca9e7db958d4e98a478ec115022e81b9b38e2c8"
dependencies = [
"ahash",
"base64 0.13.1",
@ -406,9 +406,9 @@ dependencies = [
[[package]]
name = "clap"
version = "4.5.4"
version = "4.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90bc066a67923782aa8515dbaea16946c5bcc5addbd668bb80af688e53e548a0"
checksum = "5db83dced34638ad474f39f250d7fea9598bdd239eaced1bdf45d597da0f433f"
dependencies = [
"clap_builder",
"clap_derive",
@ -416,9 +416,9 @@ dependencies = [
[[package]]
name = "clap_builder"
version = "4.5.2"
version = "4.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae129e2e766ae0ec03484e609954119f123cc1fe650337e155d03b022f24f7b4"
checksum = "f7e204572485eb3fbf28f871612191521df159bc3e15a9f5064c66dba3a8c05f"
dependencies = [
"anstream",
"anstyle",
@ -430,9 +430,9 @@ dependencies = [
[[package]]
name = "clap_derive"
version = "4.5.4"
version = "4.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "528131438037fd55894f62d6e9f068b8f45ac57ffa77517819645d10aed04f64"
checksum = "c780290ccf4fb26629baa7a1081e68ced113f1d3ec302fa5948f1c381ebf06c6"
dependencies = [
"heck 0.5.0",
"proc-macro2",
@ -670,9 +670,9 @@ checksum = "7e962a19be5cfc3f3bf6dd8f61eb50107f356ad6270fbb3ed41476571db78be5"
[[package]]
name = "databake"
version = "0.1.7"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82175d72e69414ceafbe2b49686794d3a8bed846e0d50267355f83ea8fdd953a"
checksum = "6a04fbfbecca8f0679c8c06fef907594adcc3e2052e11163a6d30535a1a5604d"
dependencies = [
"databake-derive",
"proc-macro2",
@ -681,9 +681,9 @@ dependencies = [
[[package]]
name = "databake-derive"
version = "0.1.7"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "377af281d8f23663862a7c84623bc5dcf7f8c44b13c7496a590bdc157f941a43"
checksum = "4078275de501a61ceb9e759d37bdd3d7210e654dbc167ac1a3678ef4435ed57b"
dependencies = [
"proc-macro2",
"quote",
@ -1369,7 +1369,7 @@ dependencies = [
[[package]]
name = "kcl-lib"
version = "0.1.58"
version = "0.1.60"
dependencies = [
"anyhow",
"approx",
@ -1436,9 +1436,9 @@ dependencies = [
[[package]]
name = "kittycad"
version = "0.3.3"
version = "0.3.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b0cbef813153197e60c0e96f59eea0b75f8418380f414b20250ee81b60e522c3"
checksum = "df75feef10313fa1cb15b7cecd0f579877312ba3d42bb5b8b4c1d4b1d0fcabf0"
dependencies = [
"anyhow",
"async-trait",
@ -1451,7 +1451,7 @@ dependencies = [
"format_serde_error",
"futures",
"http 0.2.12",
"itertools 0.12.1",
"itertools 0.13.0",
"log",
"mime_guess",
"parse-display",
@ -2037,9 +2037,9 @@ dependencies = [
[[package]]
name = "regex"
version = "1.10.4"
version = "1.10.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c117dbdfde9c8308975b6a18d71f3f385c89461f7b3fb054288ecf2a2058ba4c"
checksum = "b91213439dad192326a0d7c6ee3955910425f441d7038e0d6933b0aec5c4517f"
dependencies = [
"aho-corasick",
"memchr",
@ -2378,9 +2378,9 @@ dependencies = [
[[package]]
name = "schemars"
version = "0.8.20"
version = "0.8.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b0218ceea14babe24a4a5836f86ade86c1effbc198164e619194cb5069187e29"
checksum = "09c024468a378b7e36765cd36702b7a90cc3cba11654f6685c8f233408e89e92"
dependencies = [
"bigdecimal",
"bytes",
@ -2395,9 +2395,9 @@ dependencies = [
[[package]]
name = "schemars_derive"
version = "0.8.20"
version = "0.8.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ed5a1ccce8ff962e31a165d41f6e2a2dd1245099dc4d594f5574a86cd90f4d3"
checksum = "b1eee588578aff73f856ab961cd2f79e36bc45d7ded33a7562adba4667aecc0e"
dependencies = [
"proc-macro2",
"quote",
@ -2945,9 +2945,9 @@ dependencies = [
[[package]]
name = "tokio-tungstenite"
version = "0.23.0"
version = "0.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "becd34a233e7e31a3dbf7c7241b38320f57393dcae8e7324b0167d21b8e320b0"
checksum = "c6989540ced10490aaf14e6bad2e3d33728a2813310a0c71d1574304c49631cd"
dependencies = [
"futures-util",
"log",

View File

@ -10,11 +10,11 @@ rust-version = "1.73"
crate-type = ["cdylib"]
[dependencies]
bson = { version = "2.10.0", features = ["uuid-1", "chrono"] }
clap = "4.5.4"
bson = { version = "2.11.0", features = ["uuid-1", "chrono"] }
clap = "4.5.7"
gloo-utils = "0.2.0"
kcl-lib = { path = "kcl" }
kittycad = { workspace = true }
kittycad.workspace = true
serde_json = "1.0.116"
tokio = { version = "1.38.0", features = ["sync"] }
toml = "0.8.14"
@ -68,7 +68,7 @@ members = [
]
[workspace.dependencies]
kittycad = { version = "0.3.3", default-features = false, features = ["js", "requests"] }
kittycad = { version = "0.3.5", default-features = false, features = ["js", "requests"] }
kittycad-modeling-session = "0.1.4"
[[test]]

View File

@ -11,7 +11,7 @@ repository = "https://github.com/KittyCAD/modeling-app"
proc-macro = true
[dependencies]
databake = "0.1.7"
databake = "0.1.8"
kcl-lib = { path = "../kcl" }
proc-macro2 = "1"
quote = "1"

View File

@ -1,7 +1,7 @@
[package]
name = "kcl-lib"
description = "KittyCAD Language implementation and tools"
version = "0.1.58"
version = "0.1.60"
edition = "2021"
license = "MIT"
repository = "https://github.com/KittyCAD/modeling-app"
@ -16,9 +16,9 @@ async-recursion = "1.1.1"
async-trait = "0.1.80"
base64 = "0.22.1"
chrono = "0.4.38"
clap = { version = "4.5.4", default-features = false, optional = true }
clap = { version = "4.5.7", default-features = false, optional = true }
dashmap = "5.5.3"
databake = { version = "0.1.7", features = ["derive"] }
databake = { version = "0.1.8", features = ["derive"] }
derive-docs = { version = "0.1.18", path = "../derive-docs" }
form_urlencoded = "1.2.1"
futures = { version = "0.3.30" }
@ -54,9 +54,9 @@ web-sys = { version = "0.3.69", features = ["console"] }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
approx = "0.5"
bson = { version = "2.10.0", features = ["uuid-1", "chrono"] }
bson = { version = "2.11.0", features = ["uuid-1", "chrono"] }
tokio = { version = "1.38.0", features = ["full"] }
tokio-tungstenite = { version = "0.23.0", features = ["rustls-tls-native-roots"] }
tokio-tungstenite = { version = "0.23.1", features = ["rustls-tls-native-roots"] }
tower-lsp = { version = "0.20.0", features = ["proposed"] }
[features]

View File

@ -63,9 +63,10 @@ impl StdLibFnArg {
pub fn get_autocomplete_snippet(&self, index: usize) -> Result<Option<(usize, String)>> {
if self.type_ == "SketchGroup"
|| self.type_ == "ExtrudeGroup"
|| self.type_ == "SketchSurface"
|| self.type_ == "SketchGroupSet"
|| self.type_ == "ExtrudeGroup"
|| self.type_ == "ExtrudeGroupSet"
|| self.type_ == "SketchSurface"
{
return Ok(Some((index, format!("${{{}:{}}}", index, "%"))));
}

View File

@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize};
use thiserror::Error;
use tower_lsp::lsp_types::{Diagnostic, DiagnosticSeverity};
use crate::executor::SourceRange;
use crate::{executor::SourceRange, lsp::IntoDiagnostic};
#[derive(Error, Debug, Serialize, Deserialize, ts_rs::TS, Clone, PartialEq, Eq)]
#[ts(export)]
@ -42,19 +42,9 @@ pub struct KclErrorDetails {
}
impl KclError {
/// Get the error message, line and column from the error and input code.
pub fn get_message_line_column(&self, input: &str) -> (String, Option<usize>, Option<usize>) {
// Calculate the line and column of the error from the source range.
let (line, column) = if let Some(range) = self.source_ranges().first() {
let line = input[..range.0[0]].lines().count();
let column = input[..range.0[0]].lines().last().map(|l| l.len()).unwrap_or_default();
(Some(line), Some(column))
} else {
(None, None)
};
(format!("{}: {}", self.error_type(), self.message()), line, column)
/// Get the error message.
pub fn get_message(&self) -> String {
format!("{}: {}", self.error_type(), self.message())
}
pub fn error_type(&self) -> &'static str {
@ -106,24 +96,6 @@ impl KclError {
}
}
pub fn to_lsp_diagnostic(&self, code: &str) -> Diagnostic {
let (message, _, _) = self.get_message_line_column(code);
let source_ranges = self.source_ranges();
Diagnostic {
range: source_ranges.first().map(|r| r.to_lsp_range(code)).unwrap_or_default(),
severity: Some(DiagnosticSeverity::ERROR),
code: None,
// TODO: this is neat we can pass a URL to a help page here for this specific error.
code_description: None,
source: Some("kcl".to_string()),
message,
related_information: None,
tags: None,
data: None,
}
}
pub fn override_source_ranges(&self, source_ranges: Vec<SourceRange>) -> Self {
let mut new = self.clone();
match &mut new {
@ -163,6 +135,26 @@ impl KclError {
}
}
impl IntoDiagnostic for KclError {
fn to_lsp_diagnostic(&self, code: &str) -> Diagnostic {
let message = self.get_message();
let source_ranges = self.source_ranges();
Diagnostic {
range: source_ranges.first().map(|r| r.to_lsp_range(code)).unwrap_or_default(),
severity: Some(DiagnosticSeverity::ERROR),
code: None,
// TODO: this is neat we can pass a URL to a help page here for this specific error.
code_description: None,
source: Some("kcl".to_string()),
message,
related_information: None,
tags: None,
data: None,
}
}
}
/// This is different than to_string() in that it will serialize the Error
/// the struct as JSON so we can deserialize it on the js side.
impl From<KclError> for String {

View File

@ -11,6 +11,7 @@ pub mod engine;
pub mod errors;
pub mod executor;
pub mod fs;
pub mod lint;
pub mod lsp;
pub mod parser;
pub mod settings;

View File

@ -0,0 +1,64 @@
use crate::ast::types;
/// The "Node" type wraps all the AST elements we're able to find in a KCL
/// file. Tokens we walk through will be one of these.
#[derive(Clone, Debug)]
pub enum Node<'a> {
Program(&'a types::Program),
ExpressionStatement(&'a types::ExpressionStatement),
VariableDeclaration(&'a types::VariableDeclaration),
ReturnStatement(&'a types::ReturnStatement),
VariableDeclarator(&'a types::VariableDeclarator),
Literal(&'a types::Literal),
Identifier(&'a types::Identifier),
BinaryExpression(&'a types::BinaryExpression),
FunctionExpression(&'a types::FunctionExpression),
CallExpression(&'a types::CallExpression),
PipeExpression(&'a types::PipeExpression),
PipeSubstitution(&'a types::PipeSubstitution),
ArrayExpression(&'a types::ArrayExpression),
ObjectExpression(&'a types::ObjectExpression),
MemberExpression(&'a types::MemberExpression),
UnaryExpression(&'a types::UnaryExpression),
Parameter(&'a types::Parameter),
ObjectProperty(&'a types::ObjectProperty),
MemberObject(&'a types::MemberObject),
LiteralIdentifier(&'a types::LiteralIdentifier),
}
macro_rules! impl_from {
($node:ident, $t: ident) => {
impl<'a> From<&'a types::$t> for Node<'a> {
fn from(v: &'a types::$t) -> Self {
Node::$t(v)
}
}
};
}
impl_from!(Node, Program);
impl_from!(Node, ExpressionStatement);
impl_from!(Node, VariableDeclaration);
impl_from!(Node, ReturnStatement);
impl_from!(Node, VariableDeclarator);
impl_from!(Node, Literal);
impl_from!(Node, Identifier);
impl_from!(Node, BinaryExpression);
impl_from!(Node, FunctionExpression);
impl_from!(Node, CallExpression);
impl_from!(Node, PipeExpression);
impl_from!(Node, PipeSubstitution);
impl_from!(Node, ArrayExpression);
impl_from!(Node, ObjectExpression);
impl_from!(Node, MemberExpression);
impl_from!(Node, UnaryExpression);
impl_from!(Node, Parameter);
impl_from!(Node, ObjectProperty);
impl_from!(Node, MemberObject);
impl_from!(Node, LiteralIdentifier);

View File

@ -0,0 +1,233 @@
use super::Node;
use crate::ast::types::{
BinaryPart, BodyItem, LiteralIdentifier, MemberExpression, MemberObject, ObjectExpression, ObjectProperty,
Parameter, Program, UnaryExpression, Value, VariableDeclarator,
};
use anyhow::Result;
/// Walker is implemented by things that are able to walk an AST tree to
/// produce lints. This trait is implemented automatically for a few of the
/// common types, but can be manually implemented too.
pub trait Walker<'a> {
/// Walk will visit every element of the AST.
fn walk(&self, n: Node<'a>) -> Result<bool>;
}
impl<'a, FnT> Walker<'a> for FnT
where
FnT: Fn(Node<'a>) -> Result<bool>,
{
fn walk(&self, n: Node<'a>) -> Result<bool> {
self(n)
}
}
/// Run the Walker against all [Node]s in a [Program].
pub fn walk<'a, WalkT>(prog: &'a Program, f: &WalkT) -> Result<()>
where
WalkT: Walker<'a>,
{
f.walk(prog.into())?;
for bi in &prog.body {
walk_body_item(bi, f)?;
}
Ok(())
}
fn walk_variable_declarator<'a, WalkT>(node: &'a VariableDeclarator, f: &WalkT) -> Result<()>
where
WalkT: Walker<'a>,
{
f.walk(node.into())?;
f.walk((&node.id).into())?;
walk_value(&node.init, f)?;
Ok(())
}
fn walk_parameter<'a, WalkT>(node: &'a Parameter, f: &WalkT) -> Result<()>
where
WalkT: Walker<'a>,
{
f.walk(node.into())?;
f.walk((&node.identifier).into())?;
Ok(())
}
fn walk_member_object<'a, WalkT>(node: &'a MemberObject, f: &WalkT) -> Result<()>
where
WalkT: Walker<'a>,
{
f.walk(node.into())?;
Ok(())
}
fn walk_literal_identifier<'a, WalkT>(node: &'a LiteralIdentifier, f: &WalkT) -> Result<()>
where
WalkT: Walker<'a>,
{
f.walk(node.into())?;
Ok(())
}
fn walk_member_expression<'a, WalkT>(node: &'a MemberExpression, f: &WalkT) -> Result<()>
where
WalkT: Walker<'a>,
{
f.walk(node.into())?;
walk_member_object(&node.object, f)?;
walk_literal_identifier(&node.property, f)?;
Ok(())
}
fn walk_binary_part<'a, WalkT>(node: &'a BinaryPart, f: &WalkT) -> Result<()>
where
WalkT: Walker<'a>,
{
match node {
BinaryPart::Literal(lit) => f.walk(lit.as_ref().into())?,
BinaryPart::Identifier(id) => f.walk(id.as_ref().into())?,
BinaryPart::BinaryExpression(be) => f.walk(be.as_ref().into())?,
BinaryPart::CallExpression(ce) => f.walk(ce.as_ref().into())?,
BinaryPart::UnaryExpression(ue) => {
walk_unary_expression(ue, f)?;
true
}
BinaryPart::MemberExpression(me) => {
walk_member_expression(me, f)?;
true
}
};
Ok(())
}
fn walk_value<'a, WalkT>(node: &'a Value, f: &WalkT) -> Result<()>
where
WalkT: Walker<'a>,
{
match node {
Value::Literal(lit) => {
f.walk(lit.as_ref().into())?;
}
Value::Identifier(id) => {
// sometimes there's a bare Identifier without a Value::Identifier.
f.walk(id.as_ref().into())?;
}
Value::BinaryExpression(be) => {
f.walk(be.as_ref().into())?;
walk_binary_part(&be.left, f)?;
walk_binary_part(&be.right, f)?;
}
Value::FunctionExpression(fe) => {
f.walk(fe.as_ref().into())?;
for arg in &fe.params {
walk_parameter(arg, f)?;
}
walk(&fe.body, f)?;
}
Value::CallExpression(ce) => {
f.walk(ce.as_ref().into())?;
f.walk((&ce.callee).into())?;
for e in &ce.arguments {
walk_value::<WalkT>(e, f)?;
}
}
Value::PipeExpression(pe) => {
f.walk(pe.as_ref().into())?;
for e in &pe.body {
walk_value::<WalkT>(e, f)?;
}
}
Value::PipeSubstitution(ps) => {
f.walk(ps.as_ref().into())?;
}
Value::ArrayExpression(ae) => {
f.walk(ae.as_ref().into())?;
for e in &ae.elements {
walk_value::<WalkT>(e, f)?;
}
}
Value::ObjectExpression(oe) => {
walk_object_expression(oe, f)?;
}
Value::MemberExpression(me) => {
walk_member_expression(me, f)?;
}
Value::UnaryExpression(ue) => {
walk_unary_expression(ue, f)?;
}
_ => {
println!("{:?}", node);
unimplemented!()
}
}
Ok(())
}
/// Walk through an [ObjectProperty].
fn walk_object_property<'a, WalkT>(node: &'a ObjectProperty, f: &WalkT) -> Result<()>
where
WalkT: Walker<'a>,
{
f.walk(node.into())?;
walk_value(&node.value, f)?;
Ok(())
}
/// Walk through an [ObjectExpression].
fn walk_object_expression<'a, WalkT>(node: &'a ObjectExpression, f: &WalkT) -> Result<()>
where
WalkT: Walker<'a>,
{
f.walk(node.into())?;
for prop in &node.properties {
walk_object_property(prop, f)?;
}
Ok(())
}
/// walk through an [UnaryExpression].
fn walk_unary_expression<'a, WalkT>(node: &'a UnaryExpression, f: &WalkT) -> Result<()>
where
WalkT: Walker<'a>,
{
f.walk(node.into())?;
walk_binary_part(&node.argument, f)?;
Ok(())
}
/// walk through a [BodyItem].
fn walk_body_item<'a, WalkT>(node: &'a BodyItem, f: &WalkT) -> Result<()>
where
WalkT: Walker<'a>,
{
// We don't walk a BodyItem since it's an enum itself.
match node {
BodyItem::ExpressionStatement(xs) => {
f.walk(xs.into())?;
walk_value(&xs.expression, f)?;
}
BodyItem::VariableDeclaration(vd) => {
f.walk(vd.into())?;
for dec in &vd.declarations {
walk_variable_declarator(dec, f)?;
}
}
BodyItem::ReturnStatement(rs) => {
f.walk(rs.into())?;
walk_value(&rs.argument, f)?;
}
}
Ok(())
}

View File

@ -0,0 +1,131 @@
use crate::{
ast::types::VariableDeclarator,
executor::SourceRange,
lint::{
rule::{def_finding, Discovered, Finding},
Node,
},
};
use anyhow::Result;
def_finding!(
Z0001,
"Identifiers must be lowerCamelCase",
"\
By convention, variable names are lowerCamelCase, not snake_case, kebab-case,
nor CammelCase. 🐪
For instance, a good identifier for the variable representing 'box height'
would be 'boxHeight', not 'BOX_HEIGHT', 'box_height' nor 'BoxHeight'. For
more information there's a pretty good Wikipedia page at
https://en.wikipedia.org/wiki/Camel_case
"
);
fn lint_lower_camel_case(decl: &VariableDeclarator) -> Result<Vec<Discovered>> {
let mut findings = vec![];
let ident = &decl.id;
let name = &ident.name;
if !name.chars().next().unwrap().is_lowercase() {
findings.push(Z0001.at(format!("found '{}'", name), SourceRange::new(ident.start, ident.end)));
return Ok(findings);
}
if name.contains('-') || name.contains('_') {
findings.push(Z0001.at(format!("found '{}'", name), SourceRange::new(ident.start, ident.end)));
return Ok(findings);
}
Ok(findings)
}
pub fn lint_variables(decl: Node) -> Result<Vec<Discovered>> {
let Node::VariableDeclaration(decl) = decl else {
return Ok(vec![]);
};
Ok(decl
.declarations
.iter()
.flat_map(|v| lint_lower_camel_case(v).unwrap_or_default())
.collect())
}
#[cfg(test)]
mod tests {
use super::{lint_variables, Z0001};
use crate::lint::rule::{assert_finding, test_finding, test_no_finding};
#[test]
fn z0001_const() {
assert_finding!(lint_variables, Z0001, "const Thickness = 0.5");
assert_finding!(lint_variables, Z0001, "const THICKNESS = 0.5");
assert_finding!(lint_variables, Z0001, "const THICC_NES = 0.5");
assert_finding!(lint_variables, Z0001, "const thicc_nes = 0.5");
}
test_finding!(z0001_full_bad, lint_variables, Z0001, "\
// Define constants
const pipeLength = 40
const pipeSmallDia = 10
const pipeLargeDia = 20
const thickness = 0.5
// Create the sketch to be revolved around the y-axis. Use the small diameter, large diameter, length, and thickness to define the sketch.
const Part001 = startSketchOn('XY')
|> startProfileAt([pipeLargeDia - (thickness / 2), 38], %)
|> line([thickness, 0], %)
|> line([0, -1], %)
|> angledLineToX({
angle: 60,
to: pipeSmallDia + thickness
}, %)
|> line([0, -pipeLength], %)
|> angledLineToX({
angle: -60,
to: pipeLargeDia + thickness
}, %)
|> line([0, -1], %)
|> line([-thickness, 0], %)
|> line([0, 1], %)
|> angledLineToX({ angle: 120, to: pipeSmallDia }, %)
|> line([0, pipeLength], %)
|> angledLineToX({ angle: 60, to: pipeLargeDia }, %)
|> close(%)
|> revolve({ axis: 'y' }, %)
");
test_no_finding!(z0001_full_good, lint_variables, Z0001, "\
// Define constants
const pipeLength = 40
const pipeSmallDia = 10
const pipeLargeDia = 20
const thickness = 0.5
// Create the sketch to be revolved around the y-axis. Use the small diameter, large diameter, length, and thickness to define the sketch.
const part001 = startSketchOn('XY')
|> startProfileAt([pipeLargeDia - (thickness / 2), 38], %)
|> line([thickness, 0], %)
|> line([0, -1], %)
|> angledLineToX({
angle: 60,
to: pipeSmallDia + thickness
}, %)
|> line([0, -pipeLength], %)
|> angledLineToX({
angle: -60,
to: pipeLargeDia + thickness
}, %)
|> line([0, -1], %)
|> line([-thickness, 0], %)
|> line([0, 1], %)
|> angledLineToX({ angle: 120, to: pipeSmallDia }, %)
|> line([0, pipeLength], %)
|> angledLineToX({ angle: 60, to: pipeLargeDia }, %)
|> close(%)
|> revolve({ axis: 'y' }, %)
");
}

View File

@ -0,0 +1,4 @@
mod camel_case;
#[allow(unused_imports)]
pub use camel_case::{lint_variables, Z0001};

View File

@ -0,0 +1,9 @@
mod ast_node;
mod ast_walk;
pub mod checks;
mod rule;
pub use ast_node::Node;
pub use ast_walk::walk;
// pub(crate) use rule::{def_finding, finding};
pub use rule::{lint, Discovered, Finding};

View File

@ -0,0 +1,180 @@
use super::{walk, Node};
use crate::{ast::types::Program, executor::SourceRange, lsp::IntoDiagnostic};
use anyhow::Result;
use std::sync::{Arc, Mutex};
use tower_lsp::lsp_types::{Diagnostic, DiagnosticSeverity};
/// Check the provided AST for any found rule violations.
///
/// The Rule trait is automatically implemented for a few other types,
/// but it can also be manually implemented as required.
pub trait Rule<'a> {
/// Check the AST at this specific node for any Finding(s).
fn check(&self, node: Node<'a>) -> Result<Vec<Discovered>>;
}
impl<'a, FnT> Rule<'a> for FnT
where
FnT: Fn(Node<'a>) -> Result<Vec<Discovered>>,
{
fn check(&self, n: Node<'a>) -> Result<Vec<Discovered>> {
self(n)
}
}
/// Specific discovered lint rule Violation of a particular Finding.
#[derive(Clone, Debug)]
pub struct Discovered {
/// Zoo Lint Finding information.
pub finding: Finding,
/// Further information about the specific finding.
pub description: String,
/// Source code location.
pub pos: SourceRange,
/// Is this discovered issue overridden by the programmer?
pub overridden: bool,
}
impl IntoDiagnostic for Discovered {
fn to_lsp_diagnostic(&self, code: &str) -> Diagnostic {
let message = self.finding.title.to_owned();
let source_range = self.pos;
Diagnostic {
range: source_range.to_lsp_range(code),
severity: Some(DiagnosticSeverity::INFORMATION),
code: None,
// TODO: this is neat we can pass a URL to a help page here for this specific error.
code_description: None,
source: Some("lint".to_string()),
message,
related_information: None,
tags: None,
data: None,
}
}
}
/// Abstract lint problem type.
#[derive(Clone, Debug, PartialEq)]
pub struct Finding {
/// Unique identifier for this particular issue.
pub code: &'static str,
/// Short one-line description of this issue.
pub title: &'static str,
/// Long human-readable description of this issue.
pub description: &'static str,
/// Is this discovered issue experimental?
pub experimental: bool,
}
impl Finding {
/// Create a new Discovered finding at the specific Position.
pub fn at(&self, description: String, pos: SourceRange) -> Discovered {
Discovered {
description,
finding: self.clone(),
pos,
overridden: false,
}
}
}
macro_rules! def_finding {
( $code:ident, $title:expr, $description:expr ) => {
/// Generated Finding
pub const $code: Finding = $crate::lint::rule::finding!($code, $title, $description);
};
}
pub(crate) use def_finding;
macro_rules! finding {
( $code:ident, $title:expr, $description:expr ) => {
$crate::lint::rule::Finding {
code: stringify!($code),
title: $title,
description: $description,
experimental: false,
}
};
}
pub(crate) use finding;
#[cfg(test)]
pub(crate) use test::{assert_finding, assert_no_finding, test_finding, test_no_finding};
/// Check the provided Program for any Findings.
pub fn lint<'a, RuleT>(prog: &'a Program, rule: RuleT) -> Result<Vec<Discovered>>
where
RuleT: Rule<'a>,
{
let v = Arc::new(Mutex::new(vec![]));
walk(prog, &|node: Node<'a>| {
let mut findings = v.lock().map_err(|_| anyhow::anyhow!("mutex"))?;
findings.append(&mut rule.check(node)?);
Ok(true)
})?;
let x = v.lock().unwrap();
Ok(x.clone())
}
#[cfg(test)]
mod test {
macro_rules! assert_no_finding {
( $check:expr, $finding:expr, $kcl:expr ) => {
let tokens = $crate::token::lexer($kcl).unwrap();
let parser = $crate::parser::Parser::new(tokens);
let prog = parser.ast().unwrap();
for discovered_finding in $crate::lint::lint(&prog, $check).unwrap() {
if discovered_finding.finding == $finding {
assert!(false, "Finding {:?} was emitted", $finding.code);
}
}
};
}
macro_rules! assert_finding {
( $check:expr, $finding:expr, $kcl:expr ) => {
let tokens = $crate::token::lexer($kcl).unwrap();
let parser = $crate::parser::Parser::new(tokens);
let prog = parser.ast().unwrap();
for discovered_finding in $crate::lint::lint(&prog, $check).unwrap() {
if discovered_finding.finding == $finding {
return;
}
}
assert!(false, "Finding {:?} was not emitted", $finding.code);
};
}
macro_rules! test_finding {
( $name:ident, $check:expr, $finding:expr, $kcl:expr ) => {
#[test]
fn $name() {
$crate::lint::rule::assert_finding!($check, $finding, $kcl);
}
};
}
macro_rules! test_no_finding {
( $name:ident, $check:expr, $finding:expr, $kcl:expr ) => {
#[test]
fn $name() {
$crate::lint::rule::assert_no_finding!($check, $finding, $kcl);
}
};
}
pub(crate) use assert_finding;
pub(crate) use assert_no_finding;
pub(crate) use test_finding;
pub(crate) use test_no_finding;
}

View File

@ -36,9 +36,9 @@ use tower_lsp::{
use super::backend::{InnerHandle, UpdateHandle};
use crate::{
ast::types::VariableKind,
errors::KclError,
executor::SourceRange,
lsp::{backend::Backend as _, safemap::SafeMap},
lint::{checks, lint},
lsp::{backend::Backend as _, safemap::SafeMap, util::IntoDiagnostic},
parser::PIPE_OPERATOR,
};
@ -166,6 +166,7 @@ impl crate::lsp::backend::Backend for Backend {
}
async fn inner_on_change(&self, params: TextDocumentItem, force: bool) {
self.clear_diagnostics_map(&params.uri).await;
// We already updated the code map in the shared backend.
// Lets update the tokens.
@ -251,14 +252,14 @@ impl crate::lsp::backend::Backend for Backend {
// Execute the code if we have an executor context.
// This function automatically executes if we should & updates the diagnostics if we got
// errors.
let result = self.execute(&params, ast).await;
if result.is_err() {
// We return early because we got errors, and we don't want to clear the diagnostics.
if self.execute(&params, ast.clone()).await.is_err() {
// if there was an issue, let's bail and avoid trying to lint.
return;
}
// Lets update the diagnostics, since we got no errors.
self.clear_diagnostics(&params.uri).await;
for discovered_finding in lint(&ast, checks::lint_variables).into_iter().flatten() {
self.add_to_diagnostics(&params, discovered_finding).await;
}
}
}
@ -356,30 +357,7 @@ impl Backend {
.await;
}
async fn add_to_diagnostics(&self, params: &TextDocumentItem, err: KclError) {
let diagnostic = err.to_lsp_diagnostic(&params.text);
// We got errors, update the diagnostics.
self.diagnostics_map
.insert(
params.uri.to_string(),
DocumentDiagnosticReport::Full(RelatedFullDocumentDiagnosticReport {
related_documents: None,
full_document_diagnostic_report: FullDocumentDiagnosticReport {
result_id: None,
items: vec![diagnostic.clone()],
},
}),
)
.await;
// Publish the diagnostic.
// If the client supports it.
self.client
.publish_diagnostics(params.uri.clone(), vec![diagnostic], None)
.await;
}
async fn clear_diagnostics(&self, uri: &url::Url) {
async fn clear_diagnostics_map(&self, uri: &url::Url) {
self.diagnostics_map
.insert(
uri.to_string(),
@ -392,10 +370,43 @@ impl Backend {
}),
)
.await;
}
// Publish the diagnostic, we reset it here so the client knows the code compiles now.
// If the client supports it.
self.client.publish_diagnostics(uri.clone(), vec![], None).await;
async fn add_to_diagnostics<DiagT: IntoDiagnostic + std::fmt::Debug>(
&self,
params: &TextDocumentItem,
diagnostic: DiagT,
) {
self.client
.log_message(MessageType::INFO, format!("adding {:?} to diag", diagnostic))
.await;
let diagnostic = diagnostic.to_lsp_diagnostic(&params.text);
let DocumentDiagnosticReport::Full(mut report) = self
.diagnostics_map
.get(params.uri.clone().as_str())
.await
.unwrap_or(DocumentDiagnosticReport::Full(RelatedFullDocumentDiagnosticReport {
related_documents: None,
full_document_diagnostic_report: FullDocumentDiagnosticReport {
result_id: None,
items: vec![],
},
}))
else {
unreachable!();
};
report.full_document_diagnostic_report.items.push(diagnostic);
self.diagnostics_map
.insert(params.uri.to_string(), DocumentDiagnosticReport::Full(report.clone()))
.await;
self.client
.publish_diagnostics(params.uri.clone(), report.full_document_diagnostic_report.items, None)
.await;
}
async fn execute(&self, params: &TextDocumentItem, ast: crate::ast::types::Program) -> Result<()> {

View File

@ -7,3 +7,5 @@ mod safemap;
#[cfg(test)]
mod tests;
pub mod util;
pub use util::IntoDiagnostic;

View File

@ -1498,6 +1498,53 @@ async fn test_kcl_lsp_diagnostic_has_errors() {
}
}
#[tokio::test(flavor = "multi_thread")]
async fn test_kcl_lsp_diagnostic_has_lints() {
let server = kcl_lsp_server(false).await.unwrap();
// Send open file.
server
.did_open(tower_lsp::lsp_types::DidOpenTextDocumentParams {
text_document: tower_lsp::lsp_types::TextDocumentItem {
uri: "file:///testlint.kcl".try_into().unwrap(),
language_id: "kcl".to_string(),
version: 1,
text: r#"let THING = 10"#.to_string(),
},
})
.await;
server.wait_on_handle().await;
// Send diagnostics request.
let diagnostics = server
.diagnostic(tower_lsp::lsp_types::DocumentDiagnosticParams {
text_document: tower_lsp::lsp_types::TextDocumentIdentifier {
uri: "file:///testlint.kcl".try_into().unwrap(),
},
partial_result_params: Default::default(),
work_done_progress_params: Default::default(),
identifier: None,
previous_result_id: None,
})
.await
.unwrap();
// Check the diagnostics.
if let tower_lsp::lsp_types::DocumentDiagnosticReportResult::Report(diagnostics) = diagnostics {
if let tower_lsp::lsp_types::DocumentDiagnosticReport::Full(diagnostics) = diagnostics {
assert_eq!(diagnostics.full_document_diagnostic_report.items.len(), 1);
assert_eq!(
diagnostics.full_document_diagnostic_report.items[0].message,
"Identifiers must be lowerCamelCase"
);
} else {
panic!("Expected full diagnostics");
}
} else {
panic!("Expected diagnostics");
}
}
#[tokio::test(flavor = "multi_thread")]
async fn test_copilot_lsp_set_editor_info() {
let server = copilot_lsp_server().await.unwrap();

View File

@ -1,7 +1,7 @@
//! Utility functions for working with ropes and positions.
use ropey::Rope;
use tower_lsp::lsp_types::Position;
use tower_lsp::lsp_types::{Diagnostic, Position};
pub fn position_to_offset(position: Position, rope: &Rope) -> Option<usize> {
Some(rope.try_line_to_char(position.line as usize).ok()? + position.character as usize)
@ -31,3 +31,10 @@ pub fn get_line_before(pos: Position, rope: &Rope) -> Option<String> {
let line_start = offset - char_offset;
Some(rope.slice(line_start..offset).to_string())
}
/// Convert an object into a [lsp_types::Diagnostic] given the
/// [TextDocumentItem]'s `.text` field.
pub trait IntoDiagnostic {
/// Convert the traited object to a [lsp_types::Diagnostic].
fn to_lsp_diagnostic(&self, text: &str) -> Diagnostic;
}

View File

@ -0,0 +1,126 @@
//! Standard library chamfers.
use anyhow::Result;
use derive_docs::stdlib;
use kittycad::types::ModelingCmd;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::{
errors::{KclError, KclErrorDetails},
executor::{ExtrudeGroup, MemoryItem},
std::Args,
};
pub(crate) const DEFAULT_TOLERANCE: f64 = 0.0000001;
/// Data for chamfers.
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, ts_rs::TS, JsonSchema)]
#[ts(export)]
#[serde(rename_all = "camelCase")]
pub struct ChamferData {
/// The radius of the chamfer.
pub radius: f64,
/// The tags of the paths you want to chamfer.
pub tags: Vec<EdgeReference>,
}
/// A string or a uuid.
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, ts_rs::TS, JsonSchema, Ord, PartialOrd, Eq, Hash)]
#[ts(export)]
#[serde(untagged)]
pub enum EdgeReference {
/// A uuid of an edge.
Uuid(uuid::Uuid),
/// A tag name of an edge.
Tag(String),
}
/// Create chamfers on tagged paths.
pub async fn chamfer(args: Args) -> Result<MemoryItem, KclError> {
let (data, extrude_group): (ChamferData, Box<ExtrudeGroup>) = args.get_data_and_extrude_group()?;
let extrude_group = inner_chamfer(data, extrude_group, args).await?;
Ok(MemoryItem::ExtrudeGroup(extrude_group))
}
/// Create chamfers on tagged paths.
///
/// ```no_run
/// const width = 20
/// const length = 10
/// const thickness = 1
/// const chamferRadius = 2
///
/// const mountingPlateSketch = startSketchOn("XY")
/// |> startProfileAt([-width/2, -length/2], %)
/// |> lineTo([width/2, -length/2], %, 'edge1')
/// |> lineTo([width/2, length/2], %, 'edge2')
/// |> lineTo([-width/2, length/2], %, 'edge3')
/// |> close(%, 'edge4')
///
/// const mountingPlate = extrude(thickness, mountingPlateSketch)
/// |> chamfer({
/// radius: chamferRadius,
/// tags: [
/// getNextAdjacentEdge('edge1', %),
/// getNextAdjacentEdge('edge2', %),
/// getNextAdjacentEdge('edge3', %),
/// getNextAdjacentEdge('edge4', %)
/// ],
/// }, %)
/// ```
#[stdlib {
name = "chamfer",
}]
async fn inner_chamfer(
data: ChamferData,
extrude_group: Box<ExtrudeGroup>,
args: Args,
) -> Result<Box<ExtrudeGroup>, KclError> {
// Check if tags contains any duplicate values.
let mut tags = data.tags.clone();
tags.sort();
tags.dedup();
if tags.len() != data.tags.len() {
return Err(KclError::Type(KclErrorDetails {
message: "Duplicate tags are not allowed.".to_string(),
source_ranges: vec![args.source_range],
}));
}
for tag in data.tags {
let edge_id = match tag {
EdgeReference::Uuid(uuid) => uuid,
EdgeReference::Tag(tag) => {
extrude_group
.sketch_group_values
.iter()
.find(|p| p.get_name() == tag)
.ok_or_else(|| {
KclError::Type(KclErrorDetails {
message: format!("No edge found with tag: `{}`", tag),
source_ranges: vec![args.source_range],
})
})?
.get_base()
.geo_meta
.id
}
};
args.send_modeling_cmd(
uuid::Uuid::new_v4(),
ModelingCmd::Solid3DFilletEdge {
edge_id,
object_id: extrude_group.id,
radius: data.radius,
tolerance: DEFAULT_TOLERANCE, // We can let the user set this in the future.
cut_type: Some(kittycad::types::CutType::Chamfer),
},
)
.await?;
}
Ok(extrude_group)
}

View File

@ -117,6 +117,7 @@ async fn inner_fillet(
object_id: extrude_group.id,
radius: data.radius,
tolerance: DEFAULT_TOLERANCE, // We can let the user set this in the future.
cut_type: Some(kittycad::types::CutType::Fillet),
},
)
.await?;

View File

@ -275,7 +275,7 @@ pub async fn min(args: Args) -> Result<MemoryItem, KclError> {
tags = ["math"],
}]
fn inner_min(args: Vec<f64>) -> f64 {
let mut min = std::f64::MAX;
let mut min = f64::MAX;
for arg in args.iter() {
if *arg < min {
min = *arg;
@ -312,7 +312,7 @@ pub async fn max(args: Args) -> Result<MemoryItem, KclError> {
tags = ["math"],
}]
fn inner_max(args: Vec<f64>) -> f64 {
let mut max = std::f64::MIN;
let mut max = f64::MIN;
for arg in args.iter() {
if *arg > max {
max = *arg;

View File

@ -1,5 +1,6 @@
//! Functions implemented for language execution.
pub mod chamfer;
pub mod extrude;
pub mod fillet;
pub mod helix;
@ -10,6 +11,7 @@ pub mod patterns;
pub mod revolve;
pub mod segment;
pub mod shapes;
pub mod shell;
pub mod sketch;
pub mod types;
pub mod utils;
@ -29,7 +31,8 @@ use crate::{
docs::StdLibFn,
errors::{KclError, KclErrorDetails},
executor::{
ExecutorContext, ExtrudeGroup, MemoryItem, Metadata, SketchGroup, SketchGroupSet, SketchSurface, SourceRange,
ExecutorContext, ExtrudeGroup, ExtrudeGroupSet, MemoryItem, Metadata, SketchGroup, SketchGroupSet,
SketchSurface, SourceRange,
},
std::{kcl_stdlib::KclStdLibFn, sketch::SketchOnFaceTag},
};
@ -81,11 +84,13 @@ lazy_static! {
Box::new(crate::std::patterns::PatternLinear3D),
Box::new(crate::std::patterns::PatternCircular2D),
Box::new(crate::std::patterns::PatternCircular3D),
Box::new(crate::std::chamfer::Chamfer),
Box::new(crate::std::fillet::Fillet),
Box::new(crate::std::fillet::GetOppositeEdge),
Box::new(crate::std::fillet::GetNextAdjacentEdge),
Box::new(crate::std::fillet::GetPreviousAdjacentEdge),
Box::new(crate::std::helix::Helix),
Box::new(crate::std::shell::Shell),
Box::new(crate::std::revolve::Revolve),
Box::new(crate::std::revolve::GetEdge),
Box::new(crate::std::import::Import),
@ -769,6 +774,52 @@ impl Args {
Ok((data, sketch_surface, tag))
}
fn get_data_and_extrude_group_set<T: serde::de::DeserializeOwned>(&self) -> Result<(T, ExtrudeGroupSet), KclError> {
let first_value = self
.args
.first()
.ok_or_else(|| {
KclError::Type(KclErrorDetails {
message: format!("Expected a struct as the first argument, found `{:?}`", self.args),
source_ranges: vec![self.source_range],
})
})?
.get_json_value()?;
let data: T = serde_json::from_value(first_value).map_err(|e| {
KclError::Type(KclErrorDetails {
message: format!("Failed to deserialize struct from JSON: {}", e),
source_ranges: vec![self.source_range],
})
})?;
let second_value = self.args.get(1).ok_or_else(|| {
KclError::Type(KclErrorDetails {
message: format!(
"Expected an ExtrudeGroup as the second argument, found `{:?}`",
self.args
),
source_ranges: vec![self.source_range],
})
})?;
let extrude_set = if let MemoryItem::ExtrudeGroup(eg) = second_value {
ExtrudeGroupSet::ExtrudeGroup(eg.clone())
} else if let MemoryItem::ExtrudeGroups { value } = second_value {
ExtrudeGroupSet::ExtrudeGroups(value.clone())
} else {
return Err(KclError::Type(KclErrorDetails {
message: format!(
"Expected a ExtrudeGroup or Vector of ExtrudeGroups as the second argument, found `{:?}`",
self.args
),
source_ranges: vec![self.source_range],
}));
};
Ok((data, extrude_set))
}
fn get_data_and_extrude_group<T: serde::de::DeserializeOwned>(&self) -> Result<(T, Box<ExtrudeGroup>), KclError> {
let first_value = self
.args

View File

@ -8,7 +8,7 @@ use serde::{Deserialize, Serialize};
use crate::{
errors::{KclError, KclErrorDetails},
executor::{ExtrudeGroup, Geometries, Geometry, MemoryItem, SketchGroup, SketchGroupSet},
executor::{ExtrudeGroup, ExtrudeGroupSet, Geometries, Geometry, MemoryItem, SketchGroup, SketchGroupSet},
std::{types::Uint, Args},
};
@ -141,7 +141,7 @@ async fn inner_pattern_linear_2d(
/// A linear pattern on a 3D model.
pub async fn pattern_linear_3d(args: Args) -> Result<MemoryItem, KclError> {
let (data, extrude_group): (LinearPattern3dData, Box<ExtrudeGroup>) = args.get_data_and_extrude_group()?;
let (data, extrude_group_set): (LinearPattern3dData, ExtrudeGroupSet) = args.get_data_and_extrude_group_set()?;
if data.axis == [0.0, 0.0, 0.0] {
return Err(KclError::Semantic(KclErrorDetails {
@ -152,7 +152,7 @@ pub async fn pattern_linear_3d(args: Args) -> Result<MemoryItem, KclError> {
}));
}
let extrude_groups = inner_pattern_linear_3d(data, extrude_group, args).await?;
let extrude_groups = inner_pattern_linear_3d(data, extrude_group_set, args).await?;
Ok(MemoryItem::ExtrudeGroups { value: extrude_groups })
}
@ -178,26 +178,36 @@ pub async fn pattern_linear_3d(args: Args) -> Result<MemoryItem, KclError> {
}]
async fn inner_pattern_linear_3d(
data: LinearPattern3dData,
extrude_group: Box<ExtrudeGroup>,
extrude_group_set: ExtrudeGroupSet,
args: Args,
) -> Result<Vec<Box<ExtrudeGroup>>, KclError> {
let starting_extrude_groups = match extrude_group_set {
ExtrudeGroupSet::ExtrudeGroup(extrude_group) => vec![extrude_group],
ExtrudeGroupSet::ExtrudeGroups(extrude_groups) => extrude_groups,
};
if args.ctx.is_mock {
return Ok(vec![extrude_group.clone()]);
return Ok(starting_extrude_groups);
}
let geometries = pattern_linear(
LinearPattern::ThreeD(data),
Geometry::ExtrudeGroup(extrude_group),
args.clone(),
)
.await?;
let mut extrude_groups = Vec::new();
for extrude_group in starting_extrude_groups.iter() {
let geometries = pattern_linear(
LinearPattern::ThreeD(data.clone()),
Geometry::ExtrudeGroup(extrude_group.clone()),
args.clone(),
)
.await?;
let Geometries::ExtrudeGroups(extrude_groups) = geometries else {
return Err(KclError::Semantic(KclErrorDetails {
message: "Expected a vec of extrude groups".to_string(),
source_ranges: vec![args.source_range],
}));
};
let Geometries::ExtrudeGroups(new_extrude_groups) = geometries else {
return Err(KclError::Semantic(KclErrorDetails {
message: "Expected a vec of extrude groups".to_string(),
source_ranges: vec![args.source_range],
}));
};
extrude_groups.extend(new_extrude_groups);
}
Ok(extrude_groups)
}
@ -335,9 +345,9 @@ impl CircularPattern {
/// A circular pattern on a 2D sketch.
pub async fn pattern_circular_2d(args: Args) -> Result<MemoryItem, KclError> {
let (data, sketch_group): (CircularPattern2dData, Box<SketchGroup>) = args.get_data_and_sketch_group()?;
let (data, sketch_group_set): (CircularPattern2dData, SketchGroupSet) = args.get_data_and_sketch_group_set()?;
let sketch_groups = inner_pattern_circular_2d(data, sketch_group, args).await?;
let sketch_groups = inner_pattern_circular_2d(data, sketch_group_set, args).await?;
Ok(MemoryItem::SketchGroups { value: sketch_groups })
}
@ -364,35 +374,45 @@ pub async fn pattern_circular_2d(args: Args) -> Result<MemoryItem, KclError> {
}]
async fn inner_pattern_circular_2d(
data: CircularPattern2dData,
sketch_group: Box<SketchGroup>,
sketch_group_set: SketchGroupSet,
args: Args,
) -> Result<Vec<Box<SketchGroup>>, KclError> {
let starting_sketch_groups = match sketch_group_set {
SketchGroupSet::SketchGroup(sketch_group) => vec![sketch_group],
SketchGroupSet::SketchGroups(sketch_groups) => sketch_groups,
};
if args.ctx.is_mock {
return Ok(vec![sketch_group]);
return Ok(starting_sketch_groups);
}
let geometries = pattern_circular(
CircularPattern::TwoD(data),
Geometry::SketchGroup(sketch_group),
args.clone(),
)
.await?;
let mut sketch_groups = Vec::new();
for sketch_group in starting_sketch_groups.iter() {
let geometries = pattern_circular(
CircularPattern::TwoD(data.clone()),
Geometry::SketchGroup(sketch_group.clone()),
args.clone(),
)
.await?;
let Geometries::SketchGroups(sketch_groups) = geometries else {
return Err(KclError::Semantic(KclErrorDetails {
message: "Expected a vec of sketch groups".to_string(),
source_ranges: vec![args.source_range],
}));
};
let Geometries::SketchGroups(new_sketch_groups) = geometries else {
return Err(KclError::Semantic(KclErrorDetails {
message: "Expected a vec of sketch groups".to_string(),
source_ranges: vec![args.source_range],
}));
};
sketch_groups.extend(new_sketch_groups);
}
Ok(sketch_groups)
}
/// A circular pattern on a 3D model.
pub async fn pattern_circular_3d(args: Args) -> Result<MemoryItem, KclError> {
let (data, extrude_group): (CircularPattern3dData, Box<ExtrudeGroup>) = args.get_data_and_extrude_group()?;
let (data, extrude_group_set): (CircularPattern3dData, ExtrudeGroupSet) = args.get_data_and_extrude_group_set()?;
let extrude_groups = inner_pattern_circular_3d(data, extrude_group, args).await?;
let extrude_groups = inner_pattern_circular_3d(data, extrude_group_set, args).await?;
Ok(MemoryItem::ExtrudeGroups { value: extrude_groups })
}
@ -416,26 +436,36 @@ pub async fn pattern_circular_3d(args: Args) -> Result<MemoryItem, KclError> {
}]
async fn inner_pattern_circular_3d(
data: CircularPattern3dData,
extrude_group: Box<ExtrudeGroup>,
extrude_group_set: ExtrudeGroupSet,
args: Args,
) -> Result<Vec<Box<ExtrudeGroup>>, KclError> {
let starting_extrude_groups = match extrude_group_set {
ExtrudeGroupSet::ExtrudeGroup(extrude_group) => vec![extrude_group],
ExtrudeGroupSet::ExtrudeGroups(extrude_groups) => extrude_groups,
};
if args.ctx.is_mock {
return Ok(vec![extrude_group]);
return Ok(starting_extrude_groups);
}
let geometries = pattern_circular(
CircularPattern::ThreeD(data),
Geometry::ExtrudeGroup(extrude_group),
args.clone(),
)
.await?;
let mut extrude_groups = Vec::new();
for extrude_group in starting_extrude_groups.iter() {
let geometries = pattern_circular(
CircularPattern::ThreeD(data.clone()),
Geometry::ExtrudeGroup(extrude_group.clone()),
args.clone(),
)
.await?;
let Geometries::ExtrudeGroups(extrude_groups) = geometries else {
return Err(KclError::Semantic(KclErrorDetails {
message: "Expected a vec of extrude groups".to_string(),
source_ranges: vec![args.source_range],
}));
};
let Geometries::ExtrudeGroups(new_extrude_groups) = geometries else {
return Err(KclError::Semantic(KclErrorDetails {
message: "Expected a vec of extrude groups".to_string(),
source_ranges: vec![args.source_range],
}));
};
extrude_groups.extend(new_extrude_groups);
}
Ok(extrude_groups)
}

View File

@ -0,0 +1,140 @@
//! Standard library shells.
use anyhow::Result;
use derive_docs::stdlib;
use kittycad::types::ModelingCmd;
use parse_display::{Display, FromStr};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::{
errors::{KclError, KclErrorDetails},
executor::{ExtrudeGroup, ExtrudeSurface, MemoryItem},
std::{sketch::StartOrEnd, Args},
};
/// A tag for a face.
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, ts_rs::TS, JsonSchema, FromStr, Display)]
#[ts(export)]
#[serde(rename_all = "snake_case", untagged)]
#[display("{0}")]
pub enum FaceTag {
StartOrEnd(StartOrEnd),
/// A string tag for the face you want to sketch on.
String(String),
}
/// Data for shells.
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, ts_rs::TS, JsonSchema)]
#[ts(export)]
#[serde(rename_all = "camelCase")]
pub struct ShellData {
/// The thickness of the shell.
pub thickness: f64,
/// The faces you want removed.
pub faces: Vec<FaceTag>,
}
/// Create a shell.
pub async fn shell(args: Args) -> Result<MemoryItem, KclError> {
let (data, extrude_group): (ShellData, Box<ExtrudeGroup>) = args.get_data_and_extrude_group()?;
let extrude_group = inner_shell(data, extrude_group, args).await?;
Ok(MemoryItem::ExtrudeGroup(extrude_group))
}
/// Shell a solid.
///
/// ```no_run
/// const firstSketch = startSketchOn('XY')
/// |> startProfileAt([-12, 12], %)
/// |> line([24, 0], %)
/// |> line([0, -24], %)
/// |> line([-24, 0], %)
/// |> close(%)
/// |> extrude(6, %)
///
/// // Remove the end face for the extrusion.
/// shell({
/// faces: ['end'],
/// thickness: 0.25,
/// }, firstSketch)
/// ```
#[stdlib {
name = "shell",
}]
async fn inner_shell(
data: ShellData,
extrude_group: Box<ExtrudeGroup>,
args: Args,
) -> Result<Box<ExtrudeGroup>, KclError> {
if data.faces.is_empty() {
return Err(KclError::Type(KclErrorDetails {
message: "Expected at least one face".to_string(),
source_ranges: vec![args.source_range],
}));
}
let mut face_ids = Vec::new();
for tag in data.faces {
let extrude_plane_id = match tag {
FaceTag::String(ref s) => {
if s.is_empty() {
return Err(KclError::Type(KclErrorDetails {
message: "Expected a non-empty tag for the face".to_string(),
source_ranges: vec![args.source_range],
}));
}
extrude_group
.value
.iter()
.find_map(|extrude_surface| match extrude_surface {
ExtrudeSurface::ExtrudePlane(extrude_plane) if extrude_plane.name == *s => {
Some(extrude_plane.face_id)
}
ExtrudeSurface::ExtrudeArc(extrude_arc) if extrude_arc.name == *s => Some(extrude_arc.face_id),
ExtrudeSurface::ExtrudePlane(_) | ExtrudeSurface::ExtrudeArc(_) => None,
})
.ok_or_else(|| {
KclError::Type(KclErrorDetails {
message: format!("Expected a face with the tag `{}`", tag),
source_ranges: vec![args.source_range],
})
})?
}
FaceTag::StartOrEnd(StartOrEnd::Start) => extrude_group.start_cap_id.ok_or_else(|| {
KclError::Type(KclErrorDetails {
message: "Expected a start face".to_string(),
source_ranges: vec![args.source_range],
})
})?,
FaceTag::StartOrEnd(StartOrEnd::End) => extrude_group.end_cap_id.ok_or_else(|| {
KclError::Type(KclErrorDetails {
message: "Expected an end face".to_string(),
source_ranges: vec![args.source_range],
})
})?,
};
face_ids.push(extrude_plane_id);
}
if face_ids.is_empty() {
return Err(KclError::Type(KclErrorDetails {
message: "Expected at least one valid face".to_string(),
source_ranges: vec![args.source_range],
}));
}
args.send_modeling_cmd(
uuid::Uuid::new_v4(),
ModelingCmd::Solid3DShellFace {
face_ids,
object_id: extrude_group.id,
shell_thickness: data.thickness,
},
)
.await?;
Ok(extrude_group)
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

View File

@ -1959,3 +1959,53 @@ async fn serial_test_neg_xz_plane() {
let result = execute_and_snapshot(code, UnitLength::Mm).await.unwrap();
twenty_twenty::assert_image("tests/executor/outputs/neg_xz_plane.png", &result, 1.0);
}
#[tokio::test(flavor = "multi_thread")]
async fn serial_test_linear_pattern3d_a_pattern() {
let code = r#"const exampleSketch = startSketchOn('XZ')
|> startProfileAt([0, 0], %)
|> line([0, 2], %)
|> line([3, 1], %)
|> line([0, -4], %)
|> close(%)
|> extrude(1, %)
const pattn1 = patternLinear3d({
axis: [1, 0, 0],
repetitions: 6,
distance: 6
}, exampleSketch)
const pattn2 = patternLinear3d({
axis: [0, 0, 1],
distance: 1,
repetitions: 6
}, pattn1)
"#;
let result = execute_and_snapshot(code, UnitLength::Mm).await.unwrap();
twenty_twenty::assert_image("tests/executor/outputs/linear_pattern3d_a_pattern.png", &result, 1.0);
}
#[tokio::test(flavor = "multi_thread")]
async fn serial_test_circular_pattern3d_a_pattern() {
let code = r#"const exampleSketch = startSketchOn('XZ')
|> startProfileAt([0, 0], %)
|> line([0, 2], %)
|> line([3, 1], %)
|> line([0, -4], %)
|> close(%)
|> extrude(1, %)
const pattn1 = patternLinear3d({
axis: [1, 0, 0],
repetitions: 6,
distance: 6
}, exampleSketch)
const pattn2 = patternCircular3d({axis: [0,0, 1], center: [-20, -20, -20], repetitions: 40, arcDegrees: 360, rotateDuplicates: false}, pattn1)
"#;
let result = execute_and_snapshot(code, UnitLength::Mm).await.unwrap();
twenty_twenty::assert_image("tests/executor/outputs/circular_pattern3d_a_pattern.png", &result, 1.0);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 209 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 102 KiB

View File

@ -1880,10 +1880,10 @@
resolved "https://registry.yarnpkg.com/@juggle/resize-observer/-/resize-observer-3.4.0.tgz#08d6c5e20cf7e4cc02fd181c4b0c225cd31dbb60"
integrity sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==
"@kittycad/lib@^0.0.64":
version "0.0.64"
resolved "https://registry.yarnpkg.com/@kittycad/lib/-/lib-0.0.64.tgz#0cea0788cd8af4a8964ddbf7152028affadcb17f"
integrity sha512-qHyvNYKbhsfR5aXLFrdKrBQ4JI+0G0v096oROD3HatJ+AIzg5H0THmI+rMnQ9L4zx4U6n1A9gLi7ZQjSsZsleg==
"@kittycad/lib@^0.0.67":
version "0.0.67"
resolved "https://registry.yarnpkg.com/@kittycad/lib/-/lib-0.0.67.tgz#b8edc66d83e41a79a7f238ba41bc27f0101935fb"
integrity sha512-Uy2fve75bgpnlPiIgKrnKAqiko+1hlTCPSIPky6mv7Hrnwn7FhWAeeesdyc1Xws9Ae18kNyA2po8udK6PjZPkA==
dependencies:
node-fetch "3.3.2"
openapi-types "^12.0.0"