Compare commits

...

61 Commits

Author SHA1 Message Date
58b643cedd Update export test file sizes 2024-09-20 13:22:23 -04:00
5512f99997 Add a test step to confirm the solid bg of the settings dialog (#3927) 2024-09-20 12:04:36 -04:00
01cc9e751b implement from for unit length (#3932)
Signed-off-by: Jess Frazelle <github@jessfraz.com>
2024-09-19 17:24:09 -07:00
bfac6b7dc8 bump the world (kcl-lib) (#3930)
* bump the world

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

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

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

This reverts commit e095d2a990.

---------

Signed-off-by: Jess Frazelle <github@jessfraz.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-09-19 16:31:32 -07:00
d1f9a02ffa fix dumb ass logic bug with edge cuts, actually extends edge cuts array versus overwriting it lolz (#3928)
* dumb ass logic bug with edge cuts

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

* change order

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

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

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

This reverts commit 1ad9eb315e.

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

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

This reverts commit 4004c9c1db.

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

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

This reverts commit 57d0d05d06.

---------

Signed-off-by: Jess Frazelle <github@jessfraz.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-09-19 14:50:36 -07:00
d8236dd8da Fix zoom callback on camera controls (#3924) 2024-09-19 21:26:27 +00:00
dabf256e2b change to index map, re-fmt, and fillet face id (#3926)
* change to index map, re-fmt, and fillet face id

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-09-19 21:06:29 +00:00
4285e81001 Nadro/2833/zoom level increase when swapping sketch modes (#3854)
* fix: fixing logic around setting the perspective and position of perspective camera when leaving sketch mode

* fix: typo

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

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

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

This reverts commit 60b12ffc54.

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

This reverts commit 9ab973c6c4.

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

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

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

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

* remove unused vars

* fix: fixed the positions of the mouse clicks since the zoom level has changed?

* fix: updating test to make it work with my new zoom level

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

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

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Kurt Hutten Irev-Dev <k.hutten@protonmail.ch>
2024-09-19 14:39:32 -05:00
370375c328 Should we bump power precedence? (#3900)
* Should we bump power predecence

* add test

* fix stupid test name
2024-09-19 19:31:41 +10:00
9f22882c68 Add tests that verify folder deletion behavior (#3719)
* Move file tree deletion tests into file-tree.spect.ts

* Move other file tree tests into file-tree.spec.ts

* Add tests that prove we show a deletion confirmation for folders

* Fix lint warning
2024-09-19 15:15:02 +10:00
db5331d9b9 Rename UserVal::set to new to be clearer (#3913) 2024-09-19 00:23:19 +00:00
5cc92f0162 Replace kittycad crate with kittycad-modeling-cmds (#3909)
lib.rs/kittycad-modeling-cmds is the source of our Modeling API. It gets included in our backend APIs, and those APIs generate OpenAPI specs which are read by `openapitor` which generates the lib.rs/kittycad crate. So basically, our modeling app is using the _generated code_ instead of the _handwritten code_.

This sucks -- if you add a new field to the modeling-api crate, you have to merge PRs to the engine, api-deux, and kittycad.rs before finally you can get the new field into the modeling-app. I was pretty embarrased when @mlfarrell asked how to get a new field into the modeling app and had to explain this whole bullshit cycle. Let's fix it.

Switching to use the kittycad-modeling-cmds (aka kcmc) crate directly should speed up our dev cycle.
2024-09-18 17:04:04 -05:00
2978e80226 Bump vite from 5.4.2 to 5.4.3 (#3788)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 5.4.2 to 5.4.3.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v5.4.3/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-type: direct:development
  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-09-18 14:22:00 +10:00
4a74c60150 Shell two at once from the same sketch on face (#3908)
* shell two at once from the same obhject

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

* docs

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

---------

Signed-off-by: Jess Frazelle <github@jessfraz.com>
2024-09-17 18:49:08 -07:00
00fa40bbc9 Bug fix: make dismiss during export not fire success toast (#3882)
* Bug fix: make dismiss during export not fire success toast

* Fix export fail test, since this failure errors early now

* Remove throttling from send side

* Move toast.loading out to when first engine command is sent, so it is shown immediately

* Use shared, named constants for toast messages

* Hook up a couple other error toasts to the `pendingExport.toastId`
2024-09-17 19:06:06 -04:00
max
62b78840b6 Fix canExtrudeSelectionItem and getSelectionType for multiple selections (#3884)
* fix isSketchPipe in canExtrudeSelectionItem

* fix count in getSelectionType

used to count only same type as first selection
2024-09-17 22:32:07 +02:00
f828c36e58 renaming extrude to sweep to generalize the command (#3773)
* fix: just a one liner? forcing a revolve to be an extrude artifact

* fix: first step in renaming ExtrusionArtifact to SweepArtifact

* fix: renaming extrusion to sweep for a few things? need to do another pass

* fix: cleaning up comments, fixing unit tests for new key names

* fix: unit test update

* fix: removing TODO comments that are not needed

* fix: renaming more extrude/extrusion values to sweep

* fix: test:nowatch -u to update the vitests

* Look at this (photo)Graph *in the voice of Nickelback*

* fix: removing TODOs

* fix: forgot to update the extrudeEdge string in other files

* chore: adding e2e test to see if users can sketch on revolved face

* fix: removing garbage string

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-09-17 13:22:53 -05:00
8c5b146c94 Nadro/3716/mvp revolve (#3728)
* chore: Implemented a executeAst interrupt to stop processing a KCL program

* fix: added a catch since this promise was not being caught

* fix: fmt formatting, need to fix some tsc errors next.

* fix: fixing tsc errors

* fix: cleaning up comment

* fix: only rejecting pending modeling commands

* fix: adding constant for rejection message, adding rejection in WASM send command

* fix: tsc, lint, fmt checks

* feat: first pass over revolve with basic hard coded X axis

* fix: updated revolve status for DEV only

* fix: adding some TODOs to warn others about the Revolve MVP

* fix: fmt, lint, tsc checks

* fix: codespell got me

* fix: xstate v5 upgrade

* fix: removing this fix for a different PR. Not needed for initial MVP

* fix: renaming extrude function to sweep since it fixes extrude and revolve now

* fix: updating selection logic to support revolve

* fix: renaming extrude to sweep since it adds revolve

* fix: swapping as for type in function parameters

* fix: updated from object destruct to structuredClone

* fix: addressing PR comments

* fix: one other typo for return value of revolve
2024-09-17 08:29:52 -05:00
61c7d9844d Make light theme borders more contrasting, update sidebar icons (#3883)
* Make light theme borders more contrasting

* Update icons in sidebar

* fix disabled styles on ActionIcon

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

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

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

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

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

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

* Update src/components/CustomIcon.tsx

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

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

* trigger CI

* fmt

* Update "Make" button test locator to be more specific

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Kurt Hutten <k.hutten@protonmail.ch>
Co-authored-by: 49fl <ircsurfer33@gmail.com>
2024-09-16 17:12:54 -04:00
8d48c17395 Fix: Opposite adjacent edge selection (#3896)
* fix opposite adjacent edge selection

* make test more robust
2024-09-17 05:38:58 +10:00
0ff820d4da Unify execution state into a single struct (#3877)
* Add ExecState that combines ProgramMemory and DynamicState

* Remove unneeded clones

* Add exec_state parameter to all KCL stdlib functions

* Move pipe value into ExecState

* Add test for pipe substitution not leaking into function calls

* KCL: Better message on assertEqual function

Also add a new no-visual test for performance testing.

* Fix new array module to use ExecState

---------

Co-authored-by: Adam Chalmers <adam.chalmers@zoo.dev>
2024-09-16 19:10:33 +00:00
c4ff1c2ef1 Update types.md for constants (#3899)
Update types.md
2024-09-16 11:50:59 -07:00
b6aba2f29c Bump once_cell from 1.19.0 to 1.20.0 in /src/wasm-lib (#3889)
Bumps [once_cell](https://github.com/matklad/once_cell) from 1.19.0 to 1.20.0.
- [Changelog](https://github.com/matklad/once_cell/blob/master/CHANGELOG.md)
- [Commits](https://github.com/matklad/once_cell/compare/v1.19.0...v1.20.0)

---
updated-dependencies:
- dependency-name: once_cell
  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-09-16 11:34:58 -07:00
7467f7ea50 Bump pyo3 from 0.22.2 to 0.22.3 in /src/wasm-lib (#3890)
Bumps [pyo3](https://github.com/pyo3/pyo3) from 0.22.2 to 0.22.3.
- [Release notes](https://github.com/pyo3/pyo3/releases)
- [Changelog](https://github.com/PyO3/pyo3/blob/v0.22.3/CHANGELOG.md)
- [Commits](https://github.com/pyo3/pyo3/compare/v0.22.2...v0.22.3)

---
updated-dependencies:
- dependency-name: pyo3
  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-09-16 11:34:36 -07:00
0c6d3e0ccf Bump pretty_assertions from 1.4.0 to 1.4.1 in /src/wasm-lib (#3893)
Bumps [pretty_assertions](https://github.com/rust-pretty-assertions/rust-pretty-assertions) from 1.4.0 to 1.4.1.
- [Release notes](https://github.com/rust-pretty-assertions/rust-pretty-assertions/releases)
- [Changelog](https://github.com/rust-pretty-assertions/rust-pretty-assertions/blob/main/CHANGELOG.md)
- [Commits](https://github.com/rust-pretty-assertions/rust-pretty-assertions/compare/v1.4.0...v1.4.1)

---
updated-dependencies:
- dependency-name: pretty_assertions
  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-09-16 11:34:22 -07:00
e82917ea01 Bump anyhow from 1.0.88 to 1.0.89 in /src/wasm-lib (#3892)
Bumps [anyhow](https://github.com/dtolnay/anyhow) from 1.0.88 to 1.0.89.
- [Release notes](https://github.com/dtolnay/anyhow/releases)
- [Commits](https://github.com/dtolnay/anyhow/compare/1.0.88...1.0.89)

---
updated-dependencies:
- dependency-name: anyhow
  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-09-16 11:32:54 -07:00
857c1aad3d Bump tokio-tungstenite from 0.23.1 to 0.24.0 in /src/wasm-lib (#3891)
Bumps [tokio-tungstenite](https://github.com/snapview/tokio-tungstenite) from 0.23.1 to 0.24.0.
- [Changelog](https://github.com/snapview/tokio-tungstenite/blob/master/CHANGELOG.md)
- [Commits](https://github.com/snapview/tokio-tungstenite/commits/v0.24.0)

---
updated-dependencies:
- dependency-name: tokio-tungstenite
  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-09-16 11:31:55 -07:00
dc73acb1b1 KCL: Better message on assertEqual function (#3898)
Also add a new no-visual test for performance testing.
2024-09-16 11:43:49 -05:00
8602e937d3 Cut release v0.25.2 (#3879)
Co-authored-by: Pierre Jacquier <pierrejacquier39@gmail.com>
2024-09-16 11:45:13 -04:00
a2133d8317 Add updater-test back after electron migration (#3873)
* Add updater-test back after electron migration
Fixes #3871

* Separate updater-test files more

* Push dummy 0.999.999 version to updater-test

* Push 0.255.255

* Revert dummy push commits

* Clean up
2024-09-16 04:58:30 -04:00
39ce0da3e5 Fail playwright tests when console errors exists (#3345)
* initial console error whitelist

* add testInfo to the beforeEach

* set  COLLECT_CONSOLE_ERRORS

* add more console errors

* temporarily  set max_retrys to 0 instead of 4

* more console errors

* revert max retries back to 4

* add 'necessary' to complete sentence

* tweak env var name

* update whitelist

* test disabling flag

* update whitelist

* lint + enable for chrome only

* re-enabled on CI

* re-order whitelist

* create failOnConsoleErrors

* try update list

* add more to list

* tweak list again

* tweak again<

* tweak again

* tweak

* testInfo

* increase timeout

---------

Co-authored-by: ryanrosello-og <ry@zoo.dev>
Co-authored-by: Jess Frazelle <jessfraz@users.noreply.github.com>
Co-authored-by: Kurt Hutten Irev-Dev <k.hutten@protonmail.ch>
2024-09-16 07:32:33 +10:00
f235a950b0 KCL stdlib reduce function (#3881)
Adds an `arrayReduce` function to KCL stdlib. Right now, it can only reduce SketchGroup values because my implementation of higher-order KCL functions sucks. But we will generalize it in the future to be able to reduce any type.

This simplifies sketching polygons, e.g.

```
fn decagon = (radius) => {
  let step = (1/10) * tau()
  let sketch = startSketchAt([
    (cos(0) * radius), 
    (sin(0) * radius),
  ])
  return arrayReduce([1..10], sketch, (i, sg) => {
      let x = cos(step * i) * radius
      let y = sin(step * i) * radius
      return lineTo([x, y], sg)
  })
}
```

Part of #3842
2024-09-14 00:10:17 -04:00
3cd3e1af72 Bug fix: make "phantom side panes" get properly cleared (#3878)
* Write a failing test

* Make `openPanes` get cleared of any hidden panes

* Grrr fmt
2024-09-13 14:49:33 -04:00
8c6266e94b Bump bson from 2.11.0 to 2.12.0 in /src/wasm-lib (#3869)
Bumps [bson](https://github.com/mongodb/bson-rust) from 2.11.0 to 2.12.0.
- [Release notes](https://github.com/mongodb/bson-rust/releases)
- [Commits](https://github.com/mongodb/bson-rust/compare/v2.11.0...v2.12.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-09-13 09:42:02 -07:00
755a6016c7 Bump insta from 1.38.0 to 1.40.0 in /src/wasm-lib (#3840)
Bumps [insta](https://github.com/mitsuhiko/insta) from 1.38.0 to 1.40.0.
- [Release notes](https://github.com/mitsuhiko/insta/releases)
- [Changelog](https://github.com/mitsuhiko/insta/blob/master/CHANGELOG.md)
- [Commits](https://github.com/mitsuhiko/insta/compare/1.38.0...1.40.0)

---
updated-dependencies:
- dependency-name: insta
  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-09-13 09:41:38 -07:00
1cbbefba97 Update Onboarding Bracket (#3874)
* Update Onboarding Bracket

* update KCL header

* update text to go to last character in onboarding code and delete for error reporting

* update allowable tensile stress

* Update test

* fix text

* run prettier

* Make error message in tooltip not matter

* Image asset path needs to be relative on desktop

---------

Co-authored-by: Frank Noirot <frank@kittycad.io>
Co-authored-by: Frank Noirot <frank@zoo.dev>
2024-09-13 11:24:31 -04:00
8610d606f4 Refactor clientSide scene (#3859)
* refactor clientSide scene

* start consolidate threejs segment funcitons

* rename stuff

* first pass of integrating threejs segment create and update into one

* reduce create segment complexity

* add color back in

* use input

* fix comment

* feedback changes
2024-09-13 21:14:14 +10:00
728e87a627 Remove most of modelingMachine.context.store (#3867) 2024-09-12 22:06:50 -04:00
772034af68 Sketch on face of chamfer now works, added an example (#3876)
* sketch on face of chamfer example

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

* sketch on face of chamfer example

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

* make pretty

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

* docs

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

---------

Signed-off-by: Jess Frazelle <github@jessfraz.com>
2024-09-12 16:13:11 -07:00
957a9ca4fe bump kittycad.rs (#3875)
Signed-off-by: Jess Frazelle <github@jessfraz.com>
2024-09-12 14:23:54 -07:00
max
472eb2bafe AST mod for Multi-Edge Fillets (#3724)
* test

* test + selection loop

* wipe as

* multi body multi fillet test

* make eslint happy again

* as fatality

* Revert "make eslint happy again"

This reverts commit 21a966b9b0.

* lint error fix
2024-09-12 21:45:26 +02:00
88216d4c76 Bump serde from 1.0.209 to 1.0.210 in /src/wasm-lib (#3838)
Bumps [serde](https://github.com/serde-rs/serde) from 1.0.209 to 1.0.210.
- [Release notes](https://github.com/serde-rs/serde/releases)
- [Commits](https://github.com/serde-rs/serde/compare/v1.0.209...v1.0.210)

---
updated-dependencies:
- dependency-name: serde
  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-09-12 12:14:12 -07:00
8b1e4d6708 Bump anyhow from 1.0.86 to 1.0.88 in /src/wasm-lib (#3868)
Bumps [anyhow](https://github.com/dtolnay/anyhow) from 1.0.86 to 1.0.88.
- [Release notes](https://github.com/dtolnay/anyhow/releases)
- [Commits](https://github.com/dtolnay/anyhow/compare/1.0.86...1.0.88)

---
updated-dependencies:
- dependency-name: anyhow
  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-09-12 09:13:07 -07:00
769c3ec785 Remove double upload workaround for 0.25.1 release (#3870) 2024-09-12 05:18:37 -04:00
1c4179a9db Use Inter 4.0 as sans-serif font (#3857)
* Use Inter 4.0 as sans-serif font

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

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

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

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

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

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

* Host the Inter font locally

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

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

* Re-run CI

* Just use the variable font, it's the future

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

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

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

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

* Re-run CI

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

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

* Re-run CI

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-09-11 16:57:54 -04:00
292f89859f Make settings reset button only reset current level (#3855)
* Update test to expect new behavior (failing)

* Update behavior to match new test expectations

* Make reset button more clear

* Fix eslint issue

* Fix up separate test that relied on old reset logic
2024-09-11 09:39:10 -04:00
a00800bddc add more shell samples (#3861)
* add more shell sampels

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

* docs

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

---------

Signed-off-by: Jess Frazelle <github@jessfraz.com>
2024-09-10 19:50:34 -07:00
78ceba6d20 fixing the position and display of the segment labels during sketch mode (#3796)
* bug: fixing the position and display of the segment labels during sketch mode

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

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

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

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

* fix: minor visual tweaks

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

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

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

* fix: adding border styling

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

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

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

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

* feat: aligned the text to the slope of the line drawn

* fix: tsc, lint, fmt

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

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

* fix: linter warnings for unused variable

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-09-10 16:22:16 -05:00
6776a350af Update KNOWN-ISSUES.md 2024-09-10 13:19:49 -07:00
dd75f06f77 Add linting to codemirror-lsp-client (#3850) 2024-09-10 20:18:31 +00:00
394872d84e remove debug log (#3853) 2024-09-10 20:09:39 +00:00
f9eef6397f Do not read in the "theme" setting at the project level (#3845)
* Do not read in the "theme" setting at the project level

* Add a couple unit tests
2024-09-10 15:15:20 -04:00
900bac999c Bugfix: update sketch mode colors on theme change (#3849)
* Update client-side scene mesh base colors properly

* Add E2E test

* Remove use of `as`
2024-09-10 13:30:39 -04:00
5b2738f826 Fixing bug in convertThreeCamValuesToEngineCam (#3806)
fix: we need the up of the camera, not a hard coded up
2024-09-10 11:45:32 -05:00
dab96577a7 fix: users shouldn't have to press down arrow twice to select an option (#3809)
* fix: users shouldn't have to press down arrow twice to select an option

* add regression test for cmd bar arrow

* tweak
2024-09-10 02:10:14 +00:00
25443eba31 internal: Add lints for promises (#3733)
* Add lints for floating and misued promises

* Add logging async errors in main

* Add async error catch in test-utils

* Change any to unknown

* Trap promise errors and ignore more await warnings

* Add more ignores and toSync helper

* Fix more lint warnings

* Add more ignores and fixes

* Add more reject reporting

* Add accepting arbitrary parameters to toSync()

* Fix more lints

* Revert unintentional change to non-arrow function

* Revert unintentional change to use arrow function

* Fix new warnings in main with auto updater

* Fix formatting

* Change lints to error

This is what the recommended type checked rules do.

* Fix to properly report promise rejections

* Fix formatting

* Fix formatting

* Remove unused import

* Remove unused convenience function

* Move type helpers

* Fix to not return promise when caller doesn't expect it

* Add ignores to lsp code
2024-09-10 08:17:45 +10:00
0a72d7a39a Remove ill-advised CSS added during #3794 (#3844) 2024-09-09 17:23:01 -04:00
5f8d4f8294 Migrate to XState v5 (#3735)
* migrate settingsMachine

* Guard events with properties instead

* migrate settingsMachine

* Migrate auth machine

* Migrate file machine

* Migrate depracated types

* Migrate home machine

* Migrate command bar machine

* Version fixes

* Migrate command bar machine

* Migrate modeling machine

* Migrate types, state.can, state.matches and state.nextEvents

* Fix syntax

* Pass in modelingState into editor manager instead of modeling event

* Fix issue with missing command bar provider

* Fix state transition

* Fix type issue in Home

* Make sure no guards rely on event type

* Fix up command bar submission logic

* Home machine tweaks to get things running

* Fix AST fillet function args

* Handle "Set selection" when it is called by actor onDone

* Remove unused imports

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

* Fix injectin project to the fileTree machine

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

This reverts commit 4b43ff69d1.

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

* Re-run CI

* Restore success toasts on file/folder deletion

* Replace casting with guarding against event.type

* Remove console.log

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

* Replace all instances of event casting with guards against event.type

---------

Co-authored-by: Frank Noirot <frank@kittycad.io>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Kurt Hutten Irev-Dev <k.hutten@protonmail.ch>
Co-authored-by: Jonathan Tran <jonnytran@gmail.com>
Co-authored-by: Frank Noirot <frank@zoo.dev>
2024-09-09 12:59:36 -04:00
max
7c2cfba0ac Extract updateAstAndFocus from Main Function (#3832)
refactor: pull out updateAst and focus
2024-09-09 12:15:16 +02:00
5ee43bda22 Move recast functions to new unparser module (#3824)
This just moves code.  Nothing else was changed.
2024-09-07 12:51:35 -04:00
a1b6bbac7e Replace msi with exe/nsis in download endpoint generation (#3828) 2024-09-06 20:42:47 -04:00
285 changed files with 28833 additions and 13423 deletions

View File

@ -13,6 +13,8 @@
"plugin:css-modules/recommended" "plugin:css-modules/recommended"
], ],
"rules": { "rules": {
"@typescript-eslint/no-floating-promises": "error",
"@typescript-eslint/no-misused-promises": "error",
"semi": [ "semi": [
"error", "error",
"never" "never"
@ -24,7 +26,6 @@
{ {
"files": ["e2e/**/*.ts"], // Update the pattern based on your file structure "files": ["e2e/**/*.ts"], // Update the pattern based on your file structure
"rules": { "rules": {
"@typescript-eslint/no-floating-promises": "warn",
"suggest-no-throw/suggest-no-throw": "off", "suggest-no-throw/suggest-no-throw": "off",
"testing-library/prefer-screen-queries": "off", "testing-library/prefer-screen-queries": "off",
"jest/valid-expect": "off" "jest/valid-expect": "off"

View File

@ -52,7 +52,6 @@ jobs:
VERSION=$(date +'%-y.%-m.%-d') yarn bump-jsons VERSION=$(date +'%-y.%-m.%-d') yarn bump-jsons
# TODO: see if we need to inject updater nightly URL here https://dl.zoo.dev/releases/modeling-app/nightly/last_update.json # TODO: see if we need to inject updater nightly URL here https://dl.zoo.dev/releases/modeling-app/nightly/last_update.json
# TODO: see if we ned to add updater test URL here https://dl.zoo.dev/releases/modeling-app/updater-test/last_update.json
- uses: actions/upload-artifact@v3 - uses: actions/upload-artifact@v3
with: with:
@ -64,6 +63,17 @@ jobs:
- id: export_version - id: export_version
run: echo "version=`cat package.json | jq -r '.version'`" >> "$GITHUB_OUTPUT" run: echo "version=`cat package.json | jq -r '.version'`" >> "$GITHUB_OUTPUT"
- name: Prepare electron-builder.yml file for updater test
if: ${{ env.CUT_RELEASE_PR == 'true' }}
run: |
yq -i '.publish[0].url = "https://dl.zoo.dev/releases/modeling-app/updater-test"' electron-builder.yml
- uses: actions/upload-artifact@v3
with:
name: prepared-files-updater-test
path: |
electron-builder.yml
build-apps: build-apps:
needs: [prepare-files] needs: [prepare-files]
@ -149,7 +159,27 @@ jobs:
# TODO: add the 'Build for Mac TestFlight (nightly)' stage back # TODO: add the 'Build for Mac TestFlight (nightly)' stage back
# TODO: add the updater tests back - uses: actions/download-artifact@v3
if: ${{ env.CUT_RELEASE_PR == 'true' }}
name: prepared-files-updater-test
- name: Copy updated electron-builder.yml file for updater test
if: ${{ env.CUT_RELEASE_PR == 'true' }}
run: |
ls -R prepared-files-updater-test
cp prepared-files-updater-test/electron-builder.yml electron-builder.yml
- name: Build the app (updater-test)
if: ${{ env.CUT_RELEASE_PR == 'true' }}
run: yarn electron-builder --config ${{ env.BUILD_RELEASE && '--publish always' || '' }}
- uses: actions/upload-artifact@v3
if: ${{ env.CUT_RELEASE_PR == 'true' }}
with:
name: updater-test-${{ matrix.os }}
path: |
out/Zoo*.*
out/latest*.yml
publish-apps-release: publish-apps-release:
@ -193,8 +223,8 @@ jobs:
--arg notes "${NOTES}" \ --arg notes "${NOTES}" \
--arg mac_arm64_url "$RELEASE_DIR/${{ env.URL_CODED_NAME }}-${VERSION_NO_V}-arm64-mac.dmg" \ --arg mac_arm64_url "$RELEASE_DIR/${{ env.URL_CODED_NAME }}-${VERSION_NO_V}-arm64-mac.dmg" \
--arg mac_x64_url "$RELEASE_DIR/${{ env.URL_CODED_NAME }}-${VERSION_NO_V}-x64-mac.dmg" \ --arg mac_x64_url "$RELEASE_DIR/${{ env.URL_CODED_NAME }}-${VERSION_NO_V}-x64-mac.dmg" \
--arg windows_arm64_url "$RELEASE_DIR/${{ env.URL_CODED_NAME }}-${VERSION_NO_V}-arm64-win.msi" \ --arg windows_arm64_url "$RELEASE_DIR/${{ env.URL_CODED_NAME }}-${VERSION_NO_V}-arm64-win.exe" \
--arg windows_x64_url "$RELEASE_DIR/${{ env.URL_CODED_NAME }}-${VERSION_NO_V}-x64-win.msi" \ --arg windows_x64_url "$RELEASE_DIR/${{ env.URL_CODED_NAME }}-${VERSION_NO_V}-x64-win.exe" \
--arg linux_arm64_url "$RELEASE_DIR/${{ env.URL_CODED_NAME }}-${VERSION_NO_V}-arm64-linux.AppImage" \ --arg linux_arm64_url "$RELEASE_DIR/${{ env.URL_CODED_NAME }}-${VERSION_NO_V}-arm64-linux.AppImage" \
--arg linux_x64_url "$RELEASE_DIR/${{ env.URL_CODED_NAME }}-${VERSION_NO_V}-x86_64-linux.AppImage" \ --arg linux_x64_url "$RELEASE_DIR/${{ env.URL_CODED_NAME }}-${VERSION_NO_V}-x86_64-linux.AppImage" \
'{ '{
@ -208,10 +238,10 @@ jobs:
"dmg-x64": { "dmg-x64": {
"url": $mac_x64_url "url": $mac_x64_url
}, },
"msi-arm64": { "exe-arm64": {
"url": $windows_arm64_url "url": $windows_arm64_url
}, },
"msi-x64": { "exe-x64": {
"url": $windows_x64_url "url": $windows_x64_url
}, },
"appimage-arm64": { "appimage-arm64": {
@ -245,15 +275,6 @@ jobs:
parent: false parent: false
destination: ${{ env.BUCKET_DIR }} destination: ${{ env.BUCKET_DIR }}
# TODO: remove workaround introduced in https://github.com/KittyCAD/modeling-app/issues/3817
- name: Upload release files to public bucket (test/electron-builder workaround)
uses: google-github-actions/upload-cloud-storage@v2.2.0
with:
path: out
glob: 'Zoo*'
parent: false
destination: '${{ env.BUCKET_DIR }}/test/electron-builder'
- name: Upload update endpoint to public bucket - name: Upload update endpoint to public bucket
uses: google-github-actions/upload-cloud-storage@v2.2.0 uses: google-github-actions/upload-cloud-storage@v2.2.0
with: with:
@ -262,15 +283,6 @@ jobs:
parent: false parent: false
destination: ${{ env.BUCKET_DIR }} destination: ${{ env.BUCKET_DIR }}
# TODO: remove workaround introduced in https://github.com/KittyCAD/modeling-app/issues/3817
- name: Upload update endpoint to public bucket (test/electron-builder workaround)
uses: google-github-actions/upload-cloud-storage@v2.2.0
with:
path: out
glob: 'latest*'
parent: false
destination: '${{ env.BUCKET_DIR }}/test/electron-builder'
- name: Upload download endpoint to public bucket - name: Upload download endpoint to public bucket
uses: google-github-actions/upload-cloud-storage@v2.2.0 uses: google-github-actions/upload-cloud-storage@v2.2.0
with: with:

View File

@ -45,7 +45,7 @@ jobs:
- run: yarn xstate:typegen - run: yarn xstate:typegen
- run: yarn tsc - run: yarn tsc
- name: Lint - name: Lint
run: yarn eslint --max-warnings 0 src e2e run: yarn eslint --max-warnings 0 src e2e packages/codemirror-lsp-client
check-typos: check-typos:

View File

@ -34,7 +34,7 @@ jobs:
- 'src/wasm-lib/**' - 'src/wasm-lib/**'
playwright-chrome: playwright-chrome:
timeout-minutes: ${{ matrix.os == 'macos-14' && 60 || 40 }} timeout-minutes: ${{ matrix.os == 'macos-14' && 60 || 50 }}
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
@ -232,6 +232,7 @@ jobs:
exit 0 exit 0
env: env:
CI: true CI: true
FAIL_ON_CONSOLE_ERRORS: true
NODE_ENV: development NODE_ENV: development
VITE_KC_DEV_TOKEN: ${{ secrets.KITTYCAD_API_TOKEN_DEV }} VITE_KC_DEV_TOKEN: ${{ secrets.KITTYCAD_API_TOKEN_DEV }}
VITE_KC_SKIP_AUTH: true VITE_KC_SKIP_AUTH: true
@ -410,6 +411,7 @@ jobs:
exit 0 exit 0
env: env:
CI: true CI: true
FAIL_ON_CONSOLE_ERRORS: true
NODE_ENV: development NODE_ENV: development
VITE_KC_DEV_TOKEN: ${{ secrets.KITTYCAD_API_TOKEN_DEV }} VITE_KC_DEV_TOKEN: ${{ secrets.KITTYCAD_API_TOKEN_DEV }}
VITE_KC_SKIP_AUTH: true VITE_KC_SKIP_AUTH: true

View File

@ -22,8 +22,3 @@ once fixed in engine will just start working here with no language changes.
- **Chamfers**: Chamfers cannot intersect, you will get an error. Only simple - **Chamfers**: Chamfers cannot intersect, you will get an error. Only simple
chamfer cases work currently. chamfer cases work currently.
Sketching on the chamfered face does not currently work.
- **Shell**: Shell sometimes does not work when arcs or fillets are involved.
We are tracking the engine side bug on this.

858
docs/kcl/arrayReduce.md Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -19,6 +19,7 @@ layout: manual
* [`angledLineToX`](kcl/angledLineToX) * [`angledLineToX`](kcl/angledLineToX)
* [`angledLineToY`](kcl/angledLineToY) * [`angledLineToY`](kcl/angledLineToY)
* [`arc`](kcl/arc) * [`arc`](kcl/arc)
* [`arrayReduce`](kcl/arrayReduce)
* [`asin`](kcl/asin) * [`asin`](kcl/asin)
* [`assert`](kcl/assert) * [`assert`](kcl/assert)
* [`assertEqual`](kcl/assertEqual) * [`assertEqual`](kcl/assertEqual)

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@ -13,14 +13,16 @@ arrays can hold objects and vice versa.
`true` or `false` work when defining values. `true` or `false` work when defining values.
## Variable declaration ## Constant declaration
Variables are defined with the `let` keyword like so: Constants are defined with the `let` keyword like so:
``` ```
let myBool = false let myBool = false
``` ```
Currently you cannot redeclare a constant.
## Array ## Array
An array is defined with `[]` braces. What is inside the brackets can An array is defined with `[]` braces. What is inside the brackets can

View File

@ -8,8 +8,8 @@ import {
PERSIST_MODELING_CONTEXT, PERSIST_MODELING_CONTEXT,
} from './test-utils' } from './test-utils'
test.beforeEach(async ({ context, page }) => { test.beforeEach(async ({ context, page }, testInfo) => {
await setup(context, page) await setup(context, page, testInfo)
}) })
test.afterEach(async ({ page }, testInfo) => { test.afterEach(async ({ page }, testInfo) => {

View File

@ -3,8 +3,8 @@ import { getUtils, setup, tearDown } from './test-utils'
import { EngineCommand } from 'lang/std/artifactGraph' import { EngineCommand } from 'lang/std/artifactGraph'
import { uuidv4 } from 'lib/utils' import { uuidv4 } from 'lib/utils'
test.beforeEach(async ({ context, page }) => { test.beforeEach(async ({ context, page }, testInfo) => {
await setup(context, page) await setup(context, page, testInfo)
}) })
test.afterEach(async ({ page }, testInfo) => { test.afterEach(async ({ page }, testInfo) => {

View File

@ -12,8 +12,8 @@ import { bracket } from 'lib/exampleKcl'
import { TEST_CODE_LONG_WITH_ERROR_OUT_OF_VIEW } from './storageStates' import { TEST_CODE_LONG_WITH_ERROR_OUT_OF_VIEW } from './storageStates'
import fsp from 'fs/promises' import fsp from 'fs/promises'
test.beforeEach(async ({ context, page }) => { test.beforeEach(async ({ context, page }, testInfo) => {
await setup(context, page) await setup(context, page, testInfo)
}) })
test.afterEach(async ({ page }, testInfo) => { test.afterEach(async ({ page }, testInfo) => {
@ -65,6 +65,8 @@ const extrude001 = extrude(5, sketch001)`
test('Opening and closing the code pane will consistently show error diagnostics', async ({ test('Opening and closing the code pane will consistently show error diagnostics', async ({
page, page,
}) => { }) => {
await page.goto('http://localhost:3000')
const u = await getUtils(page) const u = await getUtils(page)
// Load the app with the working starter code // Load the app with the working starter code
@ -90,7 +92,7 @@ const extrude001 = extrude(5, sketch001)`
// Delete a character to break the KCL // Delete a character to break the KCL
await u.openKclCodePanel() await u.openKclCodePanel()
await page.getByText('extrude(').click() await page.getByText('thickness, bracketLeg1Sketch)').click()
await page.keyboard.press('Backspace') await page.keyboard.press('Backspace')
// Ensure that a badge appears on the button // Ensure that a badge appears on the button
@ -101,7 +103,7 @@ const extrude001 = extrude(5, sketch001)`
// error text on hover // error text on hover
await page.hover('.cm-lint-marker-error') await page.hover('.cm-lint-marker-error')
await expect(page.getByText('Unexpected token: |').first()).toBeVisible() await expect(page.locator('.cm-tooltip').first()).toBeVisible()
// Close the code pane // Close the code pane
await codePaneButton.click() await codePaneButton.click()
@ -124,7 +126,7 @@ const extrude001 = extrude(5, sketch001)`
// error text on hover // error text on hover
await page.hover('.cm-lint-marker-error') await page.hover('.cm-lint-marker-error')
await expect(page.getByText('Unexpected token: |').first()).toBeVisible() await expect(page.locator('.cm-tooltip').first()).toBeVisible()
}) })
test('When error is not in view you can click the badge to scroll to it', async ({ test('When error is not in view you can click the badge to scroll to it', async ({

View File

@ -3,8 +3,8 @@ import { test, expect } from '@playwright/test'
import { getUtils, setup, tearDown } from './test-utils' import { getUtils, setup, tearDown } from './test-utils'
import { KCL_DEFAULT_LENGTH } from 'lib/constants' import { KCL_DEFAULT_LENGTH } from 'lib/constants'
test.beforeEach(async ({ context, page }) => { test.beforeEach(async ({ context, page }, testInfo) => {
await setup(context, page) await setup(context, page, testInfo)
}) })
test.afterEach(async ({ page }, testInfo) => { test.afterEach(async ({ page }, testInfo) => {

View File

@ -1,8 +1,8 @@
import { test, expect } from '@playwright/test' import { test, expect } from '@playwright/test'
import { getUtils, setup, tearDown } from './test-utils' import { getUtils, setup, tearDown } from './test-utils'
test.beforeEach(async ({ context, page }) => { test.beforeEach(async ({ context, page }, testInfo) => {
await setup(context, page) await setup(context, page, testInfo)
}) })
test.afterEach(async ({ page }, testInfo) => { test.afterEach(async ({ page }, testInfo) => {

View File

@ -104,7 +104,7 @@ test(
}, },
{ timeout: 15_000 } { timeout: 15_000 }
) )
.toBe(477481) .toBe(482669)
// clean up output.gltf // clean up output.gltf
await fsp.rm('output.gltf') await fsp.rm('output.gltf')

View File

@ -2,8 +2,8 @@ import { test, expect } from '@playwright/test'
import { uuidv4 } from 'lib/utils' import { uuidv4 } from 'lib/utils'
import { getUtils, setup, tearDown } from './test-utils' import { getUtils, setup, tearDown } from './test-utils'
test.beforeEach(async ({ context, page }) => { test.beforeEach(async ({ context, page }, testInfo) => {
await setup(context, page) await setup(context, page, testInfo)
}) })
test.afterEach(async ({ page }, testInfo) => { test.afterEach(async ({ page }, testInfo) => {

View File

@ -1,9 +1,18 @@
import { test, expect } from '@playwright/test' import { test, expect } from '@playwright/test'
import * as fsp from 'fs/promises' import * as fsp from 'fs/promises'
import { getUtils, setup, setupElectron, tearDown } from './test-utils' import * as fs from 'fs'
import {
executorInputPath,
getUtils,
setup,
setupElectron,
tearDown,
} from './test-utils'
import { join } from 'path'
import { FILE_EXT } from 'lib/constants'
test.beforeEach(async ({ context, page }) => { test.beforeEach(async ({ context, page }, testInfo) => {
await setup(context, page) await setup(context, page, testInfo)
}) })
test.afterEach(async ({ page }, testInfo) => { test.afterEach(async ({ page }, testInfo) => {
@ -108,7 +117,6 @@ test.describe('when using the file tree to', () => {
async ({ browser: _ }, testInfo) => { async ({ browser: _ }, testInfo) => {
const { electronApp, page } = await setupElectron({ const { electronApp, page } = await setupElectron({
testInfo, testInfo,
folderSetupFn: async () => {},
}) })
const { const {
@ -151,6 +159,7 @@ test.describe('when using the file tree to', () => {
await selectFile(kcl1) await selectFile(kcl1)
await editorTextMatches(kclCube) await editorTextMatches(kclCube)
}) })
await page.waitForTimeout(500)
await test.step(`Postcondition: ${kcl2} still exists with the original content`, async () => { await test.step(`Postcondition: ${kcl2} still exists with the original content`, async () => {
await selectFile(kcl2) await selectFile(kcl2)
@ -277,3 +286,584 @@ test.describe('when using the file tree to', () => {
} }
) )
}) })
test.describe('Renaming in the file tree', () => {
test(
'A file you have open',
{ tag: '@electron' },
async ({ browser: _ }, testInfo) => {
const { electronApp, page, dir } = await setupElectron({
testInfo,
folderSetupFn: async (dir) => {
await fsp.mkdir(join(dir, 'Test Project'), { recursive: true })
await fsp.copyFile(
executorInputPath('basic_fillet_cube_end.kcl'),
join(dir, 'Test Project', 'main.kcl')
)
await fsp.copyFile(
executorInputPath('cylinder.kcl'),
join(dir, 'Test Project', 'fileToRename.kcl')
)
},
})
const u = await getUtils(page)
await page.setViewportSize({ width: 1200, height: 500 })
page.on('console', console.log)
// Constants and locators
const projectLink = page.getByText('Test Project')
const projectMenuButton = page.getByTestId('project-sidebar-toggle')
const checkUnRenamedFS = () => {
const filePath = join(dir, 'Test Project', 'fileToRename.kcl')
return fs.existsSync(filePath)
}
const newFileName = 'newFileName'
const checkRenamedFS = () => {
const filePath = join(dir, 'Test Project', `${newFileName}.kcl`)
return fs.existsSync(filePath)
}
const fileToRename = page
.getByRole('listitem')
.filter({ has: page.getByRole('button', { name: 'fileToRename.kcl' }) })
const renamedFile = page
.getByRole('listitem')
.filter({ has: page.getByRole('button', { name: 'newFileName.kcl' }) })
const renameMenuItem = page.getByRole('button', { name: 'Rename' })
const renameInput = page.getByPlaceholder('fileToRename.kcl')
const codeLocator = page.locator('.cm-content')
await test.step('Open project and file pane', async () => {
await expect(projectLink).toBeVisible()
await projectLink.click()
await expect(projectMenuButton).toBeVisible()
await expect(projectMenuButton).toContainText('main.kcl')
await u.openFilePanel()
await expect(fileToRename).toBeVisible()
expect(checkUnRenamedFS()).toBeTruthy()
expect(checkRenamedFS()).toBeFalsy()
await fileToRename.click()
await expect(projectMenuButton).toContainText('fileToRename.kcl')
await u.openKclCodePanel()
await expect(codeLocator).toContainText('circle(')
await u.closeKclCodePanel()
})
await test.step('Rename the file', async () => {
await fileToRename.click({ button: 'right' })
await renameMenuItem.click()
await expect(renameInput).toBeVisible()
await renameInput.fill(newFileName)
await page.keyboard.press('Enter')
})
await test.step('Verify the file is renamed', async () => {
await expect(fileToRename).not.toBeAttached()
await expect(renamedFile).toBeVisible()
expect(checkUnRenamedFS()).toBeFalsy()
expect(checkRenamedFS()).toBeTruthy()
})
await test.step('Verify we navigated', async () => {
await expect(projectMenuButton).toContainText(newFileName + FILE_EXT)
const url = page.url()
expect(url).toContain(newFileName)
await expect(projectMenuButton).not.toContainText('fileToRename.kcl')
await expect(projectMenuButton).not.toContainText('main.kcl')
expect(url).not.toContain('fileToRename.kcl')
expect(url).not.toContain('main.kcl')
await u.openKclCodePanel()
await expect(codeLocator).toContainText('circle(')
})
await electronApp.close()
}
)
test(
'A file you do not have open',
{ tag: '@electron' },
async ({ browser: _ }, testInfo) => {
const { electronApp, page, dir } = await setupElectron({
testInfo,
folderSetupFn: async (dir) => {
await fsp.mkdir(join(dir, 'Test Project'), { recursive: true })
await fsp.copyFile(
executorInputPath('basic_fillet_cube_end.kcl'),
join(dir, 'Test Project', 'main.kcl')
)
await fsp.copyFile(
executorInputPath('cylinder.kcl'),
join(dir, 'Test Project', 'fileToRename.kcl')
)
},
})
const u = await getUtils(page)
await page.setViewportSize({ width: 1200, height: 500 })
page.on('console', console.log)
// Constants and locators
const newFileName = 'newFileName'
const checkUnRenamedFS = () => {
const filePath = join(dir, 'Test Project', 'fileToRename.kcl')
return fs.existsSync(filePath)
}
const checkRenamedFS = () => {
const filePath = join(dir, 'Test Project', `${newFileName}.kcl`)
return fs.existsSync(filePath)
}
const projectLink = page.getByText('Test Project')
const projectMenuButton = page.getByTestId('project-sidebar-toggle')
const fileToRename = page
.getByRole('listitem')
.filter({ has: page.getByRole('button', { name: 'fileToRename.kcl' }) })
const renamedFile = page.getByRole('listitem').filter({
has: page.getByRole('button', { name: newFileName + FILE_EXT }),
})
const renameMenuItem = page.getByRole('button', { name: 'Rename' })
const renameInput = page.getByPlaceholder('fileToRename.kcl')
const codeLocator = page.locator('.cm-content')
await test.step('Open project and file pane', async () => {
await expect(projectLink).toBeVisible()
await projectLink.click()
await expect(projectMenuButton).toBeVisible()
await expect(projectMenuButton).toContainText('main.kcl')
await u.openFilePanel()
await expect(fileToRename).toBeVisible()
expect(checkUnRenamedFS()).toBeTruthy()
expect(checkRenamedFS()).toBeFalsy()
})
await test.step('Rename the file', async () => {
await fileToRename.click({ button: 'right' })
await renameMenuItem.click()
await expect(renameInput).toBeVisible()
await renameInput.fill(newFileName)
await page.keyboard.press('Enter')
})
await test.step('Verify the file is renamed', async () => {
await expect(fileToRename).not.toBeAttached()
await expect(renamedFile).toBeVisible()
expect(checkUnRenamedFS()).toBeFalsy()
expect(checkRenamedFS()).toBeTruthy()
})
await test.step('Verify we have not navigated', async () => {
await expect(projectMenuButton).toContainText('main.kcl')
await expect(projectMenuButton).not.toContainText(
newFileName + FILE_EXT
)
await expect(projectMenuButton).not.toContainText('fileToRename.kcl')
const url = page.url()
expect(url).toContain('main.kcl')
expect(url).not.toContain(newFileName)
expect(url).not.toContain('fileToRename.kcl')
await u.openKclCodePanel()
await expect(codeLocator).toContainText('fillet(')
})
await electronApp.close()
}
)
test(
`A folder you're not inside`,
{ tag: '@electron' },
async ({ browser: _ }, testInfo) => {
const { electronApp, page, dir } = await setupElectron({
testInfo,
folderSetupFn: async (dir) => {
await fsp.mkdir(join(dir, 'Test Project'), { recursive: true })
await fsp.mkdir(join(dir, 'Test Project', 'folderToRename'), {
recursive: true,
})
await fsp.copyFile(
executorInputPath('basic_fillet_cube_end.kcl'),
join(dir, 'Test Project', 'main.kcl')
)
await fsp.copyFile(
executorInputPath('cylinder.kcl'),
join(dir, 'Test Project', 'folderToRename', 'someFileWithin.kcl')
)
},
})
const u = await getUtils(page)
await page.setViewportSize({ width: 1200, height: 500 })
page.on('console', console.log)
// Constants and locators
const projectLink = page.getByText('Test Project')
const projectMenuButton = page.getByTestId('project-sidebar-toggle')
const folderToRename = page.getByRole('button', {
name: 'folderToRename',
})
const renamedFolder = page.getByRole('button', { name: 'newFolderName' })
const renameMenuItem = page.getByRole('button', { name: 'Rename' })
const originalFolderName = 'folderToRename'
const renameInput = page.getByPlaceholder(originalFolderName)
const newFolderName = 'newFolderName'
const checkUnRenamedFolderFS = () => {
const folderPath = join(dir, 'Test Project', originalFolderName)
return fs.existsSync(folderPath)
}
const checkRenamedFolderFS = () => {
const folderPath = join(dir, 'Test Project', newFolderName)
return fs.existsSync(folderPath)
}
await test.step('Open project and file pane', async () => {
await expect(projectLink).toBeVisible()
await projectLink.click()
await expect(projectMenuButton).toBeVisible()
await expect(projectMenuButton).toContainText('main.kcl')
const url = page.url()
expect(url).toContain('main.kcl')
expect(url).not.toContain('folderToRename')
await u.openFilePanel()
await expect(folderToRename).toBeVisible()
expect(checkUnRenamedFolderFS()).toBeTruthy()
expect(checkRenamedFolderFS()).toBeFalsy()
})
await test.step('Rename the folder', async () => {
await folderToRename.click({ button: 'right' })
await expect(renameMenuItem).toBeVisible()
await renameMenuItem.click()
await expect(renameInput).toBeVisible()
await renameInput.fill(newFolderName)
await page.keyboard.press('Enter')
})
await test.step('Verify the folder is renamed, and no navigation occurred', async () => {
const url = page.url()
expect(url).toContain('main.kcl')
expect(url).not.toContain('folderToRename')
await expect(projectMenuButton).toContainText('main.kcl')
await expect(renamedFolder).toBeVisible()
await expect(folderToRename).not.toBeAttached()
expect(checkUnRenamedFolderFS()).toBeFalsy()
expect(checkRenamedFolderFS()).toBeTruthy()
})
await electronApp.close()
}
)
test(
`A folder you are inside`,
{ tag: '@electron' },
async ({ browser: _ }, testInfo) => {
const { electronApp, page, dir } = await setupElectron({
testInfo,
folderSetupFn: async (dir) => {
await fsp.mkdir(join(dir, 'Test Project'), { recursive: true })
await fsp.mkdir(join(dir, 'Test Project', 'folderToRename'), {
recursive: true,
})
await fsp.copyFile(
executorInputPath('basic_fillet_cube_end.kcl'),
join(dir, 'Test Project', 'main.kcl')
)
await fsp.copyFile(
executorInputPath('cylinder.kcl'),
join(dir, 'Test Project', 'folderToRename', 'someFileWithin.kcl')
)
},
})
const u = await getUtils(page)
await page.setViewportSize({ width: 1200, height: 500 })
page.on('console', console.log)
// Constants and locators
const projectLink = page.getByText('Test Project')
const projectMenuButton = page.getByTestId('project-sidebar-toggle')
const folderToRename = page.getByRole('button', {
name: 'folderToRename',
})
const renamedFolder = page.getByRole('button', { name: 'newFolderName' })
const fileWithinFolder = page.getByRole('listitem').filter({
has: page.getByRole('button', { name: 'someFileWithin.kcl' }),
})
const renameMenuItem = page.getByRole('button', { name: 'Rename' })
const originalFolderName = 'folderToRename'
const renameInput = page.getByPlaceholder(originalFolderName)
const newFolderName = 'newFolderName'
const checkUnRenamedFolderFS = () => {
const folderPath = join(dir, 'Test Project', originalFolderName)
return fs.existsSync(folderPath)
}
const checkRenamedFolderFS = () => {
const folderPath = join(dir, 'Test Project', newFolderName)
return fs.existsSync(folderPath)
}
await test.step('Open project and navigate into folder', async () => {
await expect(projectLink).toBeVisible()
await projectLink.click()
await expect(projectMenuButton).toBeVisible()
await expect(projectMenuButton).toContainText('main.kcl')
const url = page.url()
expect(url).toContain('main.kcl')
expect(url).not.toContain('folderToRename')
await u.openFilePanel()
await expect(folderToRename).toBeVisible()
await folderToRename.click()
await expect(fileWithinFolder).toBeVisible()
await fileWithinFolder.click()
await expect(projectMenuButton).toContainText('someFileWithin.kcl')
const newUrl = page.url()
expect(newUrl).toContain('folderToRename')
expect(newUrl).toContain('someFileWithin.kcl')
expect(newUrl).not.toContain('main.kcl')
expect(checkUnRenamedFolderFS()).toBeTruthy()
expect(checkRenamedFolderFS()).toBeFalsy()
})
await test.step('Rename the folder', async () => {
await page.waitForTimeout(60000)
await folderToRename.click({ button: 'right' })
await expect(renameMenuItem).toBeVisible()
await renameMenuItem.click()
await expect(renameInput).toBeVisible()
await renameInput.fill(newFolderName)
await page.keyboard.press('Enter')
})
await test.step('Verify the folder is renamed, and navigated to new path', async () => {
const urlSnippet = encodeURIComponent(
join(newFolderName, 'someFileWithin.kcl')
)
await page.waitForURL(new RegExp(urlSnippet))
await expect(projectMenuButton).toContainText('someFileWithin.kcl')
await expect(renamedFolder).toBeVisible()
await expect(folderToRename).not.toBeAttached()
// URL is synchronous, so we check the other stuff first
const url = page.url()
expect(url).not.toContain('main.kcl')
expect(url).toContain(newFolderName)
expect(url).toContain('someFileWithin.kcl')
expect(checkUnRenamedFolderFS()).toBeFalsy()
expect(checkRenamedFolderFS()).toBeTruthy()
})
await electronApp.close()
}
)
})
test.describe('Deleting items from the file pane', () => {
test(
`delete file when main.kcl exists, navigate to main.kcl`,
{ tag: '@electron' },
async ({ browserName }, testInfo) => {
const { electronApp, page } = await setupElectron({
testInfo,
folderSetupFn: async (dir) => {
const testDir = join(dir, 'testProject')
await fsp.mkdir(testDir, { recursive: true })
await fsp.copyFile(
executorInputPath('cylinder.kcl'),
join(testDir, 'main.kcl')
)
await fsp.copyFile(
executorInputPath('basic_fillet_cube_end.kcl'),
join(testDir, 'fileToDelete.kcl')
)
},
})
const u = await getUtils(page)
await page.setViewportSize({ width: 1200, height: 500 })
page.on('console', console.log)
// Constants and locators
const projectCard = page.getByText('testProject')
const projectMenuButton = page.getByTestId('project-sidebar-toggle')
const fileToDelete = page
.getByRole('listitem')
.filter({ has: page.getByRole('button', { name: 'fileToDelete.kcl' }) })
const deleteMenuItem = page.getByRole('button', { name: 'Delete' })
const deleteConfirmation = page.getByTestId('delete-confirmation')
await test.step('Open project and navigate to fileToDelete.kcl', async () => {
await projectCard.click()
await u.waitForPageLoad()
await u.openFilePanel()
await fileToDelete.click()
await u.waitForPageLoad()
await u.openKclCodePanel()
await expect(u.codeLocator).toContainText('getOppositeEdge(thing)')
await u.closeKclCodePanel()
})
await test.step('Delete fileToDelete.kcl', async () => {
await fileToDelete.click({ button: 'right' })
await expect(deleteMenuItem).toBeVisible()
await deleteMenuItem.click()
await expect(deleteConfirmation).toBeVisible()
await deleteConfirmation.click()
})
await test.step('Check deletion and navigation', async () => {
await u.waitForPageLoad()
await expect(fileToDelete).not.toBeVisible()
await u.closeFilePanel()
await u.openKclCodePanel()
await expect(u.codeLocator).toContainText('circle(')
await expect(projectMenuButton).toContainText('main.kcl')
})
await electronApp.close()
}
)
test.fixme(
'TODO - delete file we have open when main.kcl does not exist',
async () => {}
)
test(
`Delete folder we are not in, don't navigate`,
{ tag: '@electron' },
async ({ browserName }, testInfo) => {
const { electronApp, page } = await setupElectron({
testInfo,
folderSetupFn: async (dir) => {
await fsp.mkdir(join(dir, 'Test Project'), { recursive: true })
await fsp.mkdir(join(dir, 'Test Project', 'folderToDelete'), {
recursive: true,
})
await fsp.copyFile(
executorInputPath('basic_fillet_cube_end.kcl'),
join(dir, 'Test Project', 'main.kcl')
)
await fsp.copyFile(
executorInputPath('cylinder.kcl'),
join(dir, 'Test Project', 'folderToDelete', 'someFileWithin.kcl')
)
},
})
const u = await getUtils(page)
await page.setViewportSize({ width: 1200, height: 500 })
page.on('console', console.log)
// Constants and locators
const projectCard = page.getByText('Test Project')
const projectMenuButton = page.getByTestId('project-sidebar-toggle')
const folderToDelete = page.getByRole('button', {
name: 'folderToDelete',
})
const deleteMenuItem = page.getByRole('button', { name: 'Delete' })
const deleteConfirmation = page.getByTestId('delete-confirmation')
await test.step('Open project and open project pane', async () => {
await projectCard.click()
await u.waitForPageLoad()
await expect(projectMenuButton).toContainText('main.kcl')
await u.closeKclCodePanel()
await u.openFilePanel()
})
await test.step('Delete folderToDelete', async () => {
await folderToDelete.click({ button: 'right' })
await expect(deleteMenuItem).toBeVisible()
await deleteMenuItem.click()
await expect(deleteConfirmation).toBeVisible()
await deleteConfirmation.click()
})
await test.step('Check deletion and no navigation', async () => {
await expect(folderToDelete).not.toBeAttached()
await expect(projectMenuButton).toContainText('main.kcl')
})
await electronApp.close()
}
)
test(
`Delete folder we are in, navigate to main.kcl`,
{ tag: '@electron' },
async ({ browserName }, testInfo) => {
const { electronApp, page } = await setupElectron({
testInfo,
folderSetupFn: async (dir) => {
await fsp.mkdir(join(dir, 'Test Project'), { recursive: true })
await fsp.mkdir(join(dir, 'Test Project', 'folderToDelete'), {
recursive: true,
})
await fsp.copyFile(
executorInputPath('basic_fillet_cube_end.kcl'),
join(dir, 'Test Project', 'main.kcl')
)
await fsp.copyFile(
executorInputPath('cylinder.kcl'),
join(dir, 'Test Project', 'folderToDelete', 'someFileWithin.kcl')
)
},
})
const u = await getUtils(page)
await page.setViewportSize({ width: 1200, height: 500 })
page.on('console', console.log)
// Constants and locators
const projectCard = page.getByText('Test Project')
const projectMenuButton = page.getByTestId('project-sidebar-toggle')
const folderToDelete = page.getByRole('button', {
name: 'folderToDelete',
})
const fileWithinFolder = page.getByRole('listitem').filter({
has: page.getByRole('button', { name: 'someFileWithin.kcl' }),
})
const deleteMenuItem = page.getByRole('button', { name: 'Delete' })
const deleteConfirmation = page.getByTestId('delete-confirmation')
await test.step('Open project and navigate into folderToDelete', async () => {
await projectCard.click()
await u.waitForPageLoad()
await expect(projectMenuButton).toContainText('main.kcl')
await u.closeKclCodePanel()
await u.openFilePanel()
await folderToDelete.click()
await expect(fileWithinFolder).toBeVisible()
await fileWithinFolder.click()
await expect(projectMenuButton).toContainText('someFileWithin.kcl')
})
await test.step('Delete folderToDelete', async () => {
await folderToDelete.click({ button: 'right' })
await expect(deleteMenuItem).toBeVisible()
await deleteMenuItem.click()
await expect(deleteConfirmation).toBeVisible()
await deleteConfirmation.click()
})
await test.step('Check deletion and navigation to main.kcl', async () => {
await expect(folderToDelete).not.toBeAttached()
await expect(fileWithinFolder).not.toBeAttached()
await expect(projectMenuButton).toContainText('main.kcl')
})
await electronApp.close()
}
)
test.fixme('TODO - delete folder we are in, with no main.kcl', async () => {})
})

View File

@ -0,0 +1,270 @@
export const isErrorWhitelisted = (exception: Error) => {
// due to the way webkit/Google Chrome report errors, it was necessary
// to whitelist similar errors separately for each project
let whitelist: {
name: string
message: string
stack: string
foundInSpec: string
project: 'webkit' | 'Google Chrome'
}[] = [
{
name: '',
message: 'undefined',
stack: '',
foundInSpec: `e2e/playwright/sketch-tests.spec.ts Existing sketch with bad code delete user's code`,
project: 'Google Chrome',
},
{
name: '"{"kind"',
message:
'"engine","sourceRanges":[[0,0]],"msg":"Failed to get string from response from engine: `JsValue(undefined)`"}"',
stack: '',
foundInSpec: 'e2e/playwright/testing-settings.spec.ts',
project: 'Google Chrome',
},
{
name: '',
message: 'false',
stack: '',
foundInSpec: 'e2e/playwright/testing-segment-overlays.spec.ts',
project: 'Google Chrome',
},
{
name: '{"kind"',
// eslint-disable-next-line no-useless-escape
message: 'no connection to send on',
stack: '',
foundInSpec: 'e2e/playwright/various.spec.ts',
project: 'Google Chrome',
},
{
name: '',
message: 'sketchGroup not found',
stack: '',
foundInSpec:
'e2e/playwright/testing-selections.spec.ts Deselecting line tool should mean nothing happens on click',
project: 'Google Chrome',
},
{
name: 'engine error',
message:
'[{"error_code":"bad_request","message":"Cannot set the camera position with these values"}]',
stack: '',
foundInSpec:
'e2e/playwright/can-create-sketches-on-all-planes-and-their-back-sides.spec.ts XY',
project: 'Google Chrome',
},
{
name: '',
message: 'no connection to send on',
stack: '',
foundInSpec:
'e2e/playwright/can-create-sketches-on-all-planes-and-their-back-sides.spec.ts XY',
project: 'Google Chrome',
},
{
name: 'RangeError',
message: 'Position 160 is out of range for changeset of length 0',
stack: `RangeError: Position 160 is out of range for changeset of length 0
at _ChangeSet.mapPos (http://localhost:3000/node_modules/.vite/deps/chunk-3BHLKIA4.js?v=412eae63:756:13)
at findSharedChunks (http://localhost:3000/node_modules/.vite/deps/chunk-3BHLKIA4.js?v=412eae63:3045:49)
at _RangeSet.compare (http://localhost:3000/node_modules/.vite/deps/chunk-3BHLKIA4.js?v=412eae63:2840:24)
at findChangedDeco (http://localhost:3000/node_modules/.vite/deps/chunk-IZYF444B.js?v=412eae63:3320:12)
at DocView.update (http://localhost:3000/node_modules/.vite/deps/chunk-IZYF444B.js?v=412eae63:2774:20)
at _EditorView.update (http://localhost:3000/node_modules/.vite/deps/chunk-IZYF444B.js?v=412eae63:7056:30)
at DOMObserver.flush (http://localhost:3000/node_modules/.vite/deps/chunk-IZYF444B.js?v=412eae63:6621:17)
at MutationObserver.<anonymous> (http://localhost:3000/node_modules/.vite/deps/chunk-IZYF444B.js?v=412eae63:6322:14)`,
foundInSpec: 'e2e/playwright/editor-tests.spec.ts fold gutters work',
project: 'Google Chrome',
},
{
name: 'RangeError',
message: 'Selection points outside of document',
stack: `RangeError: Selection points outside of document
+ at checkSelection (http://localhost:3000/node_modules/.vite/deps/chunk-3BHLKIA4.js?v=412eae63:1453:13)
+ at new _Transaction (http://localhost:3000/node_modules/.vite/deps/chunk-3BHLKIA4.js?v=412eae63:2014:7)
+ at _Transaction.create (http://localhost:3000/node_modules/.vite/deps/chunk-3BHLKIA4.js?v=412eae63:2022:12)
+ at resolveTransaction (http://localhost:3000/node_modules/.vite/deps/chunk-3BHLKIA4.js?v=412eae63:2155:24)
+ at _EditorState.update (http://localhost:3000/node_modules/.vite/deps/chunk-3BHLKIA4.js?v=412eae63:2281:12)
+ at _EditorView.dispatch (http://localhost:3000/node_modules/.vite/deps/chunk-IZYF444B.js?v=412eae63:6988:148)
+ at EditorManager.selectRange (http://localhost:3000/src/editor/manager.ts:182:22)
+ at AST extrude (http://localhost:3000/src/machines/modelingMachine.ts:828:25)`,
foundInSpec: 'e2e/playwright/editor-tests.spec.ts',
project: 'Google Chrome',
},
{
name: 'Unhandled Promise Rejection',
message: "TypeError: null is not an object (evaluating 'sg.value')",
stack: `Unhandled Promise Rejection: TypeError: null is not an object (evaluating 'sg.value')
at unknown (http://localhost:3000/src/clientSideScene/sceneEntities.ts:466:23)
at unknown (http://localhost:3000/src/clientSideScene/sceneEntities.ts:454:32)
at set up draft line without teardown (http://localhost:3000/src/machines/modelingMachine.ts:983:47)
at unknown (http://localhost:3000/node_modules/.vite/deps/chunk-6FRHHHSJ.js?v=0de2e74f:1877:24)
at handleAction (http://localhost:3000/node_modules/.vite/deps/chunk-6FRHHHSJ.js?v=0de2e74f:1064:26)
at processBlock (http://localhost:3000/node_modules/.vite/deps/chunk-6FRHHHSJ.js?v=0de2e74f:1087:36)
at map ([native code]:0:0)
at resolveActions (http://localhost:3000/node_modules/.vite/deps/chunk-6FRHHHSJ.js?v=0de2e74f:1109:49)
at unknown (http://localhost:3000/node_modules/.vite/deps/chunk-6FRHHHSJ.js?v=0de2e74f:3639:37)
at provide (http://localhost:3000/node_modules/.vite/deps/chunk-6FRHHHSJ.js?v=0de2e74f:1117:18)
at unknown (http://localhost:3000/node_modules/.vite/deps/chunk-6FRHHHSJ.js?v=0de2e74f:2452:30)
at unknown (http://localhost:3000/node_modules/.vite/deps/chunk-6FRHHHSJ.js?v=0de2e74f:1831:43)
at unknown (http://localhost:3000/node_modules/.vite/deps/chunk-6FRHHHSJ.js?v=0de2e74f:1659:17)
at unknown (http://localhost:3000/node_modules/.vite/deps/chunk-6FRHHHSJ.js?v=0de2e74f:1643:19)
at unknown (http://localhost:3000/node_modules/.vite/deps/chunk-6FRHHHSJ.js?v=0de2e74f:1829:33)
at unknown (http://localhost:3000/src/clientSideScene/sceneEntities.ts:263:19)`,
foundInSpec: `e2e/playwright/testing-camera-movement.spec.ts Zoom should be consistent when exiting or entering sketches`,
project: 'webkit',
},
{
name: 'Unhandled Promise Rejection',
message: 'false',
stack: `Unhandled Promise Rejection: false
at unknown (http://localhost:3000/src/clientSideScene/ClientSideSceneComp.tsx:455:78)`,
foundInSpec: `e2e/playwright/testing-segment-overlays.spec.ts line-[tagOutsideSketch]`,
project: 'webkit',
},
{
name: 'Unhandled Promise Rejection',
message: `TypeError: null is not an object (evaluating 'programMemory.get(variableDeclarationName).value')`,
stack: ` + stack:Unhandled Promise Rejection: TypeError: null is not an object (evaluating 'programMemory.get(variableDeclarationName).value')
+ at unknown (http://localhost:3000/src/machines/modelingMachine.ts:911:49)`,
foundInSpec: `e2e/playwright/can-create-sketches-on-all-planes-and-their-back-sides.spec.ts`,
project: 'webkit',
},
{
name: 'Unhandled Promise Rejection',
message: `null is not an object (evaluating 'programMemory.get(variableDeclarationName).value')`,
stack: `Unhandled Promise Rejection: TypeError: null is not an object (evaluating 'programMemory.get(variableDeclarationName).value')
at unknown (http://localhost:3000/src/machines/modelingMachine.ts:911:49)`,
foundInSpec: `e2e/playwright/testing-camera-movement.spec.ts Zoom should be consistent when exiting or entering sketches`,
project: 'webkit',
},
{
name: 'TypeError',
message: `null is not an object (evaluating 'gl.getShaderPrecisionFormat(gl.VERTEX_SHADER, gl.HIGH_FLOAT).precision')`,
stack: `TypeError: null is not an object (evaluating 'gl.getShaderPrecisionFormat(gl.VERTEX_SHADER, gl.HIGH_FLOAT).precision')
at getMaxPrecision (http://localhost:3000/node_modules/.vite/deps/chunk-DEEFU7IG.js?v=d328572b:9557:71)
at WebGLCapabilities (http://localhost:3000/node_modules/.vite/deps/chunk-DEEFU7IG.js?v=d328572b:9570:39)
at initGLContext (http://localhost:3000/node_modules/.vite/deps/chunk-DEEFU7IG.js?v=d328572b:16993:43)
at WebGLRenderer (http://localhost:3000/node_modules/.vite/deps/chunk-DEEFU7IG.js?v=d328572b:17024:18)
at SceneInfra (http://localhost:3000/src/clientSideScene/sceneInfra.ts:185:38)
at module code (http://localhost:3000/src/lib/singletons.ts:14:41)`,
foundInSpec: `e2e/playwright/testing-segment-overlays.spec.ts angledLineToX`,
project: 'webkit',
},
{
name: 'Unhandled Promise Rejection',
message:
'{"kind":"engine","sourceRanges":[[0,0]],"msg":"Failed to get string from response from engine: `JsValue(undefined)`"}',
stack: `Unhandled Promise Rejection: {"kind":"engine","sourceRanges":[[0,0]],"msg":"Failed to get string from response from engine: \`JsValue(undefined)\`"}
at unknown (http://localhost:3000/src/lang/std/engineConnection.ts:1245:26)`,
foundInSpec:
'e2e/playwright/onboarding-tests.spec.ts Click through each onboarding step',
project: 'webkit',
},
{
name: 'Unhandled Promise Rejection',
message: 'undefined',
stack: '',
foundInSpec: `e2e/playwright/sketch-tests.spec.ts Existing sketch with bad code delete user's code`,
project: 'webkit',
},
{
name: 'Fetch API cannot load https',
message: '/api.dev.zoo.dev/logout due to access control checks.',
stack: `Fetch API cannot load https://api.dev.zoo.dev/logout due to access control checks.
at goToSignInPage (http://localhost:3000/src/components/SettingsAuthProvider.tsx:229:15)
at unknown (http://localhost:3000/node_modules/.vite/deps/chunk-6FRHHHSJ.js?v=d328572b:1877:24)
at handleAction (http://localhost:3000/node_modules/.vite/deps/chunk-6FRHHHSJ.js?v=d328572b:1064:26)
at processBlock (http://localhost:3000/node_modules/.vite/deps/chunk-6FRHHHSJ.js?v=d328572b:1087:36)
at map (:1:11)
at resolveActions (http://localhost:3000/node_modules/.vite/deps/chunk-6FRHHHSJ.js?v=d328572b:1109:49)
at unknown (http://localhost:3000/node_modules/.vite/deps/chunk-6FRHHHSJ.js?v=d328572b:3639:37)
at provide (http://localhost:3000/node_modules/.vite/deps/chunk-6FRHHHSJ.js?v=d328572b:1117:18)
at unknown (http://localhost:3000/node_modules/.vite/deps/chunk-6FRHHHSJ.js?v=d328572b:2452:30)
at unknown (http://localhost:3000/node_modules/.vite/deps/chunk-6FRHHHSJ.js?v=d328572b:1831:43)
at unknown (http://localhost:3000/node_modules/.vite/deps/chunk-6FRHHHSJ.js?v=d328572b:1659:17)
at unknown (http://localhost:3000/node_modules/.vite/deps/chunk-6FRHHHSJ.js?v=d328572b:1643:19)
at unknown (http://localhost:3000/node_modules/.vite/deps/chunk-6FRHHHSJ.js?v=d328572b:1829:33)
at unknown (http://localhost:3000/node_modules/.vite/deps/chunk-6FRHHHSJ.js?v=d328572b:2601:23)`,
foundInSpec:
'e2e/playwright/testing-selections.spec.ts Solids should be select and deletable',
project: 'webkit',
},
{
name: 'Unhandled Promise Rejection',
message: 'ReferenceError: Cannot access uninitialized variable.',
stack: `Unhandled Promise Rejection: ReferenceError: Cannot access uninitialized variable.
at setDiagnosticsForCurrentErrors (http://localhost:3000/src/lang/KclSingleton.ts:90:18)
at kclErrors (http://localhost:3000/src/lang/KclSingleton.ts:82:40)
at safeParse (http://localhost:3000/src/lang/KclSingleton.ts:150:9)
at unknown (http://localhost:3000/src/lang/KclSingleton.ts:113:32)`,
foundInSpec:
'e2e/playwright/testing-segment-overlays.spec.ts angledLineToX',
project: 'webkit',
},
{
name: 'Unhandled Promise Rejection',
message: 'sketchGroup not found',
stack: `Unhandled Promise Rejection: sketchGroup not found
at unknown (http://localhost:3000/src/machines/modelingMachine.ts:911:49)`,
foundInSpec:
'e2e/playwright/testing-selections.spec.ts Deselecting line tool should mean nothing happens on click',
project: 'webkit',
},
{
name: 'Unhandled Promise Rejection',
message:
'engine error: [{"error_code":"bad_request","message":"Cannot set the camera position with these values"}]',
stack:
'Unhandled Promise Rejection: engine error: [{"error_code":"bad_request","message":"Cannot set the camera position with these values"}]',
foundInSpec:
'e2e/playwright/testing-camera-movement.spec.ts Zoom should be consistent when exiting or entering sketches',
project: 'webkit',
},
{
name: 'SecurityError',
stack: `SecurityError: Failed to read the 'localStorage' property from 'Window': Access is denied for this document.
at <anonymous>:13:5
at <anonymous>:18:5
at <anonymous>:19:7`,
message: `Failed to read the 'localStorage' property from 'Window': Access is denied for this document.`,
project: 'Google Chrome',
foundInSpec: 'e2e/playwright/basic-sketch.spec.ts',
},
{
name: ' - internal_engine',
stack: `
`,
message: `Nothing to export`,
project: 'Google Chrome',
foundInSpec: 'e2e/playwright/regression-tests.spec.ts',
},
{
name: 'SyntaxError',
stack: `SyntaxError: Unexpected end of JSON input
at crossPlatformFetch (http://localhost:3000/src/lib/crossPlatformFetch.ts:34:31)
at async sendTelemetry (http://localhost:3000/src/lib/textToCad.ts:179:3)`,
message: `Unexpected end of JSON input`,
project: 'Google Chrome',
foundInSpec: 'e2e/playwright/text-to-cad-tests.spec.ts',
},
{
name: '{"kind"',
stack: ``,
message: `engine","sourceRanges":[[0,0]],"msg":"Failed to wait for promise from engine: JsValue(\\"Force interrupt, executionIsStale, new AST requested\\")"}`,
project: 'Google Chrome',
foundInSpec: 'e2e/playwright/testing-settings.spec.ts',
},
]
const cleanString = (str: string) => str.replace(/[`"]/g, '')
const foundItem = whitelist.find(
(item) =>
cleanString(exception.name) === cleanString(item.name) &&
cleanString(exception.message).includes(cleanString(item.message))
)
return foundItem !== undefined
}

View File

@ -38,7 +38,7 @@ test(
await expect(page.getByText(notFoundText).first()).not.toBeVisible() await expect(page.getByText(notFoundText).first()).not.toBeVisible()
// Find the make button // Find the make button
const makeButton = page.getByRole('button', { name: 'Make' }) const makeButton = page.getByRole('button', { name: 'Make part' })
// Make sure the button is visible but disabled // Make sure the button is visible but disabled
await expect(makeButton).toBeVisible() await expect(makeButton).toBeVisible()
await expect(makeButton).toBeDisabled() await expect(makeButton).toBeDisabled()

View File

@ -12,7 +12,6 @@ import {
import fsp from 'fs/promises' import fsp from 'fs/promises'
import fs from 'fs' import fs from 'fs'
import { join } from 'path' import { join } from 'path'
import { FILE_EXT } from 'lib/constants'
test.afterEach(async ({ page }, testInfo) => { test.afterEach(async ({ page }, testInfo) => {
await tearDown(page, testInfo) await tearDown(page, testInfo)
@ -204,7 +203,7 @@ test.describe('Can export from electron app', () => {
}, },
{ timeout: 15_000 } { timeout: 15_000 }
) )
.toBe(477481) .toBe(482669)
// clean up output.gltf // clean up output.gltf
await fsp.rm('output.gltf') await fsp.rm('output.gltf')
@ -1371,455 +1370,6 @@ test(
} }
) )
test.describe('Renaming in the file tree', () => {
test(
'A file you have open',
{ tag: '@electron' },
async ({ browser: _ }, testInfo) => {
const { electronApp, page, dir } = await setupElectron({
testInfo,
folderSetupFn: async (dir) => {
await fsp.mkdir(join(dir, 'Test Project'), { recursive: true })
await fsp.copyFile(
executorInputPath('basic_fillet_cube_end.kcl'),
join(dir, 'Test Project', 'main.kcl')
)
await fsp.copyFile(
executorInputPath('cylinder.kcl'),
join(dir, 'Test Project', 'fileToRename.kcl')
)
},
})
const u = await getUtils(page)
await page.setViewportSize({ width: 1200, height: 500 })
page.on('console', console.log)
// Constants and locators
const projectLink = page.getByText('Test Project')
const projectMenuButton = page.getByTestId('project-sidebar-toggle')
const checkUnRenamedFS = () => {
const filePath = join(dir, 'Test Project', 'fileToRename.kcl')
return fs.existsSync(filePath)
}
const newFileName = 'newFileName'
const checkRenamedFS = () => {
const filePath = join(dir, 'Test Project', `${newFileName}.kcl`)
return fs.existsSync(filePath)
}
const fileToRename = page
.getByRole('listitem')
.filter({ has: page.getByRole('button', { name: 'fileToRename.kcl' }) })
const renamedFile = page
.getByRole('listitem')
.filter({ has: page.getByRole('button', { name: 'newFileName.kcl' }) })
const renameMenuItem = page.getByRole('button', { name: 'Rename' })
const renameInput = page.getByPlaceholder('fileToRename.kcl')
const codeLocator = page.locator('.cm-content')
await test.step('Open project and file pane', async () => {
await expect(projectLink).toBeVisible()
await projectLink.click()
await expect(projectMenuButton).toBeVisible()
await expect(projectMenuButton).toContainText('main.kcl')
await u.openFilePanel()
await expect(fileToRename).toBeVisible()
expect(checkUnRenamedFS()).toBeTruthy()
expect(checkRenamedFS()).toBeFalsy()
await fileToRename.click()
await expect(projectMenuButton).toContainText('fileToRename.kcl')
await u.openKclCodePanel()
await expect(codeLocator).toContainText('circle(')
await u.closeKclCodePanel()
})
await test.step('Rename the file', async () => {
await fileToRename.click({ button: 'right' })
await renameMenuItem.click()
await expect(renameInput).toBeVisible()
await renameInput.fill(newFileName)
await page.keyboard.press('Enter')
})
await test.step('Verify the file is renamed', async () => {
await expect(fileToRename).not.toBeAttached()
await expect(renamedFile).toBeVisible()
expect(checkUnRenamedFS()).toBeFalsy()
expect(checkRenamedFS()).toBeTruthy()
})
await test.step('Verify we navigated', async () => {
await expect(projectMenuButton).toContainText(newFileName + FILE_EXT)
const url = page.url()
expect(url).toContain(newFileName)
await expect(projectMenuButton).not.toContainText('fileToRename.kcl')
await expect(projectMenuButton).not.toContainText('main.kcl')
expect(url).not.toContain('fileToRename.kcl')
expect(url).not.toContain('main.kcl')
await u.openKclCodePanel()
await expect(codeLocator).toContainText('circle(')
})
await electronApp.close()
}
)
test(
'A file you do not have open',
{ tag: '@electron' },
async ({ browser: _ }, testInfo) => {
const { electronApp, page, dir } = await setupElectron({
testInfo,
folderSetupFn: async (dir) => {
await fsp.mkdir(join(dir, 'Test Project'), { recursive: true })
await fsp.copyFile(
executorInputPath('basic_fillet_cube_end.kcl'),
join(dir, 'Test Project', 'main.kcl')
)
await fsp.copyFile(
executorInputPath('cylinder.kcl'),
join(dir, 'Test Project', 'fileToRename.kcl')
)
},
})
const u = await getUtils(page)
await page.setViewportSize({ width: 1200, height: 500 })
page.on('console', console.log)
// Constants and locators
const newFileName = 'newFileName'
const checkUnRenamedFS = () => {
const filePath = join(dir, 'Test Project', 'fileToRename.kcl')
return fs.existsSync(filePath)
}
const checkRenamedFS = () => {
const filePath = join(dir, 'Test Project', `${newFileName}.kcl`)
return fs.existsSync(filePath)
}
const projectLink = page.getByText('Test Project')
const projectMenuButton = page.getByTestId('project-sidebar-toggle')
const fileToRename = page
.getByRole('listitem')
.filter({ has: page.getByRole('button', { name: 'fileToRename.kcl' }) })
const renamedFile = page.getByRole('listitem').filter({
has: page.getByRole('button', { name: newFileName + FILE_EXT }),
})
const renameMenuItem = page.getByRole('button', { name: 'Rename' })
const renameInput = page.getByPlaceholder('fileToRename.kcl')
const codeLocator = page.locator('.cm-content')
await test.step('Open project and file pane', async () => {
await expect(projectLink).toBeVisible()
await projectLink.click()
await expect(projectMenuButton).toBeVisible()
await expect(projectMenuButton).toContainText('main.kcl')
await u.openFilePanel()
await expect(fileToRename).toBeVisible()
expect(checkUnRenamedFS()).toBeTruthy()
expect(checkRenamedFS()).toBeFalsy()
})
await test.step('Rename the file', async () => {
await fileToRename.click({ button: 'right' })
await renameMenuItem.click()
await expect(renameInput).toBeVisible()
await renameInput.fill(newFileName)
await page.keyboard.press('Enter')
})
await test.step('Verify the file is renamed', async () => {
await expect(fileToRename).not.toBeAttached()
await expect(renamedFile).toBeVisible()
expect(checkUnRenamedFS()).toBeFalsy()
expect(checkRenamedFS()).toBeTruthy()
})
await test.step('Verify we have not navigated', async () => {
await expect(projectMenuButton).toContainText('main.kcl')
await expect(projectMenuButton).not.toContainText(
newFileName + FILE_EXT
)
await expect(projectMenuButton).not.toContainText('fileToRename.kcl')
const url = page.url()
expect(url).toContain('main.kcl')
expect(url).not.toContain(newFileName)
expect(url).not.toContain('fileToRename.kcl')
await u.openKclCodePanel()
await expect(codeLocator).toContainText('fillet(')
})
await electronApp.close()
}
)
test(
`A folder you're not inside`,
{ tag: '@electron' },
async ({ browser: _ }, testInfo) => {
const { electronApp, page, dir } = await setupElectron({
testInfo,
folderSetupFn: async (dir) => {
await fsp.mkdir(join(dir, 'Test Project'), { recursive: true })
await fsp.mkdir(join(dir, 'Test Project', 'folderToRename'), {
recursive: true,
})
await fsp.copyFile(
executorInputPath('basic_fillet_cube_end.kcl'),
join(dir, 'Test Project', 'main.kcl')
)
await fsp.copyFile(
executorInputPath('cylinder.kcl'),
join(dir, 'Test Project', 'folderToRename', 'someFileWithin.kcl')
)
},
})
const u = await getUtils(page)
await page.setViewportSize({ width: 1200, height: 500 })
page.on('console', console.log)
// Constants and locators
const projectLink = page.getByText('Test Project')
const projectMenuButton = page.getByTestId('project-sidebar-toggle')
const folderToRename = page.getByRole('button', {
name: 'folderToRename',
})
const renamedFolder = page.getByRole('button', { name: 'newFolderName' })
const renameMenuItem = page.getByRole('button', { name: 'Rename' })
const originalFolderName = 'folderToRename'
const renameInput = page.getByPlaceholder(originalFolderName)
const newFolderName = 'newFolderName'
const checkUnRenamedFolderFS = () => {
const folderPath = join(dir, 'Test Project', originalFolderName)
return fs.existsSync(folderPath)
}
const checkRenamedFolderFS = () => {
const folderPath = join(dir, 'Test Project', newFolderName)
return fs.existsSync(folderPath)
}
await test.step('Open project and file pane', async () => {
await expect(projectLink).toBeVisible()
await projectLink.click()
await expect(projectMenuButton).toBeVisible()
await expect(projectMenuButton).toContainText('main.kcl')
const url = page.url()
expect(url).toContain('main.kcl')
expect(url).not.toContain('folderToRename')
await u.openFilePanel()
await expect(folderToRename).toBeVisible()
expect(checkUnRenamedFolderFS()).toBeTruthy()
expect(checkRenamedFolderFS()).toBeFalsy()
})
await test.step('Rename the folder', async () => {
await folderToRename.click({ button: 'right' })
await expect(renameMenuItem).toBeVisible()
await renameMenuItem.click()
await expect(renameInput).toBeVisible()
await renameInput.fill(newFolderName)
await page.keyboard.press('Enter')
})
await test.step('Verify the folder is renamed, and no navigation occurred', async () => {
const url = page.url()
expect(url).toContain('main.kcl')
expect(url).not.toContain('folderToRename')
await expect(projectMenuButton).toContainText('main.kcl')
await expect(renamedFolder).toBeVisible()
await expect(folderToRename).not.toBeAttached()
expect(checkUnRenamedFolderFS()).toBeFalsy()
expect(checkRenamedFolderFS()).toBeTruthy()
})
await electronApp.close()
}
)
test(
`A folder you are inside`,
{ tag: '@electron' },
async ({ browser: _ }, testInfo) => {
const { electronApp, page, dir } = await setupElectron({
testInfo,
folderSetupFn: async (dir) => {
await fsp.mkdir(join(dir, 'Test Project'), { recursive: true })
await fsp.mkdir(join(dir, 'Test Project', 'folderToRename'), {
recursive: true,
})
await fsp.copyFile(
executorInputPath('basic_fillet_cube_end.kcl'),
join(dir, 'Test Project', 'main.kcl')
)
await fsp.copyFile(
executorInputPath('cylinder.kcl'),
join(dir, 'Test Project', 'folderToRename', 'someFileWithin.kcl')
)
},
})
const u = await getUtils(page)
await page.setViewportSize({ width: 1200, height: 500 })
page.on('console', console.log)
// Constants and locators
const projectLink = page.getByText('Test Project')
const projectMenuButton = page.getByTestId('project-sidebar-toggle')
const folderToRename = page.getByRole('button', {
name: 'folderToRename',
})
const renamedFolder = page.getByRole('button', { name: 'newFolderName' })
const fileWithinFolder = page.getByRole('listitem').filter({
has: page.getByRole('button', { name: 'someFileWithin.kcl' }),
})
const renameMenuItem = page.getByRole('button', { name: 'Rename' })
const originalFolderName = 'folderToRename'
const renameInput = page.getByPlaceholder(originalFolderName)
const newFolderName = 'newFolderName'
const checkUnRenamedFolderFS = () => {
const folderPath = join(dir, 'Test Project', originalFolderName)
return fs.existsSync(folderPath)
}
const checkRenamedFolderFS = () => {
const folderPath = join(dir, 'Test Project', newFolderName)
return fs.existsSync(folderPath)
}
await test.step('Open project and navigate into folder', async () => {
await expect(projectLink).toBeVisible()
await projectLink.click()
await expect(projectMenuButton).toBeVisible()
await expect(projectMenuButton).toContainText('main.kcl')
const url = page.url()
expect(url).toContain('main.kcl')
expect(url).not.toContain('folderToRename')
await u.openFilePanel()
await expect(folderToRename).toBeVisible()
await folderToRename.click()
await expect(fileWithinFolder).toBeVisible()
await fileWithinFolder.click()
await expect(projectMenuButton).toContainText('someFileWithin.kcl')
const newUrl = page.url()
expect(newUrl).toContain('folderToRename')
expect(newUrl).toContain('someFileWithin.kcl')
expect(newUrl).not.toContain('main.kcl')
expect(checkUnRenamedFolderFS()).toBeTruthy()
expect(checkRenamedFolderFS()).toBeFalsy()
})
await test.step('Rename the folder', async () => {
await page.waitForTimeout(2000)
await folderToRename.click({ button: 'right' })
await expect(renameMenuItem).toBeVisible()
await renameMenuItem.click()
await expect(renameInput).toBeVisible()
await renameInput.fill(newFolderName)
await page.keyboard.press('Enter')
})
await test.step('Verify the folder is renamed, and navigated to new path', async () => {
const urlSnippet = encodeURIComponent(
join(newFolderName, 'someFileWithin.kcl')
)
await page.waitForURL(new RegExp(urlSnippet))
await expect(projectMenuButton).toContainText('someFileWithin.kcl')
await expect(renamedFolder).toBeVisible()
await expect(folderToRename).not.toBeAttached()
// URL is synchronous, so we check the other stuff first
const url = page.url()
expect(url).not.toContain('main.kcl')
expect(url).toContain(newFolderName)
expect(url).toContain('someFileWithin.kcl')
expect(checkUnRenamedFolderFS()).toBeFalsy()
expect(checkRenamedFolderFS()).toBeTruthy()
})
await electronApp.close()
}
)
})
test.describe('Deleting files from the file pane', () => {
test(
`when main.kcl exists, navigate to main.kcl`,
{ tag: '@electron' },
async ({ browserName }, testInfo) => {
const { electronApp, page } = await setupElectron({
testInfo,
folderSetupFn: async (dir) => {
const testDir = join(dir, 'testProject')
await fsp.mkdir(testDir, { recursive: true })
await fsp.copyFile(
executorInputPath('cylinder.kcl'),
join(testDir, 'main.kcl')
)
await fsp.copyFile(
executorInputPath('basic_fillet_cube_end.kcl'),
join(testDir, 'fileToDelete.kcl')
)
},
})
const u = await getUtils(page)
await page.setViewportSize({ width: 1200, height: 500 })
page.on('console', console.log)
// Constants and locators
const projectCard = page.getByText('testProject')
const projectMenuButton = page.getByTestId('project-sidebar-toggle')
const fileToDelete = page
.getByRole('listitem')
.filter({ has: page.getByRole('button', { name: 'fileToDelete.kcl' }) })
const deleteMenuItem = page.getByRole('button', { name: 'Delete' })
const deleteConfirmation = page.getByTestId('delete-confirmation')
await test.step('Open project and navigate to fileToDelete.kcl', async () => {
await projectCard.click()
await u.waitForPageLoad()
await u.openFilePanel()
await fileToDelete.click()
await u.waitForPageLoad()
await u.openKclCodePanel()
await expect(u.codeLocator).toContainText('getOppositeEdge(thing)')
await u.closeKclCodePanel()
})
await test.step('Delete fileToDelete.kcl', async () => {
await fileToDelete.click({ button: 'right' })
await expect(deleteMenuItem).toBeVisible()
await deleteMenuItem.click()
await expect(deleteConfirmation).toBeVisible()
await deleteConfirmation.click()
})
await test.step('Check deletion and navigation', async () => {
await u.waitForPageLoad()
await expect(fileToDelete).not.toBeVisible()
await u.closeFilePanel()
await u.openKclCodePanel()
await expect(u.codeLocator).toContainText('circle(')
await expect(projectMenuButton).toContainText('main.kcl')
})
await electronApp.close()
}
)
test.fixme('TODO - when main.kcl does not exist', async () => {})
})
test( test(
'Original project name persist after onboarding', 'Original project name persist after onboarding',
{ tag: '@electron' }, { tag: '@electron' },

View File

@ -11,8 +11,8 @@ import {
import { TEST_CODE_TRIGGER_ENGINE_EXPORT_ERROR } from './storageStates' import { TEST_CODE_TRIGGER_ENGINE_EXPORT_ERROR } from './storageStates'
import { bracket } from 'lib/exampleKcl' import { bracket } from 'lib/exampleKcl'
test.beforeEach(async ({ context, page }) => { test.beforeEach(async ({ context, page }, testInfo) => {
await setup(context, page) await setup(context, page, testInfo)
}) })
test.afterEach(async ({ page }, testInfo) => { test.afterEach(async ({ page }, testInfo) => {
@ -54,6 +54,67 @@ const sketch001 = startSketchAt([-0, -0])
const crypticErrorText = `ApiError` const crypticErrorText = `ApiError`
await expect(page.getByText(crypticErrorText).first()).toBeVisible() await expect(page.getByText(crypticErrorText).first()).toBeVisible()
}) })
test('user should not have to press down twice in cmdbar', async ({
page,
}) => {
// because the model has `line([0,0]..` it is valid code, but the model is invalid
// regression test for https://github.com/KittyCAD/modeling-app/issues/3251
// Since the bad model also found as issue with the artifact graph, which in tern blocked the editor diognostics
const u = await getUtils(page)
await page.addInitScript(async () => {
localStorage.setItem(
'persistCode',
`const sketch2 = startSketchOn("XY")
const sketch001 = startSketchAt([-0, -0])
|> line([0, 0], %)
|> line([-4.84, -5.29], %)
|> lineTo([profileStartX(%), profileStartY(%)], %)
|> close(%)`
)
})
await page.setViewportSize({ width: 1000, height: 500 })
await page.goto('/')
await u.waitForPageLoad()
await test.step('Check arrow down works', async () => {
await page.getByTestId('command-bar-open-button').click()
await page
.getByRole('option', { name: 'floppy disk arrow Export' })
.click()
// press arrow down key twice
await page.keyboard.press('ArrowDown')
await page.waitForTimeout(100)
await page.keyboard.press('ArrowDown')
// STL is the third option, which makes sense for two arrow downs
await expect(page.locator('[data-headlessui-state="active"]')).toHaveText(
'STL'
)
await page.keyboard.press('Escape')
await page.waitForTimeout(200)
await page.keyboard.press('Escape')
await page.waitForTimeout(200)
})
await test.step('Check arrow up works', async () => {
// theme in test is dark, which is the second option, which means we can test arrow up
await page.getByTestId('command-bar-open-button').click()
await page.getByText('The overall appearance of the').click()
await page.keyboard.press('ArrowUp')
await page.waitForTimeout(100)
await expect(page.locator('[data-headlessui-state="active"]')).toHaveText(
'light'
)
})
})
test('executes on load', async ({ page }) => { test('executes on load', async ({ page }) => {
const u = await getUtils(page) const u = await getUtils(page)
await page.addInitScript(async () => { await page.addInitScript(async () => {
@ -285,10 +346,7 @@ const sketch001 = startSketchAt([-0, -0])
// Find the toast. // Find the toast.
// Look out for the toast message // Look out for the toast message
const exportingToastMessage = page.getByText(`Exporting...`) const exportingToastMessage = page.getByText(`Exporting...`)
await expect(exportingToastMessage).toBeVisible()
const errorToastMessage = page.getByText(`Error while exporting`) const errorToastMessage = page.getByText(`Error while exporting`)
await expect(errorToastMessage).toBeVisible()
const engineErrorToastMessage = page.getByText(`Nothing to export`) const engineErrorToastMessage = page.getByText(`Nothing to export`)
await expect(engineErrorToastMessage).toBeVisible() await expect(engineErrorToastMessage).toBeVisible()

View File

@ -9,8 +9,8 @@ import {
} from './test-utils' } from './test-utils'
import { uuidv4, roundOff } from 'lib/utils' import { uuidv4, roundOff } from 'lib/utils'
test.beforeEach(async ({ context, page }) => { test.beforeEach(async ({ context, page }, testInfo) => {
await setup(context, page) await setup(context, page, testInfo)
}) })
test.afterEach(async ({ page }, testInfo) => { test.afterEach(async ({ page }, testInfo) => {
@ -618,19 +618,19 @@ test.describe('Sketch tests', () => {
await u.closeDebugPanel() await u.closeDebugPanel()
await click00r(30, 0) await click00r(30, 0)
codeStr += ` |> startProfileAt([1.53, 0], %)` codeStr += ` |> startProfileAt([2.03, 0], %)`
await expect(u.codeLocator).toHaveText(codeStr) await expect(u.codeLocator).toHaveText(codeStr)
await click00r(30, 0) await click00r(30, 0)
codeStr += ` |> line([1.53, 0], %)` codeStr += ` |> line([2.04, 0], %)`
await expect(u.codeLocator).toHaveText(codeStr) await expect(u.codeLocator).toHaveText(codeStr)
await click00r(0, 30) await click00r(0, 30)
codeStr += ` |> line([0, -1.53], %)` codeStr += ` |> line([0, -2.03], %)`
await expect(u.codeLocator).toHaveText(codeStr) await expect(u.codeLocator).toHaveText(codeStr)
await click00r(-30, 0) await click00r(-30, 0)
codeStr += ` |> line([-1.53, 0], %)` codeStr += ` |> line([-2.04, 0], %)`
await expect(u.codeLocator).toHaveText(codeStr) await expect(u.codeLocator).toHaveText(codeStr)
await click00r(undefined, undefined) await click00r(undefined, undefined)
@ -954,4 +954,68 @@ const sketch002 = startSketchOn(extrude001, 'END')
await u.getGreatestPixDiff(XYPlanePoint, noPlanesColor) await u.getGreatestPixDiff(XYPlanePoint, noPlanesColor)
).toBeLessThan(3) ).toBeLessThan(3)
}) })
test('Can attempt to sketch on revolved face', async ({
page,
browserName,
}) => {
test.skip(
browserName === 'webkit',
'Skip on Safari until `window.tearDown` is working there'
)
const u = await getUtils(page)
await page.setViewportSize({ width: 1200, height: 500 })
await page.addInitScript(async () => {
localStorage.setItem(
'persistCode',
`const lugHeadLength = 0.25
const lugDiameter = 0.5
const lugLength = 2
fn lug = (origin, length, diameter, plane) => {
const lugSketch = startSketchOn(plane)
|> startProfileAt([origin[0] + lugDiameter / 2, origin[1]], %)
|> angledLineOfYLength({ angle: 60, length: lugHeadLength }, %)
|> xLineTo(0 + .001, %)
|> yLineTo(0, %)
|> close(%)
|> revolve({ axis: "Y" }, %)
return lugSketch
}
lug([0, 0], 10, .5, "XY")`
)
})
await u.waitForAuthSkipAppStart()
await u.openDebugPanel()
await u.expectCmdLog('[data-message-type="execution-done"]')
await u.closeDebugPanel()
/***
* Test Plan
* Start the sketch mode
* Click the middle of the screen which should click the top face that is revolved
* Wait till you see the line tool be enabled
* Wait till you see the exit sketch enabled
*
* This is supposed to test that you are allowed to go into sketch mode to sketch on a revolved face
*/
await page.getByRole('button', { name: 'Start Sketch' }).click()
await expect(async () => {
await page.mouse.click(600, 250)
await page.waitForTimeout(1000)
await expect(
page.getByRole('button', { name: 'Exit Sketch' })
).toBeVisible()
await expect(
page.getByRole('button', { name: 'line Line', exact: true })
).toHaveAttribute('aria-pressed', 'true')
}).toPass({ timeout: 40_000, intervals: [1_000] })
})
}) })

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 33 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: 37 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 32 KiB

View File

@ -2,8 +2,8 @@ import { test, expect } from '@playwright/test'
import { commonPoints, getUtils, setup, tearDown } from './test-utils' import { commonPoints, getUtils, setup, tearDown } from './test-utils'
test.beforeEach(async ({ context, page }) => { test.beforeEach(async ({ context, page }, testInfo) => {
await setup(context, page) await setup(context, page, testInfo)
}) })
test.afterEach(async ({ page }, testInfo) => { test.afterEach(async ({ page }, testInfo) => {

View File

@ -26,7 +26,9 @@ import {
import * as TOML from '@iarna/toml' import * as TOML from '@iarna/toml'
import { SaveSettingsPayload } from 'lib/settings/settingsTypes' import { SaveSettingsPayload } from 'lib/settings/settingsTypes'
import { SETTINGS_FILE_NAME } from 'lib/constants' import { SETTINGS_FILE_NAME } from 'lib/constants'
import { isErrorWhitelisted } from './lib/console-error-whitelist'
import { isArray } from 'lib/utils' import { isArray } from 'lib/utils'
import { reportRejection } from 'lib/trap'
type TestColor = [number, number, number] type TestColor = [number, number, number]
export const TEST_COLORS = { export const TEST_COLORS = {
@ -439,8 +441,37 @@ export async function getUtils(page: Page, test_?: typeof test) {
} }
return maxDiff return maxDiff
}, },
doAndWaitForImageDiff: (fn: () => Promise<any>, diffCount = 200) => getPixelRGBs: async (
new Promise(async (resolve) => { coords: { x: number; y: number },
radius: number
): Promise<[number, number, number][]> => {
const buffer = await page.screenshot({
fullPage: true,
})
const screenshot = await PNG.sync.read(buffer)
const pixMultiplier: number = await page.evaluate(
'window.devicePixelRatio'
)
const allCords: [number, number][] = [[coords.x, coords.y]]
for (let i = 1; i < radius; i++) {
allCords.push([coords.x + i, coords.y])
allCords.push([coords.x - i, coords.y])
allCords.push([coords.x, coords.y + i])
allCords.push([coords.x, coords.y - i])
}
return allCords.map(([x, y]) => {
const index =
(screenshot.width * y * pixMultiplier + x * pixMultiplier) * 4 // rbga is 4 channels
return [
screenshot.data[index],
screenshot.data[index + 1],
screenshot.data[index + 2],
]
})
},
doAndWaitForImageDiff: (fn: () => Promise<unknown>, diffCount = 200) =>
new Promise<boolean>((resolve) => {
;(async () => {
await page.screenshot({ await page.screenshot({
path: './e2e/playwright/temp1.png', path: './e2e/playwright/temp1.png',
fullPage: true, fullPage: true,
@ -469,7 +500,8 @@ export async function getUtils(page: Page, test_?: typeof test) {
// run isImageDiff every 50ms until it returns true or 5 seconds have passed (100 times) // run isImageDiff every 50ms until it returns true or 5 seconds have passed (100 times)
let count = 0 let count = 0
const interval = setInterval(async () => { const interval = setInterval(() => {
;(async () => {
count++ count++
if (await isImageDiff()) { if (await isImageDiff()) {
clearInterval(interval) clearInterval(interval)
@ -478,7 +510,9 @@ export async function getUtils(page: Page, test_?: typeof test) {
clearInterval(interval) clearInterval(interval)
resolve(false) resolve(false)
} }
})().catch(reportRejection)
}, 50) }, 50)
})().catch(reportRejection)
}), }),
emulateNetworkConditions: async ( emulateNetworkConditions: async (
networkOptions: Protocol.Network.emulateNetworkConditionsParameters networkOptions: Protocol.Network.emulateNetworkConditionsParameters
@ -817,7 +851,11 @@ export async function tearDown(page: Page, testInfo: TestInfo) {
// settingsOverrides may need to be augmented to take more generic items, // settingsOverrides may need to be augmented to take more generic items,
// but we'll be strict for now // but we'll be strict for now
export async function setup(context: BrowserContext, page: Page) { export async function setup(
context: BrowserContext,
page: Page,
testInfo?: TestInfo
) {
await context.addInitScript( await context.addInitScript(
async ({ token, settingsKey, settings, IS_PLAYWRIGHT_KEY }) => { async ({ token, settingsKey, settings, IS_PLAYWRIGHT_KEY }) => {
localStorage.clear() localStorage.clear()
@ -853,6 +891,8 @@ export async function setup(context: BrowserContext, page: Page) {
secure: true, secure: true,
}, },
]) ])
failOnConsoleErrors(page, testInfo)
// kill animations, speeds up tests and reduced flakiness // kill animations, speeds up tests and reduced flakiness
await page.emulateMedia({ reducedMotion: 'reduce' }) await page.emulateMedia({ reducedMotion: 'reduce' })
@ -926,6 +966,48 @@ export async function setupElectron({
return { electronApp, page, dir: projectDirName } return { electronApp, page, dir: projectDirName }
} }
function failOnConsoleErrors(page: Page, testInfo?: TestInfo) {
// enabled for chrome for now
if (page.context().browser()?.browserType().name() === 'chromium') {
page.on('pageerror', (exception) => {
if (isErrorWhitelisted(exception)) {
return
}
// only set this env var to false if you want to collect console errors
// This can be configured in the GH workflow. This should be set to true by default (we want tests to fail when
// unwhitelisted console errors are detected).
if (process.env.FAIL_ON_CONSOLE_ERRORS === 'true') {
// Fail when running on CI and FAIL_ON_CONSOLE_ERRORS is set
// use expect to prevent page from closing and not cleaning up
expect(`An error was detected in the console: \r\n message:${exception.message} \r\n name:${exception.name} \r\n stack:${exception.stack}
*Either fix the console error or add it to the whitelist defined in ./lib/console-error-whitelist.ts (if the error can be safely ignored)
`).toEqual('Console error detected')
} else {
// the (test-results/exceptions.txt) file will be uploaded as part of an upload artifact in GH
fsp
.appendFile(
'./test-results/exceptions.txt',
[
'~~~',
`triggered_by_test:${
testInfo?.file + ' ' + (testInfo?.title || ' ')
}`,
`name:${exception.name}`,
`message:${exception.message}`,
`stack:${exception.stack}`,
`project:${testInfo?.project.name}`,
'~~~',
].join('\n')
)
.catch((err) => {
console.error(err)
})
}
})
}
}
export async function isOutOfViewInScrollContainer( export async function isOutOfViewInScrollContainer(
element: Locator, element: Locator,
container: Locator container: Locator

View File

@ -3,8 +3,8 @@ import { EngineCommand } from 'lang/std/artifactGraph'
import { uuidv4 } from 'lib/utils' import { uuidv4 } from 'lib/utils'
import { getUtils, setup, tearDown } from './test-utils' import { getUtils, setup, tearDown } from './test-utils'
test.beforeEach(async ({ context, page }) => { test.beforeEach(async ({ context, page }, testInfo) => {
await setup(context, page) await setup(context, page, testInfo)
}) })
test.afterEach(async ({ page }, testInfo) => { test.afterEach(async ({ page }, testInfo) => {
@ -12,8 +12,8 @@ test.afterEach(async ({ page }, testInfo) => {
}) })
test.describe('Testing Camera Movement', () => { test.describe('Testing Camera Movement', () => {
test('Can moving camera', async ({ page, context }) => { test('Can move camera reliably', async ({ page, context }) => {
test.skip(process.platform === 'darwin', 'Can moving camera') test.skip(process.platform === 'darwin', 'Can move camera reliably')
const u = await getUtils(page) const u = await getUtils(page)
await page.setViewportSize({ width: 1200, height: 500 }) await page.setViewportSize({ width: 1200, height: 500 })
@ -102,6 +102,13 @@ test.describe('Testing Camera Movement', () => {
await bakeInRetries(async () => { await bakeInRetries(async () => {
await page.mouse.move(700, 200) await page.mouse.move(700, 200)
await page.mouse.down({ button: 'right' }) await page.mouse.down({ button: 'right' })
const appLogoBBox = await page.getByTestId('app-logo').boundingBox()
expect(appLogoBBox).not.toBeNull()
if (!appLogoBBox) throw new Error('app logo not found')
await page.mouse.move(
appLogoBBox.x + appLogoBBox.width / 2,
appLogoBBox.y + appLogoBBox.height / 2
)
await page.mouse.move(600, 303) await page.mouse.move(600, 303)
await page.mouse.up({ button: 'right' }) await page.mouse.up({ button: 'right' })
}, [4, -10.5, -120]) }, [4, -10.5, -120])
@ -295,11 +302,11 @@ test.describe('Testing Camera Movement', () => {
await expect( await expect(
page.getByRole('button', { name: 'Edit Sketch' }) page.getByRole('button', { name: 'Edit Sketch' })
).toBeVisible() ).toBeVisible()
await hoverOverNothing()
await page.getByRole('button', { name: 'Edit Sketch' }).click() await page.getByRole('button', { name: 'Edit Sketch' }).click()
await page.waitForTimeout(400) await page.waitForTimeout(400)
await hoverOverNothing()
x = 975 x = 975
y = 468 y = 468

View File

@ -3,8 +3,8 @@ import { test, expect } from '@playwright/test'
import { getUtils, setup, tearDown, TEST_COLORS } from './test-utils' import { getUtils, setup, tearDown, TEST_COLORS } from './test-utils'
import { XOR } from 'lib/utils' import { XOR } from 'lib/utils'
test.beforeEach(async ({ context, page }) => { test.beforeEach(async ({ context, page }, testInfo) => {
await setup(context, page) await setup(context, page, testInfo)
}) })
test.afterEach(async ({ page }, testInfo) => { test.afterEach(async ({ page }, testInfo) => {

View File

@ -4,8 +4,8 @@ import { getUtils, setup, tearDown } from './test-utils'
import { uuidv4 } from 'lib/utils' import { uuidv4 } from 'lib/utils'
import { TEST_CODE_GIZMO } from './storageStates' import { TEST_CODE_GIZMO } from './storageStates'
test.beforeEach(async ({ context, page }) => { test.beforeEach(async ({ context, page }, testInfo) => {
await setup(context, page) await setup(context, page, testInfo)
}) })
test.afterEach(async ({ page }, testInfo) => { test.afterEach(async ({ page }, testInfo) => {

View File

@ -4,8 +4,8 @@ import { deg, getUtils, setup, tearDown, wiggleMove } from './test-utils'
import { LineInputsType } from 'lang/std/sketchcombos' import { LineInputsType } from 'lang/std/sketchcombos'
import { uuidv4 } from 'lib/utils' import { uuidv4 } from 'lib/utils'
test.beforeEach(async ({ context, page }) => { test.beforeEach(async ({ context, page }, testInfo) => {
await setup(context, page) await setup(context, page, testInfo)
}) })
test.afterEach(async ({ page }, testInfo) => { test.afterEach(async ({ page }, testInfo) => {

View File

@ -5,8 +5,8 @@ import { Coords2d } from 'lang/std/sketch'
import { KCL_DEFAULT_LENGTH } from 'lib/constants' import { KCL_DEFAULT_LENGTH } from 'lib/constants'
import { uuidv4 } from 'lib/utils' import { uuidv4 } from 'lib/utils'
test.beforeEach(async ({ context, page }) => { test.beforeEach(async ({ context, page }, testInfo) => {
await setup(context, page) await setup(context, page, testInfo)
}) })
test.afterEach(async ({ page }, testInfo) => { test.afterEach(async ({ page }, testInfo) => {
@ -31,12 +31,28 @@ test.describe('Testing selections', () => {
const xAxisClick = () => const xAxisClick = () =>
page.mouse.click(700, 253).then(() => page.waitForTimeout(100)) page.mouse.click(700, 253).then(() => page.waitForTimeout(100))
const xAxisClickAfterExitingSketch = () =>
page.mouse.click(639, 278).then(() => page.waitForTimeout(100))
const emptySpaceHover = () =>
test.step('Hover over empty space', async () => {
await page.mouse.move(700, 143, { steps: 5 })
await expect(page.locator('.hover-highlight')).not.toBeVisible()
})
const emptySpaceClick = () => const emptySpaceClick = () =>
page.mouse.click(700, 343).then(() => page.waitForTimeout(100)) test.step(`Click in empty space`, async () => {
await page.mouse.click(700, 143)
await expect(page.locator('.cm-line').last()).toHaveClass(
/cm-activeLine/
)
})
const topHorzSegmentClick = () => const topHorzSegmentClick = () =>
page.mouse.click(709, 290).then(() => page.waitForTimeout(100)) page.mouse
.click(startXPx, 500 - PUR * 20)
.then(() => page.waitForTimeout(100))
const bottomHorzSegmentClick = () => const bottomHorzSegmentClick = () =>
page.mouse.click(767, 396).then(() => page.waitForTimeout(100)) page.mouse
.click(startXPx + PUR * 10, 500 - PUR * 10)
.then(() => page.waitForTimeout(100))
await u.clearCommandLogs() await u.clearCommandLogs()
await expect( await expect(
@ -171,7 +187,9 @@ test.describe('Testing selections', () => {
await emptySpaceClick() await emptySpaceClick()
} }
await test.step(`Test hovering and selecting on fresh sketch`, async () => {
await selectionSequence() await selectionSequence()
})
// hovering in fresh sketch worked, lets try exiting and re-entering // hovering in fresh sketch worked, lets try exiting and re-entering
await u.openAndClearDebugPanel() await u.openAndClearDebugPanel()
@ -184,16 +202,17 @@ test.describe('Testing selections', () => {
// select a line, this verifies that sketches in the scene can be selected outside of sketch mode // select a line, this verifies that sketches in the scene can be selected outside of sketch mode
await topHorzSegmentClick() await topHorzSegmentClick()
await xAxisClickAfterExitingSketch()
await page.waitForTimeout(100) await page.waitForTimeout(100)
await emptySpaceHover()
// enter sketch again // enter sketch again
await u.doAndWaitForCmd( await u.doAndWaitForCmd(
() => page.getByRole('button', { name: 'Edit Sketch' }).click(), () => page.getByRole('button', { name: 'Edit Sketch' }).click(),
'default_camera_get_settings' 'default_camera_get_settings'
) )
await page.waitForTimeout(150)
await page.waitForTimeout(300) // wait for animation await page.waitForTimeout(450) // wait for animation
await u.openAndClearDebugPanel() await u.openAndClearDebugPanel()
await u.sendCustomCmd({ await u.sendCustomCmd({
@ -220,9 +239,10 @@ test.describe('Testing selections', () => {
await u.closeDebugPanel() await u.closeDebugPanel()
// hover again and check it works await test.step(`Test hovering and selecting on edited sketch`, async () => {
await selectionSequence() await selectionSequence()
}) })
})
test('Solids should be select and deletable', async ({ page }) => { test('Solids should be select and deletable', async ({ page }) => {
test.setTimeout(90_000) test.setTimeout(90_000)
@ -516,11 +536,22 @@ const sketch002 = startSketchOn(launderExtrudeThroughVar, seg02)
await page.waitForTimeout(100) await page.waitForTimeout(100)
await u.closeDebugPanel() await u.closeDebugPanel()
const extrusionTop: Coords2d = [800, 240] const extrusionTopCap: Coords2d = [800, 240]
const flatExtrusionFace: Coords2d = [960, 160] const flatExtrusionFace: Coords2d = [960, 160]
const arc: Coords2d = [840, 160] const tangentialArcTo: Coords2d = [840, 160]
const close: Coords2d = [720, 200] const close: Coords2d = [720, 200]
const nothing: Coords2d = [600, 200] const nothing: Coords2d = [600, 200]
const closeEdge: Coords2d = [744, 233]
const closeAdjacentEdge: Coords2d = [688, 123]
const closeOppositeEdge: Coords2d = [687, 169]
const tangentialArcEdge: Coords2d = [811, 142]
const tangentialArcOppositeEdge: Coords2d = [820, 180]
const tangentialArcAdjacentEdge: Coords2d = [893, 165]
const straightSegmentEdge: Coords2d = [819, 369]
const straightSegmentOppositeEdge: Coords2d = [635, 394]
const straightSegmentAdjacentEdge: Coords2d = [679, 329]
await page.mouse.move(nothing[0], nothing[1]) await page.mouse.move(nothing[0], nothing[1])
await page.mouse.click(nothing[0], nothing[1]) await page.mouse.click(nothing[0], nothing[1])
@ -528,26 +559,141 @@ const sketch002 = startSketchOn(launderExtrudeThroughVar, seg02)
await expect(page.getByTestId('hover-highlight')).not.toBeVisible() await expect(page.getByTestId('hover-highlight')).not.toBeVisible()
await page.waitForTimeout(200) await page.waitForTimeout(200)
await page.mouse.move(extrusionTop[0], extrusionTop[1]) const checkCodeAtHoverPosition = async (
await expect(page.getByTestId('hover-highlight').first()).toBeVisible() name = '',
await page.mouse.move(nothing[0], nothing[1]) coord: Coords2d,
await expect(page.getByTestId('hover-highlight').first()).not.toBeVisible() highlightCode: string,
activeLine = highlightCode
) => {
await test.step(`test selection for: ${name}`, async () => {
const highlightedLocator = page.getByTestId('hover-highlight')
const activeLineLocator = page.locator('.cm-activeLine')
await page.mouse.move(arc[0], arc[1]) await test.step(`hover should highlight correct code`, async () => {
await expect(page.getByTestId('hover-highlight').first()).toBeVisible() await page.mouse.move(coord[0], coord[1])
await expect(highlightedLocator.first()).toBeVisible()
await expect
.poll(async () => {
const textContents = await highlightedLocator.allTextContents()
return textContents.join('').replace(/\s+/g, '')
})
.toBe(highlightCode)
await page.mouse.move(nothing[0], nothing[1]) await page.mouse.move(nothing[0], nothing[1])
await expect(page.getByTestId('hover-highlight').first()).not.toBeVisible() })
await test.step(`click should put the cursor in the right place`, async () => {
await expect(highlightedLocator.first()).not.toBeVisible()
await page.mouse.click(coord[0], coord[1])
await expect
.poll(async () => {
const activeLines = await activeLineLocator.allInnerTexts()
return activeLines.join('')
})
.toContain(activeLine)
// check pixels near the click location are yellow
})
await test.step(`check the engine agrees with selections`, async () => {
// ultimately the only way we know if the engine agrees with the selection from the FE
// perspective is if it highlights the pixels near where we clicked yellow.
await expect
.poll(async () => {
const RGBs = await u.getPixelRGBs({ x: coord[0], y: coord[1] }, 3)
for (const rgb of RGBs) {
const [r, g, b] = rgb
const RGAverage = (r + g) / 2
const isRedGreenSameIsh = Math.abs(r - g) < 3
const isBlueLessThanRG = RGAverage - b > 45
const isYellowy = isRedGreenSameIsh && isBlueLessThanRG
if (isYellowy) return true
}
return false
})
.toBeTruthy()
await page.mouse.click(nothing[0], nothing[1])
})
})
}
await page.mouse.move(close[0], close[1]) await checkCodeAtHoverPosition(
await expect(page.getByTestId('hover-highlight').first()).toBeVisible() 'extrusionTopCap',
await page.mouse.move(nothing[0], nothing[1]) extrusionTopCap,
await expect(page.getByTestId('hover-highlight').first()).not.toBeVisible() 'startProfileAt([20,0],%)',
'startProfileAt([20, 0], %)'
)
await checkCodeAtHoverPosition(
'flatExtrusionFace',
flatExtrusionFace,
`angledLineThatIntersects({angle:3.14,intersectTag:a,offset:0},%)extrude(5+7,%)`,
'}, %)'
)
await page.mouse.move(flatExtrusionFace[0], flatExtrusionFace[1]) await checkCodeAtHoverPosition(
await expect(page.getByTestId('hover-highlight')).toHaveCount(6) // multiple lines 'tangentialArcTo',
await page.mouse.move(nothing[0], nothing[1]) tangentialArcTo,
await page.waitForTimeout(100) 'tangentialArcTo([13.14+0,13.14],%)extrude(5+7,%)',
await expect(page.getByTestId('hover-highlight').first()).not.toBeVisible() 'tangentialArcTo([13.14 + 0, 13.14], %)'
)
await checkCodeAtHoverPosition(
'tangentialArcEdge',
tangentialArcEdge,
`tangentialArcTo([13.14+0,13.14],%)`,
'tangentialArcTo([13.14 + 0, 13.14], %)'
)
await checkCodeAtHoverPosition(
'tangentialArcOppositeEdge',
tangentialArcOppositeEdge,
`tangentialArcTo([13.14+0,13.14],%)`,
'tangentialArcTo([13.14 + 0, 13.14], %)'
)
await checkCodeAtHoverPosition(
'tangentialArcAdjacentEdge',
tangentialArcAdjacentEdge,
`tangentialArcTo([13.14+0,13.14],%)`,
'tangentialArcTo([13.14 + 0, 13.14], %)'
)
await checkCodeAtHoverPosition(
'close',
close,
'close(%)extrude(5+7,%)',
'close(%)'
)
await checkCodeAtHoverPosition(
'closeEdge',
closeEdge,
`close(%)`,
'close(%)'
)
await checkCodeAtHoverPosition(
'closeAdjacentEdge',
closeAdjacentEdge,
`close(%)`,
'close(%)'
)
await checkCodeAtHoverPosition(
'closeOppositeEdge',
closeOppositeEdge,
`close(%)`,
'close(%)'
)
await checkCodeAtHoverPosition(
'straightSegmentEdge',
straightSegmentEdge,
`angledLineToY({angle:30,to:11.14},%)`,
'angledLineToY({ angle: 30, to: 11.14 }, %)'
)
await checkCodeAtHoverPosition(
'straightSegmentOppositeEdge',
straightSegmentOppositeEdge,
`angledLineToY({angle:30,to:11.14},%)`,
'angledLineToY({ angle: 30, to: 11.14 }, %)'
)
await checkCodeAtHoverPosition(
'straightSegmentAdjancentEdge',
straightSegmentAdjacentEdge,
`angledLineToY({angle:30,to:11.14},%)`,
'angledLineToY({ angle: 30, to: 11.14 }, %)'
)
}) })
test("Extrude button should be disabled if there's no extrudable geometry when nothing is selected", async ({ test("Extrude button should be disabled if there's no extrudable geometry when nothing is selected", async ({
page, page,

View File

@ -8,12 +8,16 @@ import {
tearDown, tearDown,
executorInputPath, executorInputPath,
} from './test-utils' } from './test-utils'
import { SaveSettingsPayload } from 'lib/settings/settingsTypes' import { SaveSettingsPayload, SettingsLevel } from 'lib/settings/settingsTypes'
import { TEST_SETTINGS_KEY, TEST_SETTINGS_CORRUPTED } from './storageStates' import {
TEST_SETTINGS_KEY,
TEST_SETTINGS_CORRUPTED,
TEST_SETTINGS,
} from './storageStates'
import * as TOML from '@iarna/toml' import * as TOML from '@iarna/toml'
test.beforeEach(async ({ context, page }) => { test.beforeEach(async ({ context, page }, testInfo) => {
await setup(context, page) await setup(context, page, testInfo)
}) })
test.afterEach(async ({ page }, testInfo) => { test.afterEach(async ({ page }, testInfo) => {
@ -65,12 +69,15 @@ test.describe('Testing settings', () => {
page, page,
}) => { }) => {
const u = await getUtils(page) const u = await getUtils(page)
await test.step(`Setup`, async () => {
await page.setViewportSize({ width: 1200, height: 500 }) await page.setViewportSize({ width: 1200, height: 500 })
await u.waitForAuthSkipAppStart() await u.waitForAuthSkipAppStart()
await page await page
.getByRole('button', { name: 'Start Sketch' }) .getByRole('button', { name: 'Start Sketch' })
.waitFor({ state: 'visible' }) .waitFor({ state: 'visible' })
})
// Selectors and constants
const paneButtonLocator = page.getByTestId('debug-pane-button') const paneButtonLocator = page.getByTestId('debug-pane-button')
const headingLocator = page.getByRole('heading', { const headingLocator = page.getByRole('heading', {
name: 'Settings', name: 'Settings',
@ -78,11 +85,23 @@ test.describe('Testing settings', () => {
}) })
const inputLocator = page.locator('input[name="modeling-showDebugPanel"]') const inputLocator = page.locator('input[name="modeling-showDebugPanel"]')
// Open the settings modal with the browser keyboard shortcut await test.step('Open settings dialog and set "Show debug panel" to on', async () => {
await page.keyboard.press('ControlOrMeta+Shift+,') await page.keyboard.press('ControlOrMeta+Shift+,')
await expect(headingLocator).toBeVisible() await expect(headingLocator).toBeVisible()
/** Test to close https://github.com/KittyCAD/modeling-app/issues/2713 */
await test.step(`Confirm that this dialog has a solid background`, async () => {
await expect
.poll(() => u.getGreatestPixDiff({ x: 600, y: 250 }, [28, 28, 28]), {
timeout: 1000,
message:
'Checking for solid background, should not see default plane colors',
})
.toBeLessThan(15)
})
await page.locator('#showDebugPanel').getByText('OffOn').click() await page.locator('#showDebugPanel').getByText('OffOn').click()
})
// Close it and open again with keyboard shortcut, while KCL editor is focused // Close it and open again with keyboard shortcut, while KCL editor is focused
// Put the cursor in the editor // Put the cursor in the editor
@ -154,16 +173,17 @@ test.describe('Testing settings', () => {
test('Project and user settings can be reset', async ({ page }) => { test('Project and user settings can be reset', async ({ page }) => {
const u = await getUtils(page) const u = await getUtils(page)
await test.step(`Setup`, async () => {
await page.setViewportSize({ width: 1200, height: 500 }) await page.setViewportSize({ width: 1200, height: 500 })
await u.waitForAuthSkipAppStart() await u.waitForAuthSkipAppStart()
await page })
.getByRole('button', { name: 'Start Sketch' })
.waitFor({ state: 'visible' })
// Selectors and constants
const projectSettingsTab = page.getByRole('radio', { name: 'Project' }) const projectSettingsTab = page.getByRole('radio', { name: 'Project' })
const userSettingsTab = page.getByRole('radio', { name: 'User' }) const userSettingsTab = page.getByRole('radio', { name: 'User' })
const resetButton = page.getByRole('button', { const resetButton = (level: SettingsLevel) =>
name: 'Restore default settings', page.getByRole('button', {
name: `Reset ${level}-level settings`,
}) })
const themeColorSetting = page.locator('#themeColor').getByRole('slider') const themeColorSetting = page.locator('#themeColor').getByRole('slider')
const settingValues = { const settingValues = {
@ -171,12 +191,15 @@ test.describe('Testing settings', () => {
user: '120', user: '120',
project: '50', project: '50',
} }
const resetToast = (level: SettingsLevel) =>
page.getByText(`${level}-level settings were reset`)
// Open the settings modal with lower-right button await test.step(`Open the settings modal`, async () => {
await page.getByRole('link', { name: 'Settings' }).last().click() await page.getByRole('link', { name: 'Settings' }).last().click()
await expect( await expect(
page.getByRole('heading', { name: 'Settings', exact: true }) page.getByRole('heading', { name: 'Settings', exact: true })
).toBeVisible() ).toBeVisible()
})
await test.step('Set up theme color', async () => { await test.step('Set up theme color', async () => {
// Verify we're looking at the project-level settings, // Verify we're looking at the project-level settings,
@ -195,37 +218,40 @@ test.describe('Testing settings', () => {
await test.step('Reset project settings', async () => { await test.step('Reset project settings', async () => {
// Click the reset settings button. // Click the reset settings button.
await resetButton.click() await resetButton('project').click()
await expect(page.getByText('Settings restored to default')).toBeVisible() await expect(resetToast('project')).toBeVisible()
await expect( await expect(resetToast('project')).not.toBeVisible()
page.getByText('Settings restored to default')
).not.toBeVisible()
// Verify it is now set to the inherited user value // Verify it is now set to the inherited user value
await expect(themeColorSetting).toHaveValue(settingValues.default) await expect(themeColorSetting).toHaveValue(settingValues.user)
// Check that the user setting also rolled back await test.step(`Check that the user settings did not change`, async () => {
await userSettingsTab.click() await userSettingsTab.click()
await expect(themeColorSetting).toHaveValue(settingValues.default) await expect(themeColorSetting).toHaveValue(settingValues.user)
await projectSettingsTab.click() })
// Set project-level value to 50 again to test the user-level reset await test.step(`Set project-level again to test the user-level reset`, async () => {
await projectSettingsTab.click()
await themeColorSetting.fill(settingValues.project) await themeColorSetting.fill(settingValues.project)
await userSettingsTab.click() await userSettingsTab.click()
}) })
})
await test.step('Reset user settings', async () => { await test.step('Reset user settings', async () => {
// Change the setting and click the reset settings button. // Click the reset settings button.
await themeColorSetting.fill(settingValues.user) await resetButton('user').click()
await resetButton.click()
await expect(resetToast('user')).toBeVisible()
await expect(resetToast('user')).not.toBeVisible()
// Verify it is now set to the default value // Verify it is now set to the default value
await expect(themeColorSetting).toHaveValue(settingValues.default) await expect(themeColorSetting).toHaveValue(settingValues.default)
// Check that the project setting also changed await test.step(`Check that the project settings did not change`, async () => {
await projectSettingsTab.click() await projectSettingsTab.click()
await expect(themeColorSetting).toHaveValue(settingValues.default) await expect(themeColorSetting).toHaveValue(settingValues.project)
})
}) })
}) })
@ -429,25 +455,37 @@ test.describe('Testing settings', () => {
test('Changing modeling default unit', async ({ page }) => { test('Changing modeling default unit', async ({ page }) => {
const u = await getUtils(page) const u = await getUtils(page)
await test.step(`Test setup`, async () => {
await page.setViewportSize({ width: 1200, height: 500 }) await page.setViewportSize({ width: 1200, height: 500 })
await u.waitForAuthSkipAppStart() await u.waitForAuthSkipAppStart()
await page await page
.getByRole('button', { name: 'Start Sketch' }) .getByRole('button', { name: 'Start Sketch' })
.waitFor({ state: 'visible' }) .waitFor({ state: 'visible' })
})
// Selectors and constants
const userSettingsTab = page.getByRole('radio', { name: 'User' }) const userSettingsTab = page.getByRole('radio', { name: 'User' })
const projectSettingsTab = page.getByRole('radio', { name: 'Project' })
const defaultUnitSection = page.getByText(
'default unitRoll back default unitRoll back to match'
)
const defaultUnitRollbackButton = page.getByRole('button', {
name: 'Roll back default unit',
})
// Open the settings modal with lower-right button await test.step(`Open the settings modal`, async () => {
await page.getByRole('link', { name: 'Settings' }).last().click() await page.getByRole('link', { name: 'Settings' }).last().click()
await expect( await expect(
page.getByRole('heading', { name: 'Settings', exact: true }) page.getByRole('heading', { name: 'Settings', exact: true })
).toBeVisible() ).toBeVisible()
const resetButton = page.getByRole('button', {
name: 'Restore default settings',
}) })
// Default unit should be mm
await resetButton.click() await test.step(`Reset unit setting`, async () => {
await userSettingsTab.click()
await defaultUnitSection.hover()
await defaultUnitRollbackButton.click()
await projectSettingsTab.click()
})
await test.step('Change modeling default unit within project tab', async () => { await test.step('Change modeling default unit within project tab', async () => {
const changeUnitOfMeasureInProjectTab = async (unitOfMeasure: string) => { const changeUnitOfMeasureInProjectTab = async (unitOfMeasure: string) => {
@ -552,4 +590,148 @@ test.describe('Testing settings', () => {
await changeUnitOfMeasureInGizmo('m', 'Meters') await changeUnitOfMeasureInGizmo('m', 'Meters')
}) })
}) })
test('Changing theme in sketch mode', async ({ page }) => {
const u = await getUtils(page)
await page.addInitScript(() => {
localStorage.setItem(
'persistCode',
`const sketch001 = startSketchOn('XZ')
|> startProfileAt([0, 0], %)
|> line([5, 0], %)
|> line([0, 5], %)
|> line([-5, 0], %)
|> lineTo([profileStartX(%), profileStartY(%)], %)
|> close(%)
const extrude001 = extrude(5, sketch001)
`
)
})
await page.setViewportSize({ width: 1200, height: 500 })
// Selectors and constants
const editSketchButton = page.getByRole('button', { name: 'Edit Sketch' })
const lineToolButton = page.getByTestId('line')
const segmentOverlays = page.getByTestId('segment-overlay')
const sketchOriginLocation = { x: 600, y: 250 }
const darkThemeSegmentColor: [number, number, number] = [215, 215, 215]
const lightThemeSegmentColor: [number, number, number] = [90, 90, 90]
await test.step(`Get into sketch mode`, async () => {
await u.waitForAuthSkipAppStart()
await page.mouse.click(700, 200)
await expect(editSketchButton).toBeVisible()
await editSketchButton.click()
// We use the line tool as a proxy for sketch mode
await expect(lineToolButton).toBeVisible()
await expect(segmentOverlays).toHaveCount(4)
// but we allow more time to pass for animating to the sketch
await page.waitForTimeout(1000)
})
await test.step(`Check the sketch line color before`, async () => {
await expect
.poll(() =>
u.getGreatestPixDiff(sketchOriginLocation, darkThemeSegmentColor)
)
.toBeLessThan(15)
})
await test.step(`Change theme to light using command palette`, async () => {
await page.keyboard.press('ControlOrMeta+K')
await page.getByRole('option', { name: 'theme' }).click()
await page.getByRole('option', { name: 'light' }).click()
await expect(page.getByText('theme to "light"')).toBeVisible()
// Make sure we haven't left sketch mode
await expect(lineToolButton).toBeVisible()
})
await test.step(`Check the sketch line color after`, async () => {
await expect
.poll(() =>
u.getGreatestPixDiff(sketchOriginLocation, lightThemeSegmentColor)
)
.toBeLessThan(15)
})
})
test(`Turning off "Show debug panel" with debug panel open leaves no phantom panel`, async ({
page,
}) => {
const u = await getUtils(page)
// Override beforeEach test setup
// with debug panel open
// but "show debug panel" set to false
await page.addInitScript(
async ({ settingsKey, settings }) => {
localStorage.setItem(settingsKey, settings)
localStorage.setItem(
'persistModelingContext',
'{"openPanes":["debug"]}'
)
},
{
settingsKey: TEST_SETTINGS_KEY,
settings: TOML.stringify({
settings: {
...TEST_SETTINGS,
modeling: { ...TEST_SETTINGS.modeling, showDebugPanel: false },
},
}),
}
)
await page.setViewportSize({ width: 1200, height: 500 })
// Constants and locators
const resizeHandle = page.locator('.sidebar-resize-handles > div.block')
const debugPaneButton = page.getByTestId('debug-pane-button')
const commandsButton = page.getByRole('button', { name: 'Commands' })
const debugPaneOption = page.getByRole('option', {
name: 'Settings · modeling · show debug panel',
})
async function setShowDebugPanelTo(value: 'On' | 'Off') {
await commandsButton.click()
await debugPaneOption.click()
await page.getByRole('option', { name: value }).click()
await expect(
page.getByText(
`Set show debug panel to "${value === 'On'}" for this project`
)
).toBeVisible()
}
await test.step(`Initial load with corrupted settings`, async () => {
await u.waitForAuthSkipAppStart()
// Check that the debug panel is not visible
await expect(debugPaneButton).not.toBeVisible()
// Check the pane resize handle wrapper is not visible
await expect(resizeHandle).not.toBeVisible()
})
await test.step(`Open code pane to verify we see the resize handles`, async () => {
await u.openKclCodePanel()
await expect(resizeHandle).toBeVisible()
await u.closeKclCodePanel()
})
await test.step(`Turn on debug panel, open it`, async () => {
await setShowDebugPanelTo('On')
await expect(debugPaneButton).toBeVisible()
// We want the logic to clear the phantom panel, so we shouldn't see
// the real panel (and therefore the resize handle) yet
await expect(resizeHandle).not.toBeVisible()
await u.openDebugPanel()
await expect(resizeHandle).toBeVisible()
})
await test.step(`Turn off debug panel setting with it open`, async () => {
await setShowDebugPanelTo('Off')
await expect(debugPaneButton).not.toBeVisible()
await expect(resizeHandle).not.toBeVisible()
})
})
}) })

View File

@ -2,8 +2,8 @@ import { test, expect } from '@playwright/test'
import { doExport, getUtils, makeTemplate, setup, tearDown } from './test-utils' import { doExport, getUtils, makeTemplate, setup, tearDown } from './test-utils'
test.beforeEach(async ({ context, page }) => { test.beforeEach(async ({ context, page }, testInfo) => {
await setup(context, page) await setup(context, page, testInfo)
}) })
test.afterEach(async ({ page }, testInfo) => { test.afterEach(async ({ page }, testInfo) => {

View File

@ -1,12 +1,9 @@
appId: dev.zoo.modeling-app appId: dev.zoo.modeling-app
directories: directories:
output: out output: out
buildResources: assets buildResources: assets
files: files:
- .vite/** - .vite/**
mac: mac:
category: public.app-category.developer-tools category: public.app-category.developer-tools
artifactName: "${productName}-${version}-${arch}-${os}.${ext}" artifactName: "${productName}-${version}-${arch}-${os}.${ext}"
@ -28,7 +25,6 @@ mac:
description: Zoo KCL File description: Zoo KCL File
role: Editor role: Editor
rank: Owner rank: Owner
win: win:
artifactName: "${productName}-${version}-${arch}-${os}.${ext}" artifactName: "${productName}-${version}-${arch}-${os}.${ext}"
target: target:
@ -51,18 +47,15 @@ win:
mimeType: text/vnd.zoo.kcl mimeType: text/vnd.zoo.kcl
description: Zoo KCL File description: Zoo KCL File
role: Editor role: Editor
msi: msi:
oneClick: false oneClick: false
perMachine: true perMachine: true
nsis: nsis:
oneClick: false oneClick: false
perMachine: true perMachine: true
allowElevation: true allowElevation: true
installerIcon: "assets/icon.ico" installerIcon: "assets/icon.ico"
include: "./installer.nsh" include: "./installer.nsh"
linux: linux:
artifactName: "${productName}-${version}-${arch}-${os}.${ext}" artifactName: "${productName}-${version}-${arch}-${os}.${ext}"
target: target:
@ -76,7 +69,6 @@ linux:
mimeType: text/vnd.zoo.kcl mimeType: text/vnd.zoo.kcl
description: Zoo KCL File description: Zoo KCL File
role: Editor role: Editor
publish: publish:
- provider: generic - provider: generic
url: https://dl.zoo.dev/releases/modeling-app url: https://dl.zoo.dev/releases/modeling-app

View File

@ -15,6 +15,7 @@
/> />
<link rel="apple-touch-icon" href="/logo192.png" /> <link rel="apple-touch-icon" href="/logo192.png" />
<link rel="manifest" href="/manifest.json" /> <link rel="manifest" href="/manifest.json" />
<link rel="stylesheet" href="./inter/inter.css" />
<link rel="stylesheet" href="https://use.typekit.net/zzv8rvm.css" /> <link rel="stylesheet" href="https://use.typekit.net/zzv8rvm.css" />
<script <script
defer defer

View File

@ -1,6 +1,6 @@
{ {
"name": "zoo-modeling-app", "name": "zoo-modeling-app",
"version": "0.25.1", "version": "0.25.2",
"private": true, "private": true,
"productName": "Zoo Modeling App", "productName": "Zoo Modeling App",
"author": { "author": {
@ -34,7 +34,7 @@
"@ts-stack/markdown": "^1.5.0", "@ts-stack/markdown": "^1.5.0",
"@tweenjs/tween.js": "^23.1.1", "@tweenjs/tween.js": "^23.1.1",
"@xstate/inspect": "^0.8.0", "@xstate/inspect": "^0.8.0",
"@xstate/react": "^3.2.2", "@xstate/react": "^4.1.1",
"bonjour-service": "^1.2.1", "bonjour-service": "^1.2.1",
"codemirror": "^6.0.1", "codemirror": "^6.0.1",
"decamelize": "^6.0.0", "decamelize": "^6.0.0",
@ -64,7 +64,7 @@
"vscode-languageserver-protocol": "^3.17.5", "vscode-languageserver-protocol": "^3.17.5",
"vscode-uri": "^3.0.8", "vscode-uri": "^3.0.8",
"web-vitals": "^3.5.2", "web-vitals": "^3.5.2",
"xstate": "^4.38.2" "xstate": "^5.17.4"
}, },
"scripts": { "scripts": {
"start": "vite", "start": "vite",
@ -88,7 +88,7 @@
"build:wasm": "yarn wasm-prep && cd src/wasm-lib && wasm-pack build --release --target web --out-dir pkg && cargo test -p kcl-lib export_bindings && cd ../.. && yarn isomorphic-copy-wasm && yarn fmt", "build:wasm": "yarn wasm-prep && cd src/wasm-lib && wasm-pack build --release --target web --out-dir pkg && cargo test -p kcl-lib export_bindings && cd ../.. && yarn isomorphic-copy-wasm && yarn fmt",
"remove-importmeta": "sed -i 's/import.meta.url/window.location.origin/g' \"./src/wasm-lib/pkg/wasm_lib.js\"; sed -i '' 's/import.meta.url/window.location.origin/g' \"./src/wasm-lib/pkg/wasm_lib.js\" || echo \"sed for both mac and linux\"", "remove-importmeta": "sed -i 's/import.meta.url/window.location.origin/g' \"./src/wasm-lib/pkg/wasm_lib.js\"; sed -i '' 's/import.meta.url/window.location.origin/g' \"./src/wasm-lib/pkg/wasm_lib.js\" || echo \"sed for both mac and linux\"",
"wasm-prep": "rimraf src/wasm-lib/pkg && mkdirp src/wasm-lib/pkg && rimraf src/wasm-lib/kcl/bindings", "wasm-prep": "rimraf src/wasm-lib/pkg && mkdirp src/wasm-lib/pkg && rimraf src/wasm-lib/kcl/bindings",
"lint": "eslint --fix src e2e", "lint": "eslint --fix src e2e packages/codemirror-lsp-client",
"bump-jsons": "echo \"$(jq --arg v \"$VERSION\" '.version=$v' package.json --indent 2)\" > package.json", "bump-jsons": "echo \"$(jq --arg v \"$VERSION\" '.version=$v' package.json --indent 2)\" > package.json",
"postinstall": "yarn xstate:typegen && ./node_modules/.bin/electron-rebuild", "postinstall": "yarn xstate:typegen && ./node_modules/.bin/electron-rebuild",
"xstate:typegen": "yarn xstate typegen \"src/**/*.ts?(x)\"", "xstate:typegen": "yarn xstate typegen \"src/**/*.ts?(x)\"",
@ -183,7 +183,7 @@
"tailwindcss": "^3.4.1", "tailwindcss": "^3.4.1",
"ts-node": "^10.0.0", "ts-node": "^10.0.0",
"typescript": "^5.0.0", "typescript": "^5.0.0",
"vite": "^5.4.2", "vite": "^5.4.3",
"vite-plugin-eslint": "^1.8.1", "vite-plugin-eslint": "^1.8.1",
"vite-plugin-package-version": "^1.1.0", "vite-plugin-package-version": "^1.1.0",
"vite-tsconfig-paths": "^4.3.2", "vite-tsconfig-paths": "^4.3.2",

View File

@ -72,6 +72,7 @@ export class LanguageServerClient {
async initialize() { async initialize() {
// Start the client in the background. // Start the client in the background.
this.client.setNotifyFn(this.processNotifications.bind(this)) this.client.setNotifyFn(this.processNotifications.bind(this))
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.client.start() this.client.start()
this.ready = true this.ready = true
@ -195,6 +196,9 @@ export class LanguageServerClient {
} }
private processNotifications(notification: LSP.NotificationMessage) { private processNotifications(notification: LSP.NotificationMessage) {
for (const plugin of this.plugins) plugin.processNotification(notification) for (const plugin of this.plugins) {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
plugin.processNotification(notification)
}
} }
} }

View File

@ -12,6 +12,7 @@ export default function lspFormatExt(
run: (view: EditorView) => { run: (view: EditorView) => {
let value = view.plugin(plugin) let value = view.plugin(plugin)
if (!value) return false if (!value) return false
// eslint-disable-next-line @typescript-eslint/no-floating-promises
value.requestFormatting() value.requestFormatting()
return true return true
}, },

View File

@ -117,6 +117,7 @@ export class LanguageServerPlugin implements PluginValue {
this.processLspNotification = options.processLspNotification this.processLspNotification = options.processLspNotification
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.initialize({ this.initialize({
documentText: this.getDocText(), documentText: this.getDocText(),
}) })
@ -149,6 +150,7 @@ export class LanguageServerPlugin implements PluginValue {
} }
async initialize({ documentText }: { documentText: string }) { async initialize({ documentText }: { documentText: string }) {
// eslint-disable-next-line @typescript-eslint/no-misused-promises
if (this.client.initializePromise) { if (this.client.initializePromise) {
await this.client.initializePromise await this.client.initializePromise
} }
@ -162,7 +164,9 @@ export class LanguageServerPlugin implements PluginValue {
}, },
}) })
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.requestSemanticTokens() this.requestSemanticTokens()
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.updateFoldingRanges() this.updateFoldingRanges()
} }
@ -225,7 +229,9 @@ export class LanguageServerPlugin implements PluginValue {
contentChanges: [{ text: this.view.state.doc.toString() }], contentChanges: [{ text: this.view.state.doc.toString() }],
}) })
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.requestSemanticTokens() this.requestSemanticTokens()
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.updateFoldingRanges() this.updateFoldingRanges()
} catch (e) { } catch (e) {
console.error(e) console.error(e)
@ -526,7 +532,9 @@ export class LanguageServerPlugin implements PluginValue {
processDiagnostics(params: PublishDiagnosticsParams) { processDiagnostics(params: PublishDiagnosticsParams) {
if (params.uri !== this.getDocUri()) return if (params.uri !== this.getDocUri()) return
const diagnostics = params.diagnostics // Commented to avoid the lint. See TODO below.
// const diagnostics =
params.diagnostics
.map(({ range, message, severity }) => ({ .map(({ range, message, severity }) => ({
from: posToOffset(this.view.state.doc, range.start)!, from: posToOffset(this.view.state.doc, range.start)!,
to: posToOffset(this.view.state.doc, range.end)!, to: posToOffset(this.view.state.doc, range.end)!,

View File

@ -57,10 +57,10 @@ export default defineConfig({
}, },
}, // or 'chrome-beta' }, // or 'chrome-beta'
}, },
{ // {
name: 'webkit', // name: 'webkit',
use: { ...devices['Desktop Safari'] }, // use: { ...devices['Desktop Safari'] },
}, // },
// { // {
// name: 'firefox', // name: 'firefox',
// use: { ...devices['Desktop Firefox'] }, // use: { ...devices['Desktop Firefox'] },

Binary file not shown.

Binary file not shown.

14
public/inter/inter.css Normal file
View File

@ -0,0 +1,14 @@
@font-face {
font-family: Inter;
font-style: normal;
font-weight: 100 900;
font-display: swap;
src: url("InterVariable.woff2") format("woff2");
}
@font-face {
font-family: Inter;
font-style: italic;
font-weight: 100 900;
font-display: swap;
src: url("InterVariable-Italic.woff2") format("woff2");
}

View File

@ -1,12 +1,8 @@
import { MouseEventHandler, useEffect, useMemo, useRef } from 'react' import { useEffect, useMemo, useRef } from 'react'
import { uuidv4 } from 'lib/utils'
import { useHotKeyListener } from './hooks/useHotKeyListener' import { useHotKeyListener } from './hooks/useHotKeyListener'
import { Stream } from './components/Stream' import { Stream } from './components/Stream'
import { EngineCommand } from 'lang/std/artifactGraph'
import { throttle } from './lib/utils'
import { AppHeader } from './components/AppHeader' import { AppHeader } from './components/AppHeader'
import { useHotkeys } from 'react-hotkeys-hook' import { useHotkeys } from 'react-hotkeys-hook'
import { getNormalisedCoordinates } from './lib/utils'
import { useLoaderData, useNavigate } from 'react-router-dom' import { useLoaderData, useNavigate } from 'react-router-dom'
import { type IndexLoaderData } from 'lib/types' import { type IndexLoaderData } from 'lib/types'
import { PATHS } from 'lib/paths' import { PATHS } from 'lib/paths'
@ -14,7 +10,6 @@ import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
import { onboardingPaths } from 'routes/Onboarding/paths' import { onboardingPaths } from 'routes/Onboarding/paths'
import { useEngineConnectionSubscriptions } from 'hooks/useEngineConnectionSubscriptions' import { useEngineConnectionSubscriptions } from 'hooks/useEngineConnectionSubscriptions'
import { codeManager, engineCommandManager } from 'lib/singletons' import { codeManager, engineCommandManager } from 'lib/singletons'
import { useModelingContext } from 'hooks/useModelingContext'
import { useAbsoluteFilePath } from 'hooks/useAbsoluteFilePath' import { useAbsoluteFilePath } from 'hooks/useAbsoluteFilePath'
import { isDesktop } from 'lib/isDesktop' import { isDesktop } from 'lib/isDesktop'
import { useLspContext } from 'components/LspProvider' import { useLspContext } from 'components/LspProvider'
@ -44,7 +39,6 @@ export function App() {
}, [projectName, projectPath]) }, [projectName, projectPath])
useHotKeyListener() useHotKeyListener()
const { context, state } = useModelingContext()
const { auth, settings } = useSettingsAuthContext() const { auth, settings } = useSettingsAuthContext()
const token = auth?.context?.token const token = auth?.context?.token
@ -73,61 +67,14 @@ export function App() {
(p) => p === onboardingStatus.current (p) => p === onboardingStatus.current
) )
? 'opacity-20' ? 'opacity-20'
: context.store?.didDragInStream
? 'opacity-40'
: '' : ''
useEngineConnectionSubscriptions() useEngineConnectionSubscriptions()
const debounceSocketSend = throttle<EngineCommand>((message) => {
engineCommandManager.sendSceneCommand(message)
}, 1000 / 15)
const handleMouseMove: MouseEventHandler<HTMLDivElement> = (e) => {
if (state.matches('Sketch')) {
return
}
const { x, y } = getNormalisedCoordinates({
clientX: e.clientX,
clientY: e.clientY,
el: e.currentTarget,
...context.store?.streamDimensions,
})
const newCmdId = uuidv4()
if (state.matches('idle.showPlanes')) return
if (context.store?.buttonDownInStream !== undefined) return
debounceSocketSend({
type: 'modeling_cmd_req',
cmd: {
type: 'highlight_set_entity',
selected_at_window: { x, y },
},
cmd_id: newCmdId,
})
}
return ( return (
<div <div className="relative h-full flex flex-col" ref={ref}>
className="relative h-full flex flex-col"
onMouseMove={handleMouseMove}
ref={ref}
>
<AppHeader <AppHeader
className={ className={'transition-opacity transition-duration-75 ' + paneOpacity}
'transition-opacity transition-duration-75 ' +
paneOpacity +
(context.store?.buttonDownInStream ? ' pointer-events-none' : '')
}
// Override the electron window draggable region behavior as well
// when the button is down in the stream
style={
isDesktop() && context.store?.buttonDownInStream
? ({
'-webkit-app-region': 'no-drag',
} as React.CSSProperties)
: {}
}
project={{ project, file }} project={{ project, file }}
enableMenu={true} enableMenu={true}
/> />

View File

@ -41,6 +41,7 @@ import toast from 'react-hot-toast'
import { coreDump } from 'lang/wasm' import { coreDump } from 'lang/wasm'
import { useMemo } from 'react' import { useMemo } from 'react'
import { AppStateProvider } from 'AppState' import { AppStateProvider } from 'AppState'
import { reportRejection } from 'lib/trap'
const createRouter = isDesktop() ? createHashRouter : createBrowserRouter const createRouter = isDesktop() ? createHashRouter : createBrowserRouter
@ -173,7 +174,8 @@ function CoreDump() {
[] []
) )
useHotkeyWrapper(['mod + shift + .'], () => { useHotkeyWrapper(['mod + shift + .'], () => {
toast.promise( toast
.promise(
coreDump(coreDumpManager, true), coreDump(coreDumpManager, true),
{ {
loading: 'Starting core dump...', loading: 'Starting core dump...',
@ -188,6 +190,7 @@ function CoreDump() {
}, },
} }
) )
.catch(reportRejection)
}) })
return null return null
} }

View File

@ -70,12 +70,12 @@ export function Toolbar({
*/ */
const configCallbackProps: ToolbarItemCallbackProps = useMemo( const configCallbackProps: ToolbarItemCallbackProps = useMemo(
() => ({ () => ({
modelingStateMatches: state.matches, modelingState: state,
modelingSend: send, modelingSend: send,
commandBarSend, commandBarSend,
sketchPathId, sketchPathId,
}), }),
[state.matches, send, commandBarSend, sketchPathId] [state, send, commandBarSend, sketchPathId]
) )
/** /**
@ -124,7 +124,7 @@ export function Toolbar({
}, [currentMode, disableAllButtons, configCallbackProps]) }, [currentMode, disableAllButtons, configCallbackProps])
return ( return (
<menu className="max-w-full whitespace-nowrap rounded-b px-2 py-1 bg-chalkboard-10 dark:bg-chalkboard-90 relative border border-chalkboard-20 dark:border-chalkboard-80 border-t-0 shadow-sm"> <menu className="max-w-full whitespace-nowrap rounded-b px-2 py-1 bg-chalkboard-10 dark:bg-chalkboard-90 relative border border-chalkboard-30 dark:border-chalkboard-80 border-t-0 shadow-sm">
<ul <ul
{...props} {...props}
ref={toolbarButtonsRef} ref={toolbarButtonsRef}

View File

@ -22,14 +22,16 @@ import {
UnreliableSubscription, UnreliableSubscription,
} from 'lang/std/engineConnection' } from 'lang/std/engineConnection'
import { EngineCommand } from 'lang/std/artifactGraph' import { EngineCommand } from 'lang/std/artifactGraph'
import { uuidv4 } from 'lib/utils' import { toSync, uuidv4 } from 'lib/utils'
import { deg2Rad } from 'lib/utils2d' import { deg2Rad } from 'lib/utils2d'
import { isReducedMotion, roundOff, throttle } from 'lib/utils' import { isReducedMotion, roundOff, throttle } from 'lib/utils'
import * as TWEEN from '@tweenjs/tween.js' import * as TWEEN from '@tweenjs/tween.js'
import { isQuaternionVertical } from './helpers' import { isQuaternionVertical } from './helpers'
import { reportRejection } from 'lib/trap'
const ORTHOGRAPHIC_CAMERA_SIZE = 20 const ORTHOGRAPHIC_CAMERA_SIZE = 20
const FRAMES_TO_ANIMATE_IN = 30 const FRAMES_TO_ANIMATE_IN = 30
const ORTHOGRAPHIC_MAGIC_FOV = 4
const tempQuaternion = new Quaternion() // just used for maths const tempQuaternion = new Quaternion() // just used for maths
@ -83,7 +85,7 @@ export class CameraControls {
pendingPan: Vector2 | null = null pendingPan: Vector2 | null = null
interactionGuards: MouseGuard = cameraMouseDragGuards.KittyCAD interactionGuards: MouseGuard = cameraMouseDragGuards.KittyCAD
isFovAnimationInProgress = false isFovAnimationInProgress = false
fovBeforeOrtho = 45 perspectiveFovBeforeOrtho = 45
get isPerspective() { get isPerspective() {
return this.camera instanceof PerspectiveCamera return this.camera instanceof PerspectiveCamera
} }
@ -100,6 +102,7 @@ export class CameraControls {
camProps.type === 'perspective' && camProps.type === 'perspective' &&
this.camera instanceof OrthographicCamera this.camera instanceof OrthographicCamera
) { ) {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.usePerspectiveCamera() this.usePerspectiveCamera()
} else if ( } else if (
camProps.type === 'orthographic' && camProps.type === 'orthographic' &&
@ -127,6 +130,7 @@ export class CameraControls {
} }
throttledEngCmd = throttle((cmd: EngineCommand) => { throttledEngCmd = throttle((cmd: EngineCommand) => {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.engineCommandManager.sendSceneCommand(cmd) this.engineCommandManager.sendSceneCommand(cmd)
}, 1000 / 30) }, 1000 / 30)
@ -139,6 +143,7 @@ export class CameraControls {
...convertThreeCamValuesToEngineCam(threeValues), ...convertThreeCamValuesToEngineCam(threeValues),
}, },
} }
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.engineCommandManager.sendSceneCommand(cmd) this.engineCommandManager.sendSceneCommand(cmd)
}, 1000 / 15) }, 1000 / 15)
@ -151,6 +156,7 @@ export class CameraControls {
this.lastPerspectiveCmd && this.lastPerspectiveCmd &&
Date.now() - this.lastPerspectiveCmdTime >= lastCmdDelay Date.now() - this.lastPerspectiveCmdTime >= lastCmdDelay
) { ) {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.engineCommandManager.sendSceneCommand(this.lastPerspectiveCmd, true) this.engineCommandManager.sendSceneCommand(this.lastPerspectiveCmd, true)
this.lastPerspectiveCmdTime = Date.now() this.lastPerspectiveCmdTime = Date.now()
} }
@ -218,6 +224,7 @@ export class CameraControls {
this.useOrthographicCamera() this.useOrthographicCamera()
} }
if (this.camera instanceof OrthographicCamera && !camSettings.ortho) { if (this.camera instanceof OrthographicCamera && !camSettings.ortho) {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.usePerspectiveCamera() this.usePerspectiveCamera()
} }
if (this.camera instanceof PerspectiveCamera && camSettings.fov_y) { if (this.camera instanceof PerspectiveCamera && camSettings.fov_y) {
@ -249,6 +256,7 @@ export class CameraControls {
const doZoom = () => { const doZoom = () => {
if (this.zoomDataFromLastFrame !== undefined) { if (this.zoomDataFromLastFrame !== undefined) {
this.handleStart() this.handleStart()
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.engineCommandManager.sendSceneCommand({ this.engineCommandManager.sendSceneCommand({
type: 'modeling_cmd_req', type: 'modeling_cmd_req',
cmd: { cmd: {
@ -266,6 +274,7 @@ export class CameraControls {
const doMove = () => { const doMove = () => {
if (this.moveDataFromLastFrame !== undefined) { if (this.moveDataFromLastFrame !== undefined) {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.engineCommandManager.sendSceneCommand({ this.engineCommandManager.sendSceneCommand({
type: 'modeling_cmd_req', type: 'modeling_cmd_req',
cmd: { cmd: {
@ -335,7 +344,8 @@ export class CameraControls {
this.camera.updateProjectionMatrix() this.camera.updateProjectionMatrix()
} }
onMouseDown = (event: MouseEvent) => { onMouseDown = (event: PointerEvent) => {
this.domElement.setPointerCapture(event.pointerId)
this.isDragging = true this.isDragging = true
this.mouseDownPosition.set(event.clientX, event.clientY) this.mouseDownPosition.set(event.clientX, event.clientY)
let interaction = this.getInteractionType(event) let interaction = this.getInteractionType(event)
@ -355,7 +365,7 @@ export class CameraControls {
} }
} }
onMouseMove = (event: MouseEvent) => { onMouseMove = (event: PointerEvent) => {
if (this.isDragging) { if (this.isDragging) {
this.mouseNewPosition.set(event.clientX, event.clientY) this.mouseNewPosition.set(event.clientX, event.clientY)
const deltaMove = this.mouseNewPosition const deltaMove = this.mouseNewPosition
@ -389,14 +399,33 @@ export class CameraControls {
const zoomFudgeFactor = 2280 const zoomFudgeFactor = 2280
distance = zoomFudgeFactor / (this.camera.zoom * 45) distance = zoomFudgeFactor / (this.camera.zoom * 45)
} }
const panSpeed = (distance / 1000 / 45) * this.fovBeforeOrtho const panSpeed = (distance / 1000 / 45) * this.perspectiveFovBeforeOrtho
this.pendingPan.x += -deltaMove.x * panSpeed this.pendingPan.x += -deltaMove.x * panSpeed
this.pendingPan.y += deltaMove.y * panSpeed this.pendingPan.y += deltaMove.y * panSpeed
} }
} else {
/**
* If we're not in sketch mode and not dragging, we can highlight entities
* under the cursor. This recently moved from being handled in App.tsx.
* This might not be the right spot, but it is more consolidated.
*/
if (this.syncDirection === 'engineToClient') {
const newCmdId = uuidv4()
this.throttledEngCmd({
type: 'modeling_cmd_req',
cmd: {
type: 'highlight_set_entity',
selected_at_window: { x: event.clientX, y: event.clientY },
},
cmd_id: newCmdId,
})
}
} }
} }
onMouseUp = (event: MouseEvent) => { onMouseUp = (event: PointerEvent) => {
this.domElement.releasePointerCapture(event.pointerId)
this.isDragging = false this.isDragging = false
this.handleEnd() this.handleEnd()
if (this.syncDirection === 'engineToClient') { if (this.syncDirection === 'engineToClient') {
@ -415,8 +444,19 @@ export class CameraControls {
} }
onMouseWheel = (event: WheelEvent) => { onMouseWheel = (event: WheelEvent) => {
const interaction = this.getInteractionType(event)
if (interaction === 'none') return
event.preventDefault()
if (this.syncDirection === 'engineToClient') { if (this.syncDirection === 'engineToClient') {
if (interaction === 'zoom') {
this.zoomDataFromLastFrame = event.deltaY this.zoomDataFromLastFrame = event.deltaY
} else {
// This case will get handled when we add pan and rotate using Apple trackpad.
console.error(
`Unexpected interaction type for engineToClient wheel event: ${interaction}`
)
}
return return
} }
@ -426,8 +466,16 @@ export class CameraControls {
// zoom commands to engine. This means dropping some zoom // zoom commands to engine. This means dropping some zoom
// commands too. // commands too.
// From onMouseMove zoom handling which seems to be really smooth // From onMouseMove zoom handling which seems to be really smooth
this.handleStart() this.handleStart()
if (interaction === 'zoom') {
this.pendingZoom = 1 + (event.deltaY / window.devicePixelRatio) * 0.001 this.pendingZoom = 1 + (event.deltaY / window.devicePixelRatio) * 0.001
} else {
// This case will get handled when we add pan and rotate using Apple trackpad.
console.error(
`Unexpected interaction type for wheel event: ${interaction}`
)
}
this.handleEnd() this.handleEnd()
} }
@ -459,6 +507,7 @@ export class CameraControls {
this.camera.quaternion.set(qx, qy, qz, qw) this.camera.quaternion.set(qx, qy, qz, qw)
this.camera.updateProjectionMatrix() this.camera.updateProjectionMatrix()
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.engineCommandManager.sendSceneCommand({ this.engineCommandManager.sendSceneCommand({
type: 'modeling_cmd_req', type: 'modeling_cmd_req',
cmd_id: uuidv4(), cmd_id: uuidv4(),
@ -487,19 +536,15 @@ export class CameraControls {
_usePerspectiveCamera = () => { _usePerspectiveCamera = () => {
const { x: px, y: py, z: pz } = this.camera.position const { x: px, y: py, z: pz } = this.camera.position
const { x: qx, y: qy, z: qz, w: qw } = this.camera.quaternion const { x: qx, y: qy, z: qz, w: qw } = this.camera.quaternion
const zoom = this.camera.zoom
this.camera = this.createPerspectiveCamera() this.camera = this.createPerspectiveCamera()
this.camera.position.set(px, py, pz) this.camera.position.set(px, py, pz)
this.camera.quaternion.set(qx, qy, qz, qw) this.camera.quaternion.set(qx, qy, qz, qw)
const zoomFudgeFactor = 2280
const distance = zoomFudgeFactor / (zoom * this.lastPerspectiveFov)
const direction = new Vector3().subVectors( const direction = new Vector3().subVectors(
this.camera.position, this.camera.position,
this.target this.target
) )
direction.normalize() direction.normalize()
this.camera.position.copy(this.target).addScaledVector(direction, distance)
} }
usePerspectiveCamera = async (forceSend = false) => { usePerspectiveCamera = async (forceSend = false) => {
this._usePerspectiveCamera() this._usePerspectiveCamera()
@ -929,6 +974,7 @@ export class CameraControls {
} }
if (isReducedMotion()) { if (isReducedMotion()) {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
onComplete() onComplete()
return return
} }
@ -937,7 +983,7 @@ export class CameraControls {
.to({ t: tweenEnd }, duration) .to({ t: tweenEnd }, duration)
.easing(TWEEN.Easing.Quadratic.InOut) .easing(TWEEN.Easing.Quadratic.InOut)
.onUpdate(({ t }) => cameraAtTime(t)) .onUpdate(({ t }) => cameraAtTime(t))
.onComplete(onComplete) .onComplete(toSync(onComplete, reportRejection))
.start() .start()
}) })
} }
@ -950,9 +996,9 @@ export class CameraControls {
) )
this.isFovAnimationInProgress = true this.isFovAnimationInProgress = true
let currentFov = this.lastPerspectiveFov let currentFov = this.lastPerspectiveFov
this.fovBeforeOrtho = currentFov this.perspectiveFovBeforeOrtho = currentFov
const targetFov = 4 const targetFov = ORTHOGRAPHIC_MAGIC_FOV
const fovAnimationStep = (currentFov - targetFov) / FRAMES_TO_ANIMATE_IN const fovAnimationStep = (currentFov - targetFov) / FRAMES_TO_ANIMATE_IN
let frameWaitOnFinish = 10 let frameWaitOnFinish = 10
@ -962,6 +1008,7 @@ export class CameraControls {
// Decrease the FOV // Decrease the FOV
currentFov = Math.max(currentFov - fovAnimationStep, targetFov) currentFov = Math.max(currentFov - fovAnimationStep, targetFov)
this.camera.updateProjectionMatrix() this.camera.updateProjectionMatrix()
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.dollyZoom(currentFov) this.dollyZoom(currentFov)
requestAnimationFrame(animateFovChange) // Continue the animation requestAnimationFrame(animateFovChange) // Continue the animation
} else if (frameWaitOnFinish > 0) { } else if (frameWaitOnFinish > 0) {
@ -987,10 +1034,11 @@ export class CameraControls {
) )
} }
this.isFovAnimationInProgress = true this.isFovAnimationInProgress = true
const targetFov = this.fovBeforeOrtho // Target FOV for perspective const targetFov = this.perspectiveFovBeforeOrtho // Target FOV for perspective
this.lastPerspectiveFov = 4 this.lastPerspectiveFov = ORTHOGRAPHIC_MAGIC_FOV
let currentFov = 4 let currentFov = ORTHOGRAPHIC_MAGIC_FOV
const initialCameraUp = this.camera.up.clone() const initialCameraUp = this.camera.up.clone()
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.usePerspectiveCamera() this.usePerspectiveCamera()
const tempVec = new Vector3() const tempVec = new Vector3()
@ -999,6 +1047,7 @@ export class CameraControls {
this.lastPerspectiveFov + (targetFov - this.lastPerspectiveFov) * t this.lastPerspectiveFov + (targetFov - this.lastPerspectiveFov) * t
const currentUp = tempVec.lerpVectors(initialCameraUp, targetCamUp, t) const currentUp = tempVec.lerpVectors(initialCameraUp, targetCamUp, t)
this.camera.up.copy(currentUp) this.camera.up.copy(currentUp)
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.dollyZoom(currentFov) this.dollyZoom(currentFov)
} }
@ -1023,10 +1072,10 @@ export class CameraControls {
) )
} }
this.isFovAnimationInProgress = true this.isFovAnimationInProgress = true
const targetFov = this.fovBeforeOrtho // Target FOV for perspective const targetFov = this.perspectiveFovBeforeOrtho // Target FOV for perspective
this.lastPerspectiveFov = 4 let currentFov = ORTHOGRAPHIC_MAGIC_FOV
let currentFov = 4
const initialCameraUp = this.camera.up.clone() const initialCameraUp = this.camera.up.clone()
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.usePerspectiveCamera() this.usePerspectiveCamera()
const tempVec = new Vector3() const tempVec = new Vector3()
@ -1093,7 +1142,7 @@ export class CameraControls {
this.deferReactUpdate(this.reactCameraProperties) this.deferReactUpdate(this.reactCameraProperties)
Object.values(this._camChangeCallbacks).forEach((cb) => cb()) Object.values(this._camChangeCallbacks).forEach((cb) => cb())
} }
getInteractionType = (event: any) => getInteractionType = (event: MouseEvent) =>
_getInteractionType( _getInteractionType(
this.interactionGuards, this.interactionGuards,
event, event,
@ -1175,7 +1224,7 @@ function convertThreeCamValuesToEngineCam({
const lookAt = buildLookAt(64 / zoom, target, position) const lookAt = buildLookAt(64 / zoom, target, position)
return { return {
center: new Vector3(lookAt.center.x, lookAt.center.y, lookAt.center.z), center: new Vector3(lookAt.center.x, lookAt.center.y, lookAt.center.z),
up: new Vector3(0, 0, 1), up: new Vector3(upVector.x, upVector.y, upVector.z),
vantage: new Vector3(lookAt.eye.x, lookAt.eye.y, lookAt.eye.z), vantage: new Vector3(lookAt.eye.x, lookAt.eye.y, lookAt.eye.z),
} }
} }
@ -1201,16 +1250,21 @@ function _lookAt(position: Vector3, target: Vector3, up: Vector3): Quaternion {
function _getInteractionType( function _getInteractionType(
interactionGuards: MouseGuard, interactionGuards: MouseGuard,
event: any, event: MouseEvent | WheelEvent,
enablePan: boolean, enablePan: boolean,
enableRotate: boolean, enableRotate: boolean,
enableZoom: boolean enableZoom: boolean
): interactionType | 'none' { ): interactionType | 'none' {
let state: interactionType | 'none' = 'none' if (event instanceof WheelEvent) {
if (enableZoom && interactionGuards.zoom.scrollCallback(event))
return 'zoom'
} else {
if (enablePan && interactionGuards.pan.callback(event)) return 'pan' if (enablePan && interactionGuards.pan.callback(event)) return 'pan'
if (enableRotate && interactionGuards.rotate.callback(event)) return 'rotate' if (enableRotate && interactionGuards.rotate.callback(event))
return 'rotate'
if (enableZoom && interactionGuards.zoom.dragCallback(event)) return 'zoom' if (enableZoom && interactionGuards.zoom.dragCallback(event)) return 'zoom'
return state }
return 'none'
} }
/** /**

View File

@ -5,7 +5,7 @@ import { cameraMouseDragGuards } from 'lib/cameraControls'
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext' import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
import { ARROWHEAD, DEBUG_SHOW_BOTH_SCENES } from './sceneInfra' import { ARROWHEAD, DEBUG_SHOW_BOTH_SCENES } from './sceneInfra'
import { ReactCameraProperties } from './CameraControls' import { ReactCameraProperties } from './CameraControls'
import { throttle } from 'lib/utils' import { throttle, toSync } from 'lib/utils'
import { import {
sceneInfra, sceneInfra,
kclManager, kclManager,
@ -34,17 +34,15 @@ import { CustomIcon, CustomIconName } from 'components/CustomIcon'
import { ConstrainInfo } from 'lang/std/stdTypes' import { ConstrainInfo } from 'lang/std/stdTypes'
import { getConstraintInfo } from 'lang/std/sketch' import { getConstraintInfo } from 'lang/std/sketch'
import { Dialog, Popover, Transition } from '@headlessui/react' import { Dialog, Popover, Transition } from '@headlessui/react'
import { LineInputsType } from 'lang/std/sketchcombos'
import toast from 'react-hot-toast' import toast from 'react-hot-toast'
import { InstanceProps, create } from 'react-modal-promise' import { InstanceProps, create } from 'react-modal-promise'
import { executeAst } from 'lang/langHelpers' import { executeAst } from 'lang/langHelpers'
import { import {
deleteSegmentFromPipeExpression, deleteSegmentFromPipeExpression,
makeRemoveSingleConstraintInput,
removeSingleConstraintInfo, removeSingleConstraintInfo,
} from 'lang/modifyAst' } from 'lang/modifyAst'
import { ActionButton } from 'components/ActionButton' import { ActionButton } from 'components/ActionButton'
import { err, trap } from 'lib/trap' import { err, reportRejection, trap } from 'lib/trap'
function useShouldHideScene(): { hideClient: boolean; hideServer: boolean } { function useShouldHideScene(): { hideClient: boolean; hideServer: boolean } {
const [isCamMoving, setIsCamMoving] = useState(false) const [isCamMoving, setIsCamMoving] = useState(false)
@ -124,9 +122,9 @@ export const ClientSideScene = ({
} else if (context.mouseState.type === 'isDragging') { } else if (context.mouseState.type === 'isDragging') {
cursor = 'grabbing' cursor = 'grabbing'
} else if ( } else if (
state.matches('Sketch.Line tool') || state.matches({ Sketch: 'Line tool' }) ||
state.matches('Sketch.Tangential arc to') || state.matches({ Sketch: 'Tangential arc to' }) ||
state.matches('Sketch.Rectangle tool') state.matches({ Sketch: 'Rectangle tool' })
) { ) {
cursor = 'crosshair' cursor = 'crosshair'
} else { } else {
@ -214,9 +212,9 @@ const Overlay = ({
overlay.visible && overlay.visible &&
typeof context?.segmentHoverMap?.[pathToNodeString] === 'number' && typeof context?.segmentHoverMap?.[pathToNodeString] === 'number' &&
!( !(
state.matches('Sketch.Line tool') || state.matches({ Sketch: 'Line tool' }) ||
state.matches('Sketch.Tangential arc to') || state.matches({ Sketch: 'Tangential arc to' }) ||
state.matches('Sketch.Rectangle tool') state.matches({ Sketch: 'Rectangle tool' })
) )
return ( return (
@ -542,12 +540,10 @@ const ConstraintSymbol = ({
iconName: 'dimension', iconName: 'dimension',
}, },
} }
const varName = const varName = varNameMap?.[_type]?.varName || 'var'
_type in varNameMap ? varNameMap[_type as LineInputsType].varName : 'var' const name: CustomIconName = varNameMap[_type].iconName
const name: CustomIconName = varNameMap[_type as LineInputsType].iconName const displayName = varNameMap[_type]?.displayName
const displayName = varNameMap[_type as LineInputsType]?.displayName const implicitDesc = varNameMap[_type]?.implicitConstraintDesc
const implicitDesc =
varNameMap[_type as LineInputsType]?.implicitConstraintDesc
const _node = useMemo( const _node = useMemo(
() => getNodeFromPath<Expr>(kclManager.ast, pathToNode), () => getNodeFromPath<Expr>(kclManager.ast, pathToNode),
@ -582,7 +578,7 @@ const ConstraintSymbol = ({
}} }}
// disabled={isConstrained || !convertToVarEnabled} // disabled={isConstrained || !convertToVarEnabled}
// disabled={implicitDesc} TODO why does this change styles that are hard to override? // disabled={implicitDesc} TODO why does this change styles that are hard to override?
onClick={async () => { onClick={toSync(async () => {
if (!isConstrained) { if (!isConstrained) {
send({ send({
type: 'Convert to variable', type: 'Convert to variable',
@ -604,25 +600,23 @@ const ConstraintSymbol = ({
if (trap(_node1)) return Promise.reject(_node1) if (trap(_node1)) return Promise.reject(_node1)
const shallowPath = _node1.shallowPath const shallowPath = _node1.shallowPath
const input = makeRemoveSingleConstraintInput( if (!context.sketchDetails || !argPosition) return
argPosition,
shallowPath
)
if (!input || !context.sketchDetails) return
const transform = removeSingleConstraintInfo( const transform = removeSingleConstraintInfo(
input, shallowPath,
argPosition,
kclManager.ast, kclManager.ast,
kclManager.programMemory kclManager.programMemory
) )
if (!transform) return if (!transform) return
const { modifiedAst } = transform const { modifiedAst } = transform
// eslint-disable-next-line @typescript-eslint/no-floating-promises
kclManager.updateAst(modifiedAst, true) kclManager.updateAst(modifiedAst, true)
} catch (e) { } catch (e) {
console.log('error', e) console.log('error', e)
} }
toast.success('Constraint removed') toast.success('Constraint removed')
} }
}} }, reportRejection)}
> >
<CustomIcon name={name} /> <CustomIcon name={name} />
</button> </button>
@ -688,7 +682,7 @@ const ConstraintSymbol = ({
const throttled = throttle((a: ReactCameraProperties) => { const throttled = throttle((a: ReactCameraProperties) => {
if (a.type === 'perspective' && a.fov) { if (a.type === 'perspective' && a.fov) {
sceneInfra.camControls.dollyZoom(a.fov) sceneInfra.camControls.dollyZoom(a.fov).catch(reportRejection)
} }
}, 1000 / 15) }, 1000 / 15)
@ -718,6 +712,7 @@ export const CamDebugSettings = () => {
if (camSettings.type === 'perspective') { if (camSettings.type === 'perspective') {
sceneInfra.camControls.useOrthographicCamera() sceneInfra.camControls.useOrthographicCamera()
} else { } else {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
sceneInfra.camControls.usePerspectiveCamera(true) sceneInfra.camControls.usePerspectiveCamera(true)
} }
}} }}
@ -725,7 +720,7 @@ export const CamDebugSettings = () => {
<div> <div>
<button <button
onClick={() => { onClick={() => {
sceneInfra.camControls.resetCameraPosition() sceneInfra.camControls.resetCameraPosition().catch(reportRejection)
}} }}
> >
Reset Camera Position Reset Camera Position

View File

@ -1,10 +1,8 @@
import { import {
BoxGeometry, BoxGeometry,
DoubleSide, DoubleSide,
ExtrudeGeometry,
Group, Group,
Intersection, Intersection,
LineCurve3,
Mesh, Mesh,
MeshBasicMaterial, MeshBasicMaterial,
Object3D, Object3D,
@ -15,7 +13,6 @@ import {
Points, Points,
Quaternion, Quaternion,
Scene, Scene,
Shape,
Vector2, Vector2,
Vector3, Vector3,
} from 'three' } from 'three'
@ -27,9 +24,6 @@ import {
OnClickCallbackArgs, OnClickCallbackArgs,
OnMouseEnterLeaveArgs, OnMouseEnterLeaveArgs,
RAYCASTABLE_PLANE, RAYCASTABLE_PLANE,
SEGMENT_LENGTH_LABEL,
SEGMENT_LENGTH_LABEL_OFFSET_PX,
SEGMENT_LENGTH_LABEL_TEXT,
SKETCH_GROUP_SEGMENTS, SKETCH_GROUP_SEGMENTS,
SKETCH_LAYER, SKETCH_LAYER,
X_AXIS, X_AXIS,
@ -38,7 +32,6 @@ import {
import { isQuaternionVertical, quaternionFromUpNForward } from './helpers' import { isQuaternionVertical, quaternionFromUpNForward } from './helpers'
import { import {
CallExpression, CallExpression,
getTangentialArcToInfo,
parse, parse,
Path, Path,
PathToNode, PathToNode,
@ -62,11 +55,9 @@ import {
import { getNodeFromPath, getNodePathFromSourceRange } from 'lang/queryAst' import { getNodeFromPath, getNodePathFromSourceRange } from 'lang/queryAst'
import { executeAst } from 'lang/langHelpers' import { executeAst } from 'lang/langHelpers'
import { import {
createArcGeometry, createProfileStartHandle,
dashedStraight, SegmentUtils,
profileStart, segmentUtils,
straightSegment,
tangentialArcToSegment,
} from './segments' } from './segments'
import { import {
addCallExpressionsToPipe, addCallExpressionsToPipe,
@ -75,13 +66,7 @@ import {
changeSketchArguments, changeSketchArguments,
updateStartProfileAtArgs, updateStartProfileAtArgs,
} from 'lang/std/sketch' } from 'lang/std/sketch'
import { import { isArray, isOverlap, roundOff } from 'lib/utils'
isArray,
isOverlap,
normaliseAngle,
roundOff,
throttle,
} from 'lib/utils'
import { import {
addStartProfileAt, addStartProfileAt,
createArrayExpression, createArrayExpression,
@ -92,7 +77,6 @@ import {
findUniqueName, findUniqueName,
} from 'lang/modifyAst' } from 'lang/modifyAst'
import { Selections, getEventForSegmentSelection } from 'lib/selections' import { Selections, getEventForSegmentSelection } from 'lib/selections'
import { getTangentPointFromPreviousArc } from 'lib/utils2d'
import { createGridHelper, orthoScale, perspScale } from './helpers' import { createGridHelper, orthoScale, perspScale } from './helpers'
import { Models } from '@kittycad/lib' import { Models } from '@kittycad/lib'
import { uuidv4 } from 'lib/utils' import { uuidv4 } from 'lib/utils'
@ -102,8 +86,8 @@ import {
getRectangleCallExpressions, getRectangleCallExpressions,
updateRectangleSketch, updateRectangleSketch,
} from 'lib/rectangleTool' } from 'lib/rectangleTool'
import { getThemeColorForThreeJs } from 'lib/theme' import { getThemeColorForThreeJs, Themes } from 'lib/theme'
import { err, trap } from 'lib/trap' import { err, reportRejection, trap } from 'lib/trap'
import { CSS2DObject } from 'three/examples/jsm/renderers/CSS2DRenderer' import { CSS2DObject } from 'three/examples/jsm/renderers/CSS2DRenderer'
import { Point3d } from 'wasm-lib/kcl/bindings/Point3d' import { Point3d } from 'wasm-lib/kcl/bindings/Point3d'
@ -122,6 +106,11 @@ export const TANGENTIAL_ARC_TO_SEGMENT_BODY = 'tangential-arc-to-segment-body'
export const SEGMENT_WIDTH_PX = 1.6 export const SEGMENT_WIDTH_PX = 1.6
export const HIDE_SEGMENT_LENGTH = 75 // in pixels export const HIDE_SEGMENT_LENGTH = 75 // in pixels
export const HIDE_HOVER_SEGMENT_LENGTH = 60 // in pixels export const HIDE_HOVER_SEGMENT_LENGTH = 60 // in pixels
export const SEGMENT_BODIES = [STRAIGHT_SEGMENT, TANGENTIAL_ARC_TO_SEGMENT]
export const SEGMENT_BODIES_PLUS_PROFILE_START = [
...SEGMENT_BODIES,
PROFILE_START,
]
type Vec3Array = [number, number, number] type Vec3Array = [number, number, number]
@ -155,37 +144,35 @@ export class SceneEntities {
? orthoFactor ? orthoFactor
: perspScale(sceneInfra.camControls.camera, segment)) / : perspScale(sceneInfra.camControls.camera, segment)) /
sceneInfra._baseUnitMultiplier sceneInfra._baseUnitMultiplier
const input = {
type: 'straight-segment',
from: segment.userData.from,
to: segment.userData.to,
} as const
let update: SegmentUtils['update'] | null = null
if ( if (
segment.userData.from && segment.userData.from &&
segment.userData.to && segment.userData.to &&
segment.userData.type === STRAIGHT_SEGMENT segment.userData.type === STRAIGHT_SEGMENT
) { ) {
callbacks.push( update = segmentUtils.straight.update
this.updateStraightSegment({
from: segment.userData.from,
to: segment.userData.to,
group: segment,
scale: factor,
})
)
} }
if ( if (
segment.userData.from && segment.userData.from &&
segment.userData.to && segment.userData.to &&
segment.userData.prevSegment && segment.userData.prevSegment &&
segment.userData.type === TANGENTIAL_ARC_TO_SEGMENT segment.userData.type === TANGENTIAL_ARC_TO_SEGMENT
) { ) {
callbacks.push( update = segmentUtils.tangentialArcTo.update
this.updateTangentialArcToSegment({ }
const callBack = update?.({
prevSegment: segment.userData.prevSegment, prevSegment: segment.userData.prevSegment,
from: segment.userData.from, input,
to: segment.userData.to,
group: segment, group: segment,
scale: factor, scale: factor,
sceneInfra,
}) })
) callBack && !err(callBack) && callbacks.push(callBack)
}
if (segment.name === PROFILE_START) { if (segment.name === PROFILE_START) {
segment.scale.set(factor, factor, factor) segment.scale.set(factor, factor, factor)
} }
@ -324,6 +311,7 @@ export class SceneEntities {
) )
} }
sceneInfra.setCallbacks({ sceneInfra.setCallbacks({
// eslint-disable-next-line @typescript-eslint/no-misused-promises
onClick: async (args) => { onClick: async (args) => {
if (!args) return if (!args) return
if (args.mouseEvent.which !== 1) return if (args.mouseEvent.which !== 1) return
@ -421,7 +409,7 @@ export class SceneEntities {
maybeModdedAst, maybeModdedAst,
sketchGroup.start.__geoMeta.sourceRange sketchGroup.start.__geoMeta.sourceRange
) )
const _profileStart = profileStart({ const _profileStart = createProfileStartHandle({
from: sketchGroup.start.from, from: sketchGroup.start.from,
id: sketchGroup.start.__geoMeta.id, id: sketchGroup.start.__geoMeta.id,
pathToNode: segPathToNode, pathToNode: segPathToNode,
@ -476,50 +464,31 @@ export class SceneEntities {
if (err(_node1)) return if (err(_node1)) return
const callExpName = _node1.node?.callee?.name const callExpName = _node1.node?.callee?.name
if (segment.type === 'TangentialArcTo') { const initSegment =
seg = tangentialArcToSegment({ segment.type === 'TangentialArcTo'
? segmentUtils.tangentialArcTo.init
: segmentUtils.straight.init
const result = initSegment({
prevSegment: sketchGroup.value[index - 1], prevSegment: sketchGroup.value[index - 1],
from: segment.from,
to: segment.to,
id: segment.__geoMeta.id,
pathToNode: segPathToNode,
isDraftSegment,
scale: factor,
texture: sceneInfra.extraSegmentTexture,
theme: sceneInfra._theme,
isSelected,
})
callbacks.push(
this.updateTangentialArcToSegment({
prevSegment: sketchGroup.value[index - 1],
from: segment.from,
to: segment.to,
group: seg,
scale: factor,
})
)
} else {
seg = straightSegment({
from: segment.from,
to: segment.to,
id: segment.__geoMeta.id,
pathToNode: segPathToNode,
isDraftSegment,
scale: factor,
callExpName, callExpName,
input: {
type: 'straight-segment',
from: segment.from,
to: segment.to,
},
id: segment.__geoMeta.id,
pathToNode: segPathToNode,
isDraftSegment,
scale: factor,
texture: sceneInfra.extraSegmentTexture, texture: sceneInfra.extraSegmentTexture,
theme: sceneInfra._theme, theme: sceneInfra._theme,
isSelected, isSelected,
sceneInfra,
}) })
callbacks.push( if (err(result)) return
this.updateStraightSegment({ const { group: _group, updateOverlaysCallback } = result
from: segment.from, seg = _group
to: segment.to, callbacks.push(updateOverlaysCallback)
group: seg,
scale: factor,
})
)
}
seg.layers.set(SKETCH_LAYER) seg.layers.set(SKETCH_LAYER)
seg.traverse((child) => { seg.traverse((child) => {
child.layers.set(SKETCH_LAYER) child.layers.set(SKETCH_LAYER)
@ -602,16 +571,19 @@ export class SceneEntities {
kclManager.programMemory.get(variableDeclarationName), kclManager.programMemory.get(variableDeclarationName),
variableDeclarationName variableDeclarationName
) )
if (err(sg)) return sg if (err(sg)) return Promise.reject(sg)
const lastSeg = sg.value?.slice(-1)[0] || sg.start const lastSeg = sg?.value?.slice(-1)[0] || sg.start
const index = sg.value.length // because we've added a new segment that's not in the memory yet, no need for `-1` const index = sg.value.length // because we've added a new segment that's not in the memory yet, no need for `-1`
const mod = addNewSketchLn({ const mod = addNewSketchLn({
node: _ast, node: _ast,
programMemory: kclManager.programMemory, programMemory: kclManager.programMemory,
to: [lastSeg.to[0], lastSeg.to[1]], input: {
from: [lastSeg.to[0], lastSeg.to[1]], type: 'straight-segment',
to: lastSeg.to,
from: lastSeg.to,
},
fnName: segmentName, fnName: segmentName,
pathToNode: sketchPathToNode, pathToNode: sketchPathToNode,
}) })
@ -634,6 +606,7 @@ export class SceneEntities {
draftExpressionsIndices, draftExpressionsIndices,
}) })
sceneInfra.setCallbacks({ sceneInfra.setCallbacks({
// eslint-disable-next-line @typescript-eslint/no-misused-promises
onClick: async (args) => { onClick: async (args) => {
if (!args) return if (!args) return
if (args.mouseEvent.which !== 1) return if (args.mouseEvent.which !== 1) return
@ -681,8 +654,11 @@ export class SceneEntities {
const tmp = addNewSketchLn({ const tmp = addNewSketchLn({
node: kclManager.ast, node: kclManager.ast,
programMemory: kclManager.programMemory, programMemory: kclManager.programMemory,
to: [intersection2d.x, intersection2d.y], input: {
type: 'straight-segment',
from: [lastSegment.to[0], lastSegment.to[1]], from: [lastSegment.to[0], lastSegment.to[1]],
to: [intersection2d.x, intersection2d.y],
},
fnName: fnName:
lastSegment.type === 'TangentialArcTo' lastSegment.type === 'TangentialArcTo'
? 'tangentialArcTo' ? 'tangentialArcTo'
@ -701,7 +677,7 @@ export class SceneEntities {
if (profileStart) { if (profileStart) {
sceneInfra.modelingSend({ type: 'CancelSketch' }) sceneInfra.modelingSend({ type: 'CancelSketch' })
} else { } else {
this.setUpDraftSegment( await this.setUpDraftSegment(
sketchPathToNode, sketchPathToNode,
forward, forward,
up, up,
@ -771,6 +747,7 @@ export class SceneEntities {
}) })
sceneInfra.setCallbacks({ sceneInfra.setCallbacks({
// eslint-disable-next-line @typescript-eslint/no-misused-promises
onMove: async (args) => { onMove: async (args) => {
// Update the width and height of the draft rectangle // Update the width and height of the draft rectangle
const pathToNodeTwo = structuredClone(sketchPathToNode) const pathToNodeTwo = structuredClone(sketchPathToNode)
@ -818,6 +795,7 @@ export class SceneEntities {
this.updateSegment(seg, index, 0, _ast, orthoFactor, sketchGroup) this.updateSegment(seg, index, 0, _ast, orthoFactor, sketchGroup)
) )
}, },
// eslint-disable-next-line @typescript-eslint/no-misused-promises
onClick: async (args) => { onClick: async (args) => {
// Commit the rectangle to the full AST/code and return to sketch.idle // Commit the rectangle to the full AST/code and return to sketch.idle
const cornerPoint = args.intersectionPoint?.twoD const cornerPoint = args.intersectionPoint?.twoD
@ -831,14 +809,14 @@ export class SceneEntities {
sketchPathToNode || [], sketchPathToNode || [],
'VariableDeclaration' 'VariableDeclaration'
) )
if (trap(_node)) return Promise.reject(_node) if (trap(_node)) return
const sketchInit = _node.node?.declarations?.[0]?.init const sketchInit = _node.node?.declarations?.[0]?.init
if (sketchInit.type === 'PipeExpression') { if (sketchInit.type === 'PipeExpression') {
updateRectangleSketch(sketchInit, x, y, tags[0]) updateRectangleSketch(sketchInit, x, y, tags[0])
let _recastAst = parse(recast(_ast)) let _recastAst = parse(recast(_ast))
if (trap(_recastAst)) return Promise.reject(_recastAst) if (trap(_recastAst)) return
_ast = _recastAst _ast = _recastAst
// Update the primary AST and unequip the rectangle tool // Update the primary AST and unequip the rectangle tool
@ -858,7 +836,7 @@ export class SceneEntities {
programMemory.get(variableDeclarationName), programMemory.get(variableDeclarationName),
variableDeclarationName variableDeclarationName
) )
if (err(sketchGroup)) return sketchGroup if (err(sketchGroup)) return
const sgPaths = sketchGroup.value const sgPaths = sketchGroup.value
const orthoFactor = orthoScale(sceneInfra.camControls.camera) const orthoFactor = orthoScale(sceneInfra.camControls.camera)
@ -892,9 +870,11 @@ export class SceneEntities {
}) => { }) => {
let addingNewSegmentStatus: 'nothing' | 'pending' | 'added' = 'nothing' let addingNewSegmentStatus: 'nothing' | 'pending' | 'added' = 'nothing'
sceneInfra.setCallbacks({ sceneInfra.setCallbacks({
// eslint-disable-next-line @typescript-eslint/no-misused-promises
onDragEnd: async () => { onDragEnd: async () => {
if (addingNewSegmentStatus !== 'nothing') { if (addingNewSegmentStatus !== 'nothing') {
await this.tearDownSketch({ removeAxis: false }) await this.tearDownSketch({ removeAxis: false })
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.setupSketch({ this.setupSketch({
sketchPathToNode: pathToNode, sketchPathToNode: pathToNode,
maybeModdedAst: kclManager.ast, maybeModdedAst: kclManager.ast,
@ -911,6 +891,7 @@ export class SceneEntities {
}) })
} }
}, },
// eslint-disable-next-line @typescript-eslint/no-misused-promises
onDrag: async ({ onDrag: async ({
selected, selected,
intersectionPoint, intersectionPoint,
@ -944,8 +925,11 @@ export class SceneEntities {
const mod = addNewSketchLn({ const mod = addNewSketchLn({
node: kclManager.ast, node: kclManager.ast,
programMemory: kclManager.programMemory, programMemory: kclManager.programMemory,
input: {
type: 'straight-segment',
to: [intersectionPoint.twoD.x, intersectionPoint.twoD.y], to: [intersectionPoint.twoD.x, intersectionPoint.twoD.y],
from: [prevSegment.from[0], prevSegment.from[1]], from: prevSegment.from,
},
// TODO assuming it's always a straight segments being added // TODO assuming it's always a straight segments being added
// as this is easiest, and we'll need to add "tabbing" behavior // as this is easiest, and we'll need to add "tabbing" behavior
// to support other segment types // to support other segment types
@ -958,6 +942,7 @@ export class SceneEntities {
await kclManager.executeAstMock(mod.modifiedAst) await kclManager.executeAstMock(mod.modifiedAst)
await this.tearDownSketch({ removeAxis: false }) await this.tearDownSketch({ removeAxis: false })
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.setupSketch({ this.setupSketch({
sketchPathToNode: pathToNode, sketchPathToNode: pathToNode,
maybeModdedAst: kclManager.ast, maybeModdedAst: kclManager.ast,
@ -1065,7 +1050,7 @@ export class SceneEntities {
group.userData.from[0], group.userData.from[0],
group.userData.from[1], group.userData.from[1],
] ]
const to: [number, number] = [intersection2d.x, intersection2d.y] const dragTo: [number, number] = [intersection2d.x, intersection2d.y]
let modifiedAst = draftInfo ? draftInfo.truncatedAst : { ...kclManager.ast } let modifiedAst = draftInfo ? draftInfo.truncatedAst : { ...kclManager.ast }
const _node = getNodeFromPath<CallExpression>( const _node = getNodeFromPath<CallExpression>(
@ -1088,8 +1073,11 @@ export class SceneEntities {
modded = updateStartProfileAtArgs({ modded = updateStartProfileAtArgs({
node: modifiedAst, node: modifiedAst,
pathToNode, pathToNode,
to, input: {
type: 'straight-segment',
to: dragTo,
from, from,
},
previousProgramMemory: kclManager.programMemory, previousProgramMemory: kclManager.programMemory,
}) })
} else { } else {
@ -1097,8 +1085,11 @@ export class SceneEntities {
modifiedAst, modifiedAst,
kclManager.programMemory, kclManager.programMemory,
[node.start, node.end], [node.start, node.end],
to, {
from type: 'straight-segment',
from,
to: dragTo,
}
) )
} }
if (trap(modded)) return if (trap(modded)) return
@ -1161,7 +1152,7 @@ export class SceneEntities {
) )
) )
sceneInfra.overlayCallbacks(callBacks) sceneInfra.overlayCallbacks(callBacks)
})() })().catch(reportRejection)
} }
/** /**
@ -1201,268 +1192,53 @@ export class SceneEntities {
? orthoFactor ? orthoFactor
: perspScale(sceneInfra.camControls.camera, group)) / : perspScale(sceneInfra.camControls.camera, group)) /
sceneInfra._baseUnitMultiplier sceneInfra._baseUnitMultiplier
const input = {
type: 'straight-segment',
from: segment.from,
to: segment.to,
} as const
let update: SegmentUtils['update'] | null = null
if (type === TANGENTIAL_ARC_TO_SEGMENT) { if (type === TANGENTIAL_ARC_TO_SEGMENT) {
return this.updateTangentialArcToSegment({ update = segmentUtils.tangentialArcTo.update
prevSegment: sgPaths[index - 1],
from: segment.from,
to: segment.to,
group: group,
scale: factor,
})
} else if (type === STRAIGHT_SEGMENT) { } else if (type === STRAIGHT_SEGMENT) {
return this.updateStraightSegment({ update = segmentUtils.straight.update
from: segment.from, }
to: segment.to, const callBack =
update &&
!err(update) &&
update({
input,
group, group,
scale: factor, scale: factor,
prevSegment: sgPaths[index - 1],
sceneInfra,
}) })
} else if (type === PROFILE_START) { if (callBack && !err(callBack)) return callBack
if (type === PROFILE_START) {
group.position.set(segment.from[0], segment.from[1], 0) group.position.set(segment.from[0], segment.from[1], 0)
group.scale.set(factor, factor, factor) group.scale.set(factor, factor, factor)
} }
return () => null return () => null
} }
updateTangentialArcToSegment({ /**
prevSegment, * Update the base color of each of the THREEjs meshes
from, * that represent each of the sketch segments, to get the
to, * latest value from `sceneInfra._theme`
group, */
scale = 1, updateSegmentBaseColor(newColor: Themes.Light | Themes.Dark) {
}: { const newColorThreeJs = getThemeColorForThreeJs(newColor)
prevSegment: SketchGroup['value'][number] Object.values(this.activeSegments).forEach((group) => {
from: [number, number] group.userData.baseColor = newColorThreeJs
to: [number, number] group.traverse((child) => {
group: Group if (
scale?: number child instanceof Mesh &&
}): () => SegmentOverlayPayload | null { child.material instanceof MeshBasicMaterial
group.userData.from = from ) {
group.userData.to = to child.material.color.set(newColorThreeJs)
group.userData.prevSegment = prevSegment }
const arrowGroup = group.getObjectByName(ARROWHEAD) as Group
const extraSegmentGroup = group.getObjectByName(EXTRA_SEGMENT_HANDLE)
const previousPoint =
prevSegment?.type === 'TangentialArcTo'
? getTangentPointFromPreviousArc(
prevSegment.center,
prevSegment.ccw,
prevSegment.to
)
: prevSegment.from
const arcInfo = getTangentialArcToInfo({
arcStartPoint: from,
arcEndPoint: to,
tanPreviousPoint: previousPoint,
obtuse: true,
}) })
const pxLength = arcInfo.arcLength / scale
const shouldHideIdle = pxLength < HIDE_SEGMENT_LENGTH
const shouldHideHover = pxLength < HIDE_HOVER_SEGMENT_LENGTH
const hoveredParent =
sceneInfra.hoveredObject &&
getParentGroup(sceneInfra.hoveredObject, [TANGENTIAL_ARC_TO_SEGMENT])
let isHandlesVisible = !shouldHideIdle
if (hoveredParent && hoveredParent?.uuid === group?.uuid) {
isHandlesVisible = !shouldHideHover
}
if (arrowGroup) {
arrowGroup.position.set(to[0], to[1], 0)
const arrowheadAngle =
arcInfo.endAngle + (Math.PI / 2) * (arcInfo.ccw ? 1 : -1)
arrowGroup.quaternion.setFromUnitVectors(
new Vector3(0, 1, 0),
new Vector3(Math.cos(arrowheadAngle), Math.sin(arrowheadAngle), 0)
)
arrowGroup.scale.set(scale, scale, scale)
arrowGroup.visible = isHandlesVisible
}
if (extraSegmentGroup) {
const circumferenceInPx = (2 * Math.PI * arcInfo.radius) / scale
const extraSegmentAngleDelta =
(EXTRA_SEGMENT_OFFSET_PX / circumferenceInPx) * Math.PI * 2
const extraSegmentAngle =
arcInfo.startAngle + (arcInfo.ccw ? 1 : -1) * extraSegmentAngleDelta
const extraSegmentOffset = new Vector2(
Math.cos(extraSegmentAngle) * arcInfo.radius,
Math.sin(extraSegmentAngle) * arcInfo.radius
)
extraSegmentGroup.position.set(
arcInfo.center[0] + extraSegmentOffset.x,
arcInfo.center[1] + extraSegmentOffset.y,
0
)
extraSegmentGroup.scale.set(scale, scale, scale)
extraSegmentGroup.visible = isHandlesVisible
}
const tangentialArcToSegmentBody = group.children.find(
(child) => child.userData.type === TANGENTIAL_ARC_TO_SEGMENT_BODY
) as Mesh
if (tangentialArcToSegmentBody) {
const newGeo = createArcGeometry({ ...arcInfo, scale })
tangentialArcToSegmentBody.geometry = newGeo
}
const tangentialArcToSegmentBodyDashed = group.children.find(
(child) => child.userData.type === TANGENTIAL_ARC_TO__SEGMENT_DASH
) as Mesh
if (tangentialArcToSegmentBodyDashed) {
// consider throttling the whole updateTangentialArcToSegment
// if there are more perf considerations going forward
this.throttledUpdateDashedArcGeo({
...arcInfo,
mesh: tangentialArcToSegmentBodyDashed,
isDashed: true,
scale,
})
}
const angle = normaliseAngle(
(arcInfo.endAngle * 180) / Math.PI + (arcInfo.ccw ? 90 : -90)
)
return () =>
sceneInfra.updateOverlayDetails({
arrowGroup,
group,
isHandlesVisible,
from,
to,
angle,
})
}
throttledUpdateDashedArcGeo = throttle(
(
args: Parameters<typeof createArcGeometry>[0] & {
mesh: Mesh
scale: number
}
) => (args.mesh.geometry = createArcGeometry(args)),
1000 / 30
)
updateStraightSegment({
from,
to,
group,
scale = 1,
}: {
from: [number, number]
to: [number, number]
group: Group
scale?: number
}): () => SegmentOverlayPayload | null {
group.userData.from = from
group.userData.to = to
const shape = new Shape()
shape.moveTo(0, (-SEGMENT_WIDTH_PX / 2) * scale) // The width of the line in px (2.4px in this case)
shape.lineTo(0, (SEGMENT_WIDTH_PX / 2) * scale)
const arrowGroup = group.getObjectByName(ARROWHEAD) as Group
const labelGroup = group.getObjectByName(SEGMENT_LENGTH_LABEL) as Group
const length = Math.sqrt(
Math.pow(to[0] - from[0], 2) + Math.pow(to[1] - from[1], 2)
)
const pxLength = length / scale
const shouldHideIdle = pxLength < HIDE_SEGMENT_LENGTH
const shouldHideHover = pxLength < HIDE_HOVER_SEGMENT_LENGTH
const hoveredParent =
sceneInfra.hoveredObject &&
getParentGroup(sceneInfra.hoveredObject, [STRAIGHT_SEGMENT])
let isHandlesVisible = !shouldHideIdle
if (hoveredParent && hoveredParent?.uuid === group?.uuid) {
isHandlesVisible = !shouldHideHover
}
if (arrowGroup) {
arrowGroup.position.set(to[0], to[1], 0)
const dir = new Vector3()
.subVectors(
new Vector3(to[0], to[1], 0),
new Vector3(from[0], from[1], 0)
)
.normalize()
arrowGroup.quaternion.setFromUnitVectors(new Vector3(0, 1, 0), dir)
arrowGroup.scale.set(scale, scale, scale)
arrowGroup.visible = isHandlesVisible
}
const extraSegmentGroup = group.getObjectByName(EXTRA_SEGMENT_HANDLE)
if (extraSegmentGroup) {
const offsetFromBase = new Vector2(to[0] - from[0], to[1] - from[1])
.normalize()
.multiplyScalar(EXTRA_SEGMENT_OFFSET_PX * scale)
extraSegmentGroup.position.set(
from[0] + offsetFromBase.x,
from[1] + offsetFromBase.y,
0
)
extraSegmentGroup.scale.set(scale, scale, scale)
extraSegmentGroup.visible = isHandlesVisible
}
if (labelGroup) {
const labelWrapper = labelGroup.getObjectByName(
SEGMENT_LENGTH_LABEL_TEXT
) as CSS2DObject
const labelWrapperElem = labelWrapper.element as HTMLDivElement
const label = labelWrapperElem.children[0] as HTMLParagraphElement
label.innerText = `${roundOff(length)}${sceneInfra._baseUnit}`
label.classList.add(SEGMENT_LENGTH_LABEL_TEXT)
const offsetFromMidpoint = new Vector2(to[0] - from[0], to[1] - from[1])
.normalize()
.rotateAround(new Vector2(0, 0), Math.PI / 2)
.multiplyScalar(SEGMENT_LENGTH_LABEL_OFFSET_PX * scale)
label.style.setProperty('--x', `${offsetFromMidpoint.x}px`)
label.style.setProperty('--y', `${offsetFromMidpoint.y}px`)
labelWrapper.position.set(
(from[0] + to[0]) / 2 + offsetFromMidpoint.x,
(from[1] + to[1]) / 2 + offsetFromMidpoint.y,
0
)
labelGroup.visible = isHandlesVisible
}
const straightSegmentBody = group.children.find(
(child) => child.userData.type === STRAIGHT_SEGMENT_BODY
) as Mesh
if (straightSegmentBody) {
const line = new LineCurve3(
new Vector3(from[0], from[1], 0),
new Vector3(to[0], to[1], 0)
)
straightSegmentBody.geometry = new ExtrudeGeometry(shape, {
steps: 2,
bevelEnabled: false,
extrudePath: line,
})
}
const straightSegmentBodyDashed = group.children.find(
(child) => child.userData.type === STRAIGHT_SEGMENT_DASH
) as Mesh
if (straightSegmentBodyDashed) {
straightSegmentBodyDashed.geometry = dashedStraight(
from,
to,
shape,
scale
)
}
return () =>
sceneInfra.updateOverlayDetails({
arrowGroup,
group,
isHandlesVisible,
from,
to,
}) })
} }
removeSketchGrid() { removeSketchGrid() {
@ -1558,27 +1334,30 @@ export class SceneEntities {
} }
const orthoFactor = orthoScale(sceneInfra.camControls.camera) const orthoFactor = orthoScale(sceneInfra.camControls.camera)
const input = {
type: 'straight-segment',
from: parent.userData.from,
to: parent.userData.to,
} as const
const factor = const factor =
(sceneInfra.camControls.camera instanceof OrthographicCamera (sceneInfra.camControls.camera instanceof OrthographicCamera
? orthoFactor ? orthoFactor
: perspScale(sceneInfra.camControls.camera, parent)) / : perspScale(sceneInfra.camControls.camera, parent)) /
sceneInfra._baseUnitMultiplier sceneInfra._baseUnitMultiplier
let update: SegmentUtils['update'] | null = null
if (parent.name === STRAIGHT_SEGMENT) { if (parent.name === STRAIGHT_SEGMENT) {
this.updateStraightSegment({ update = segmentUtils.straight.update
from: parent.userData.from,
to: parent.userData.to,
group: parent,
scale: factor,
})
} else if (parent.name === TANGENTIAL_ARC_TO_SEGMENT) { } else if (parent.name === TANGENTIAL_ARC_TO_SEGMENT) {
this.updateTangentialArcToSegment({ update = segmentUtils.tangentialArcTo.update
}
update &&
update({
prevSegment: parent.userData.prevSegment, prevSegment: parent.userData.prevSegment,
from: parent.userData.from, input,
to: parent.userData.to,
group: parent, group: parent,
scale: factor, scale: factor,
sceneInfra,
}) })
}
return return
} }
editorManager.setHighlightRange([[0, 0]]) editorManager.setHighlightRange([[0, 0]])
@ -1593,28 +1372,31 @@ export class SceneEntities {
if (parent) { if (parent) {
const orthoFactor = orthoScale(sceneInfra.camControls.camera) const orthoFactor = orthoScale(sceneInfra.camControls.camera)
const input = {
type: 'straight-segment',
from: parent.userData.from,
to: parent.userData.to,
} as const
const factor = const factor =
(sceneInfra.camControls.camera instanceof OrthographicCamera (sceneInfra.camControls.camera instanceof OrthographicCamera
? orthoFactor ? orthoFactor
: perspScale(sceneInfra.camControls.camera, parent)) / : perspScale(sceneInfra.camControls.camera, parent)) /
sceneInfra._baseUnitMultiplier sceneInfra._baseUnitMultiplier
let update: SegmentUtils['update'] | null = null
if (parent.name === STRAIGHT_SEGMENT) { if (parent.name === STRAIGHT_SEGMENT) {
this.updateStraightSegment({ update = segmentUtils.straight.update
from: parent.userData.from,
to: parent.userData.to,
group: parent,
scale: factor,
})
} else if (parent.name === TANGENTIAL_ARC_TO_SEGMENT) { } else if (parent.name === TANGENTIAL_ARC_TO_SEGMENT) {
this.updateTangentialArcToSegment({ update = segmentUtils.tangentialArcTo.update
}
update &&
update({
prevSegment: parent.userData.prevSegment, prevSegment: parent.userData.prevSegment,
from: parent.userData.from, input,
to: parent.userData.to,
group: parent, group: parent,
scale: factor, scale: factor,
sceneInfra,
}) })
} }
}
const isSelected = parent?.userData?.isSelected const isSelected = parent?.userData?.isSelected
colorSegment( colorSegment(
selected, selected,

View File

@ -105,10 +105,6 @@ export class SceneInfra {
_baseUnit: BaseUnit = 'mm' _baseUnit: BaseUnit = 'mm'
_baseUnitMultiplier = 1 _baseUnitMultiplier = 1
_theme: Themes = Themes.System _theme: Themes = Themes.System
_streamDimensions: { streamWidth: number; streamHeight: number } = {
streamWidth: 1280,
streamHeight: 720,
}
extraSegmentTexture: Texture extraSegmentTexture: Texture
lastMouseState: MouseState = { type: 'idle' } lastMouseState: MouseState = { type: 'idle' }
onDragStartCallback: (arg: OnDragCallbackArgs) => void = () => {} onDragStartCallback: (arg: OnDragCallbackArgs) => void = () => {}

View File

@ -26,6 +26,7 @@ import { PathToNode, SketchGroup, getTangentialArcToInfo } from 'lang/wasm'
import { import {
EXTRA_SEGMENT_HANDLE, EXTRA_SEGMENT_HANDLE,
EXTRA_SEGMENT_OFFSET_PX, EXTRA_SEGMENT_OFFSET_PX,
HIDE_HOVER_SEGMENT_LENGTH,
HIDE_SEGMENT_LENGTH, HIDE_SEGMENT_LENGTH,
PROFILE_START, PROFILE_START,
SEGMENT_WIDTH_PX, SEGMENT_WIDTH_PX,
@ -35,18 +36,448 @@ import {
TANGENTIAL_ARC_TO_SEGMENT, TANGENTIAL_ARC_TO_SEGMENT,
TANGENTIAL_ARC_TO_SEGMENT_BODY, TANGENTIAL_ARC_TO_SEGMENT_BODY,
TANGENTIAL_ARC_TO__SEGMENT_DASH, TANGENTIAL_ARC_TO__SEGMENT_DASH,
getParentGroup,
} from './sceneEntities' } from './sceneEntities'
import { getTangentPointFromPreviousArc } from 'lib/utils2d' import { getTangentPointFromPreviousArc } from 'lib/utils2d'
import { import {
ARROWHEAD, ARROWHEAD,
SceneInfra,
SEGMENT_LENGTH_LABEL, SEGMENT_LENGTH_LABEL,
SEGMENT_LENGTH_LABEL_OFFSET_PX, SEGMENT_LENGTH_LABEL_OFFSET_PX,
SEGMENT_LENGTH_LABEL_TEXT, SEGMENT_LENGTH_LABEL_TEXT,
} from './sceneInfra' } from './sceneInfra'
import { Themes, getThemeColorForThreeJs } from 'lib/theme' import { Themes, getThemeColorForThreeJs } from 'lib/theme'
import { roundOff } from 'lib/utils' import { normaliseAngle, roundOff } from 'lib/utils'
import { SegmentOverlayPayload } from 'machines/modelingMachine'
import { SegmentInputs } from 'lang/std/stdTypes'
import { err } from 'lib/trap'
export function profileStart({ interface CreateSegmentArgs {
input: SegmentInputs
prevSegment: SketchGroup['value'][number]
id: string
pathToNode: PathToNode
isDraftSegment?: boolean
scale?: number
callExpName: string
texture: Texture
theme: Themes
isSelected?: boolean
sceneInfra: SceneInfra
}
interface UpdateSegmentArgs {
input: SegmentInputs
prevSegment: SketchGroup['value'][number]
group: Group
sceneInfra: SceneInfra
scale?: number
}
interface CreateSegmentResult {
group: Group
updateOverlaysCallback: () => SegmentOverlayPayload | null
}
export interface SegmentUtils {
/**
* the init is responsible for adding all of the correct entities to the group with important details like `mesh.name = ...`
* as these act like handles later
*
* It's **Not** responsible for doing all calculations to size and position the entities as this would be duplicated in the update function
* Which should instead be called at the end of the init function
*/
init: (args: CreateSegmentArgs) => CreateSegmentResult | Error
/**
* The update function is responsible for updating the group with the correct size and position of the entities
* It should be called at the end of the init function and return a callback that can be used to update the overlay
*
* It returns a callback for updating the overlays, this is so the overlays do not have to update at the same pace threeJs does
* This is useful for performance reasons
*/
update: (
args: UpdateSegmentArgs
) => CreateSegmentResult['updateOverlaysCallback'] | Error
}
class StraightSegment implements SegmentUtils {
init: SegmentUtils['init'] = ({
input,
id,
pathToNode,
isDraftSegment,
scale = 1,
callExpName,
texture,
theme,
isSelected = false,
sceneInfra,
prevSegment,
}) => {
if (input.type !== 'straight-segment')
return new Error('Invalid segment type')
const { from, to } = input
const baseColor =
callExpName === 'close' ? 0x444444 : getThemeColorForThreeJs(theme)
const color = isSelected ? 0x0000ff : baseColor
const meshType = isDraftSegment
? STRAIGHT_SEGMENT_DASH
: STRAIGHT_SEGMENT_BODY
const segmentGroup = new Group()
const shape = new Shape()
const line = new LineCurve3(
new Vector3(from[0], from[1], 0),
new Vector3(to[0], to[1], 0)
)
const geometry = new ExtrudeGeometry(shape, {
steps: 2,
bevelEnabled: false,
extrudePath: line,
})
const body = new MeshBasicMaterial({ color })
const mesh = new Mesh(geometry, body)
mesh.userData.type = meshType
mesh.name = meshType
segmentGroup.name = STRAIGHT_SEGMENT
segmentGroup.userData = {
type: STRAIGHT_SEGMENT,
id,
from,
to,
pathToNode,
isSelected,
callExpName,
baseColor,
}
// All segment types get an extra segment handle,
// Which is a little plus sign that appears at the origin of the segment
// and can be dragged to insert a new segment
const extraSegmentGroup = createExtraSegmentHandle(scale, texture, theme)
// Segment decorators that only apply to non-close segments
if (callExpName !== 'close') {
// an arrowhead that appears at the end of the segment
const arrowGroup = createArrowhead(scale, theme, color)
// A length indicator that appears at the midpoint of the segment
const lengthIndicatorGroup = createLengthIndicator({
from,
to,
scale,
})
segmentGroup.add(arrowGroup)
segmentGroup.add(lengthIndicatorGroup)
}
segmentGroup.add(mesh, extraSegmentGroup)
let updateOverlaysCallback = this.update({
prevSegment,
input,
group: segmentGroup,
scale,
sceneInfra,
})
if (err(updateOverlaysCallback)) return updateOverlaysCallback
return {
group: segmentGroup,
updateOverlaysCallback,
}
}
update: SegmentUtils['update'] = ({
input,
group,
scale = 1,
sceneInfra,
}) => {
if (input.type !== 'straight-segment')
return new Error('Invalid segment type')
const { from, to } = input
group.userData.from = from
group.userData.to = to
const shape = new Shape()
shape.moveTo(0, (-SEGMENT_WIDTH_PX / 2) * scale) // The width of the line in px (2.4px in this case)
shape.lineTo(0, (SEGMENT_WIDTH_PX / 2) * scale)
const arrowGroup = group.getObjectByName(ARROWHEAD) as Group
const labelGroup = group.getObjectByName(SEGMENT_LENGTH_LABEL) as Group
const length = Math.sqrt(
Math.pow(to[0] - from[0], 2) + Math.pow(to[1] - from[1], 2)
)
const pxLength = length / scale
const shouldHideIdle = pxLength < HIDE_SEGMENT_LENGTH
const shouldHideHover = pxLength < HIDE_HOVER_SEGMENT_LENGTH
const hoveredParent =
sceneInfra.hoveredObject &&
getParentGroup(sceneInfra.hoveredObject, [STRAIGHT_SEGMENT])
let isHandlesVisible = !shouldHideIdle
if (hoveredParent && hoveredParent?.uuid === group?.uuid) {
isHandlesVisible = !shouldHideHover
}
if (arrowGroup) {
arrowGroup.position.set(to[0], to[1], 0)
const dir = new Vector3()
.subVectors(
new Vector3(to[0], to[1], 0),
new Vector3(from[0], from[1], 0)
)
.normalize()
arrowGroup.quaternion.setFromUnitVectors(new Vector3(0, 1, 0), dir)
arrowGroup.scale.set(scale, scale, scale)
arrowGroup.visible = isHandlesVisible
}
const extraSegmentGroup = group.getObjectByName(EXTRA_SEGMENT_HANDLE)
if (extraSegmentGroup) {
const offsetFromBase = new Vector2(to[0] - from[0], to[1] - from[1])
.normalize()
.multiplyScalar(EXTRA_SEGMENT_OFFSET_PX * scale)
extraSegmentGroup.position.set(
from[0] + offsetFromBase.x,
from[1] + offsetFromBase.y,
0
)
extraSegmentGroup.scale.set(scale, scale, scale)
extraSegmentGroup.visible = isHandlesVisible
}
if (labelGroup) {
const labelWrapper = labelGroup.getObjectByName(
SEGMENT_LENGTH_LABEL_TEXT
) as CSS2DObject
const labelWrapperElem = labelWrapper.element as HTMLDivElement
const label = labelWrapperElem.children[0] as HTMLParagraphElement
label.innerText = `${roundOff(length)}`
label.classList.add(SEGMENT_LENGTH_LABEL_TEXT)
const slope = (to[1] - from[1]) / (to[0] - from[0])
let slopeAngle = ((Math.atan(slope) * 180) / Math.PI) * -1
label.style.setProperty('--degree', `${slopeAngle}deg`)
label.style.setProperty('--x', `0px`)
label.style.setProperty('--y', `0px`)
labelWrapper.position.set((from[0] + to[0]) / 2, (from[1] + to[1]) / 2, 0)
labelGroup.visible = isHandlesVisible
}
const straightSegmentBody = group.children.find(
(child) => child.userData.type === STRAIGHT_SEGMENT_BODY
) as Mesh
if (straightSegmentBody) {
const line = new LineCurve3(
new Vector3(from[0], from[1], 0),
new Vector3(to[0], to[1], 0)
)
straightSegmentBody.geometry = new ExtrudeGeometry(shape, {
steps: 2,
bevelEnabled: false,
extrudePath: line,
})
}
const straightSegmentBodyDashed = group.children.find(
(child) => child.userData.type === STRAIGHT_SEGMENT_DASH
) as Mesh
if (straightSegmentBodyDashed) {
straightSegmentBodyDashed.geometry = dashedStraight(
from,
to,
shape,
scale
)
}
return () =>
sceneInfra.updateOverlayDetails({
arrowGroup,
group,
isHandlesVisible,
from,
to,
})
}
}
class TangentialArcToSegment implements SegmentUtils {
init: SegmentUtils['init'] = ({
prevSegment,
input,
id,
pathToNode,
isDraftSegment,
scale = 1,
texture,
theme,
isSelected,
sceneInfra,
}) => {
if (input.type !== 'straight-segment')
return new Error('Invalid segment type')
const { from, to } = input
const meshName = isDraftSegment
? TANGENTIAL_ARC_TO__SEGMENT_DASH
: TANGENTIAL_ARC_TO_SEGMENT_BODY
const group = new Group()
const geometry = createArcGeometry({
center: [0, 0],
radius: 1,
startAngle: 0,
endAngle: 1,
ccw: true,
isDashed: isDraftSegment,
scale,
})
const baseColor = getThemeColorForThreeJs(theme)
const color = isSelected ? 0x0000ff : baseColor
const body = new MeshBasicMaterial({ color })
const mesh = new Mesh(geometry, body)
const arrowGroup = createArrowhead(scale, theme, color)
const extraSegmentGroup = createExtraSegmentHandle(scale, texture, theme)
group.name = TANGENTIAL_ARC_TO_SEGMENT
mesh.userData.type = meshName
mesh.name = meshName
group.userData = {
type: TANGENTIAL_ARC_TO_SEGMENT,
id,
from,
to,
prevSegment,
pathToNode,
isSelected,
baseColor,
}
group.add(mesh, arrowGroup, extraSegmentGroup)
const updateOverlaysCallback = this.update({
prevSegment,
input,
group,
scale,
sceneInfra,
})
if (err(updateOverlaysCallback)) return updateOverlaysCallback
return {
group,
updateOverlaysCallback,
}
}
update: SegmentUtils['update'] = ({
prevSegment,
input,
group,
scale = 1,
sceneInfra,
}) => {
if (input.type !== 'straight-segment')
return new Error('Invalid segment type')
const { from, to } = input
group.userData.from = from
group.userData.to = to
group.userData.prevSegment = prevSegment
const arrowGroup = group.getObjectByName(ARROWHEAD) as Group
const extraSegmentGroup = group.getObjectByName(EXTRA_SEGMENT_HANDLE)
const previousPoint =
prevSegment?.type === 'TangentialArcTo'
? getTangentPointFromPreviousArc(
prevSegment.center,
prevSegment.ccw,
prevSegment.to
)
: prevSegment.from
const arcInfo = getTangentialArcToInfo({
arcStartPoint: from,
arcEndPoint: to,
tanPreviousPoint: previousPoint,
obtuse: true,
})
const pxLength = arcInfo.arcLength / scale
const shouldHideIdle = pxLength < HIDE_SEGMENT_LENGTH
const shouldHideHover = pxLength < HIDE_HOVER_SEGMENT_LENGTH
const hoveredParent =
sceneInfra?.hoveredObject &&
getParentGroup(sceneInfra.hoveredObject, [TANGENTIAL_ARC_TO_SEGMENT])
let isHandlesVisible = !shouldHideIdle
if (hoveredParent && hoveredParent?.uuid === group?.uuid) {
isHandlesVisible = !shouldHideHover
}
if (arrowGroup) {
arrowGroup.position.set(to[0], to[1], 0)
const arrowheadAngle =
arcInfo.endAngle + (Math.PI / 2) * (arcInfo.ccw ? 1 : -1)
arrowGroup.quaternion.setFromUnitVectors(
new Vector3(0, 1, 0),
new Vector3(Math.cos(arrowheadAngle), Math.sin(arrowheadAngle), 0)
)
arrowGroup.scale.set(scale, scale, scale)
arrowGroup.visible = isHandlesVisible
}
if (extraSegmentGroup) {
const circumferenceInPx = (2 * Math.PI * arcInfo.radius) / scale
const extraSegmentAngleDelta =
(EXTRA_SEGMENT_OFFSET_PX / circumferenceInPx) * Math.PI * 2
const extraSegmentAngle =
arcInfo.startAngle + (arcInfo.ccw ? 1 : -1) * extraSegmentAngleDelta
const extraSegmentOffset = new Vector2(
Math.cos(extraSegmentAngle) * arcInfo.radius,
Math.sin(extraSegmentAngle) * arcInfo.radius
)
extraSegmentGroup.position.set(
arcInfo.center[0] + extraSegmentOffset.x,
arcInfo.center[1] + extraSegmentOffset.y,
0
)
extraSegmentGroup.scale.set(scale, scale, scale)
extraSegmentGroup.visible = isHandlesVisible
}
const tangentialArcToSegmentBody = group.children.find(
(child) => child.userData.type === TANGENTIAL_ARC_TO_SEGMENT_BODY
) as Mesh
if (tangentialArcToSegmentBody) {
const newGeo = createArcGeometry({ ...arcInfo, scale })
tangentialArcToSegmentBody.geometry = newGeo
}
const tangentialArcToSegmentBodyDashed = group.getObjectByName(
TANGENTIAL_ARC_TO__SEGMENT_DASH
)
if (tangentialArcToSegmentBodyDashed instanceof Mesh) {
tangentialArcToSegmentBodyDashed.geometry = createArcGeometry({
...arcInfo,
isDashed: true,
scale,
})
}
const angle = normaliseAngle(
(arcInfo.endAngle * 180) / Math.PI + (arcInfo.ccw ? 90 : -90)
)
return () =>
sceneInfra.updateOverlayDetails({
arrowGroup,
group,
isHandlesVisible,
from,
to,
angle,
})
}
}
export function createProfileStartHandle({
from, from,
id, id,
pathToNode, pathToNode,
@ -85,127 +516,6 @@ export function profileStart({
return group return group
} }
export function straightSegment({
from,
to,
id,
pathToNode,
isDraftSegment,
scale = 1,
callExpName,
texture,
theme,
isSelected = false,
}: {
from: Coords2d
to: Coords2d
id: string
pathToNode: PathToNode
isDraftSegment?: boolean
scale?: number
callExpName: string
texture: Texture
theme: Themes
isSelected?: boolean
}): Group {
const segmentGroup = new Group()
const shape = new Shape()
shape.moveTo(0, (-SEGMENT_WIDTH_PX / 2) * scale)
shape.lineTo(0, (SEGMENT_WIDTH_PX / 2) * scale)
let geometry
if (isDraftSegment) {
geometry = dashedStraight(from, to, shape, scale)
} else {
const line = new LineCurve3(
new Vector3(from[0], from[1], 0),
new Vector3(to[0], to[1], 0)
)
geometry = new ExtrudeGeometry(shape, {
steps: 2,
bevelEnabled: false,
extrudePath: line,
})
}
const baseColor =
callExpName === 'close' ? 0x444444 : getThemeColorForThreeJs(theme)
const color = isSelected ? 0x0000ff : baseColor
const body = new MeshBasicMaterial({ color })
const mesh = new Mesh(geometry, body)
mesh.userData.type = isDraftSegment
? STRAIGHT_SEGMENT_DASH
: STRAIGHT_SEGMENT_BODY
mesh.name = STRAIGHT_SEGMENT_BODY
segmentGroup.userData = {
type: STRAIGHT_SEGMENT,
id,
from,
to,
pathToNode,
isSelected,
callExpName,
baseColor,
}
segmentGroup.name = STRAIGHT_SEGMENT
segmentGroup.add(mesh)
const length = Math.sqrt(
Math.pow(to[0] - from[0], 2) + Math.pow(to[1] - from[1], 2)
)
const pxLength = length / scale
const shouldHide = pxLength < HIDE_SEGMENT_LENGTH
// All segment types get an extra segment handle,
// Which is a little plus sign that appears at the origin of the segment
// and can be dragged to insert a new segment
const extraSegmentGroup = createExtraSegmentHandle(scale, texture, theme)
const directionVector = new Vector2(
to[0] - from[0],
to[1] - from[1]
).normalize()
const offsetFromBase = directionVector.multiplyScalar(
EXTRA_SEGMENT_OFFSET_PX * scale
)
extraSegmentGroup.position.set(
from[0] + offsetFromBase.x,
from[1] + offsetFromBase.y,
0
)
extraSegmentGroup.visible = !shouldHide
segmentGroup.add(extraSegmentGroup)
// Segment decorators that only apply to non-close segments
if (callExpName !== 'close') {
// an arrowhead that appears at the end of the segment
const arrowGroup = createArrowhead(scale, theme, color)
arrowGroup.position.set(to[0], to[1], 0)
const dir = new Vector3()
.subVectors(
new Vector3(to[0], to[1], 0),
new Vector3(from[0], from[1], 0)
)
.normalize()
arrowGroup.quaternion.setFromUnitVectors(new Vector3(0, 1, 0), dir)
arrowGroup.visible = !shouldHide
segmentGroup.add(arrowGroup)
// A length indicator that appears at the midpoint of the segment
const lengthIndicatorGroup = createLengthIndicator({
from,
to,
scale,
length,
})
segmentGroup.add(lengthIndicatorGroup)
}
return segmentGroup
}
function createArrowhead(scale = 1, theme: Themes, color?: number): Group { function createArrowhead(scale = 1, theme: Themes, color?: number): Group {
const baseColor = getThemeColorForThreeJs(theme) const baseColor = getThemeColorForThreeJs(theme)
const arrowMaterial = new MeshBasicMaterial({ const arrowMaterial = new MeshBasicMaterial({
@ -267,12 +577,12 @@ function createLengthIndicator({
from, from,
to, to,
scale, scale,
length, length = 0.1,
}: { }: {
from: Coords2d from: Coords2d
to: Coords2d to: Coords2d
scale: number scale: number
length: number length?: number
}) { }) {
const lengthIndicatorGroup = new Group() const lengthIndicatorGroup = new Group()
lengthIndicatorGroup.name = SEGMENT_LENGTH_LABEL lengthIndicatorGroup.name = SEGMENT_LENGTH_LABEL
@ -300,111 +610,6 @@ function createLengthIndicator({
return lengthIndicatorGroup return lengthIndicatorGroup
} }
export function tangentialArcToSegment({
prevSegment,
from,
to,
id,
pathToNode,
isDraftSegment,
scale = 1,
texture,
theme,
isSelected,
}: {
prevSegment: SketchGroup['value'][number]
from: Coords2d
to: Coords2d
id: string
pathToNode: PathToNode
isDraftSegment?: boolean
scale?: number
texture: Texture
theme: Themes
isSelected?: boolean
}): Group {
const group = new Group()
const previousPoint =
prevSegment?.type === 'TangentialArcTo'
? getTangentPointFromPreviousArc(
prevSegment.center,
prevSegment.ccw,
prevSegment.to
)
: prevSegment.from
const { center, radius, startAngle, endAngle, ccw, arcLength } =
getTangentialArcToInfo({
arcStartPoint: from,
arcEndPoint: to,
tanPreviousPoint: previousPoint,
obtuse: true,
})
const geometry = createArcGeometry({
center,
radius,
startAngle,
endAngle,
ccw,
isDashed: isDraftSegment,
scale,
})
const baseColor = getThemeColorForThreeJs(theme)
const color = isSelected ? 0x0000ff : baseColor
const body = new MeshBasicMaterial({ color })
const mesh = new Mesh(geometry, body)
mesh.userData.type = isDraftSegment
? TANGENTIAL_ARC_TO__SEGMENT_DASH
: TANGENTIAL_ARC_TO_SEGMENT_BODY
group.userData = {
type: TANGENTIAL_ARC_TO_SEGMENT,
id,
from,
to,
prevSegment,
pathToNode,
isSelected,
baseColor,
}
group.name = TANGENTIAL_ARC_TO_SEGMENT
const arrowGroup = createArrowhead(scale, theme, color)
arrowGroup.position.set(to[0], to[1], 0)
const arrowheadAngle = endAngle + (Math.PI / 2) * (ccw ? 1 : -1)
arrowGroup.quaternion.setFromUnitVectors(
new Vector3(0, 1, 0),
new Vector3(Math.cos(arrowheadAngle), Math.sin(arrowheadAngle), 0)
)
const pxLength = arcLength / scale
const shouldHide = pxLength < HIDE_SEGMENT_LENGTH
arrowGroup.visible = !shouldHide
const extraSegmentGroup = createExtraSegmentHandle(scale, texture, theme)
const circumferenceInPx = (2 * Math.PI * radius) / scale
const extraSegmentAngleDelta =
(EXTRA_SEGMENT_OFFSET_PX / circumferenceInPx) * Math.PI * 2
const extraSegmentAngle = startAngle + (ccw ? 1 : -1) * extraSegmentAngleDelta
const extraSegmentOffset = new Vector2(
Math.cos(extraSegmentAngle) * radius,
Math.sin(extraSegmentAngle) * radius
)
extraSegmentGroup.position.set(
center[0] + extraSegmentOffset.x,
center[1] + extraSegmentOffset.y,
0
)
extraSegmentGroup.visible = !shouldHide
group.add(mesh, arrowGroup, extraSegmentGroup)
return group
}
export function createArcGeometry({ export function createArcGeometry({
center, center,
radius, radius,
@ -579,3 +784,8 @@ export function dashedStraight(
geo.userData.type = 'dashed' geo.userData.type = 'dashed'
return geo return geo
} }
export const segmentUtils = {
straight: new StraightSegment(),
tangentialArcTo: new TangentialArcToSegment(),
} as const

View File

@ -29,8 +29,8 @@ export const ActionIcon = ({
size = 'md', size = 'md',
children, children,
}: ActionIconProps) => { }: ActionIconProps) => {
const computedIconClassName = `h-auto text-inherit dark:text-current !group-disabled:text-chalkboard-60 !group-disabled:text-chalkboard-60 ${iconClassName}` const computedIconClassName = `h-auto text-inherit dark:text-current group-disabled:text-chalkboard-60 group-disabled:text-chalkboard-60 ${iconClassName}`
const computedBgClassName = `bg-chalkboard-20 dark:bg-chalkboard-80 !group-disabled:bg-chalkboard-30 !dark:group-disabled:bg-chalkboard-80 ${bgClassName}` const computedBgClassName = `bg-chalkboard-20 dark:bg-chalkboard-80 group-disabled:bg-chalkboard-30 dark:group-disabled:bg-chalkboard-80 ${bgClassName}`
return ( return (
<div <div

View File

@ -151,6 +151,7 @@ export function useCalc({
}) })
if (trap(error)) return if (trap(error)) return
} }
// eslint-disable-next-line @typescript-eslint/no-floating-promises
executeAst({ executeAst({
ast, ast,
engineCommandManager, engineCommandManager,

View File

@ -2,6 +2,7 @@ import { useState, useEffect } from 'react'
import { EngineCommandManagerEvents } from 'lang/std/engineConnection' import { EngineCommandManagerEvents } from 'lang/std/engineConnection'
import { engineCommandManager, sceneInfra } from 'lib/singletons' import { engineCommandManager, sceneInfra } from 'lib/singletons'
import { throttle, isReducedMotion } from 'lib/utils' import { throttle, isReducedMotion } from 'lib/utils'
import { reportRejection } from 'lib/trap'
const updateDollyZoom = throttle( const updateDollyZoom = throttle(
(newFov: number) => sceneInfra.camControls.dollyZoom(newFov), (newFov: number) => sceneInfra.camControls.dollyZoom(newFov),
@ -16,8 +17,8 @@ export const CamToggle = () => {
useEffect(() => { useEffect(() => {
engineCommandManager.addEventListener( engineCommandManager.addEventListener(
EngineCommandManagerEvents.SceneReady, EngineCommandManagerEvents.SceneReady,
async () => { () => {
sceneInfra.camControls.dollyZoom(fov) sceneInfra.camControls.dollyZoom(fov).catch(reportRejection)
} }
) )
}, []) }, [])
@ -26,11 +27,11 @@ export const CamToggle = () => {
if (isPerspective) { if (isPerspective) {
isReducedMotion() isReducedMotion()
? sceneInfra.camControls.useOrthographicCamera() ? sceneInfra.camControls.useOrthographicCamera()
: sceneInfra.camControls.animateToOrthographic() : sceneInfra.camControls.animateToOrthographic().catch(reportRejection)
} else { } else {
isReducedMotion() isReducedMotion()
? sceneInfra.camControls.usePerspectiveCamera() ? sceneInfra.camControls.usePerspectiveCamera().catch(reportRejection)
: sceneInfra.camControls.animateToPerspective() : sceneInfra.camControls.animateToPerspective().catch(reportRejection)
} }
setIsPerspective(!isPerspective) setIsPerspective(!isPerspective)
} }

View File

@ -71,6 +71,17 @@ function CommandArgOptionInput({
inputRef.current?.focus() inputRef.current?.focus()
inputRef.current?.select() inputRef.current?.select()
}, [inputRef]) }, [inputRef])
useEffect(() => {
// work around to make sure the user doesn't have to press the down arrow key to focus the first option
// instead this makes it move from the first hit
const downArrowEvent = new KeyboardEvent('keydown', {
key: 'ArrowDown',
keyCode: 40,
which: 40,
bubbles: true,
})
inputRef?.current?.dispatchEvent(downArrowEvent)
}, [])
// Filter the options based on the query, // Filter the options based on the query,
// resetting the query when the options change // resetting the query when the options change

View File

@ -1,53 +1,43 @@
import { useMachine } from '@xstate/react' import { createActorContext } from '@xstate/react'
import { editorManager } from 'lib/singletons' import { editorManager } from 'lib/singletons'
import { commandBarMachine } from 'machines/commandBarMachine' import { commandBarMachine } from 'machines/commandBarMachine'
import { createContext, useEffect } from 'react' import { useEffect } from 'react'
import { EventFrom, StateFrom } from 'xstate'
type CommandsContextType = { export const CommandsContext = createActorContext(
commandBarState: StateFrom<typeof commandBarMachine> commandBarMachine.provide({
commandBarSend: (event: EventFrom<typeof commandBarMachine>) => void
}
export const CommandsContext = createContext<CommandsContextType>({
commandBarState: commandBarMachine.initialState,
commandBarSend: () => {},
})
export const CommandBarProvider = ({
children,
}: {
children: React.ReactNode
}) => {
const [commandBarState, commandBarSend] = useMachine(commandBarMachine, {
devTools: true,
guards: { guards: {
'Command has no arguments': (context, _event) => { 'Command has no arguments': ({ context }) => {
return ( return (
!context.selectedCommand?.args || !context.selectedCommand?.args ||
Object.keys(context.selectedCommand?.args).length === 0 Object.keys(context.selectedCommand?.args).length === 0
) )
}, },
'All arguments are skippable': (context, _event) => { 'All arguments are skippable': ({ context }) => {
return Object.values(context.selectedCommand!.args!).every( return Object.values(context.selectedCommand!.args!).every(
(argConfig) => argConfig.skip (argConfig) => argConfig.skip
) )
}, },
}, },
}) })
)
useEffect(() => { export const CommandBarProvider = ({
editorManager.setCommandBarSend(commandBarSend) children,
}) }: {
children: React.ReactNode
}) => {
return ( return (
<CommandsContext.Provider <CommandsContext.Provider>
value={{ <CommandBarProviderInner>{children}</CommandBarProviderInner>
commandBarState,
commandBarSend,
}}
>
{children}
</CommandsContext.Provider> </CommandsContext.Provider>
) )
} }
function CommandBarProviderInner({ children }: { children: React.ReactNode }) {
const commandBarActor = CommandsContext.useActorRef()
useEffect(() => {
editorManager.setCommandBarSend(commandBarActor.send)
})
return children
}

View File

@ -52,7 +52,7 @@ function CommandBarReview({ stepBack }: { stepBack: () => void }) {
e.preventDefault() e.preventDefault()
commandBarSend({ commandBarSend({
type: 'Submit command', type: 'Submit command',
data: argumentsToSubmit, output: argumentsToSubmit,
}) })
} }

View File

@ -9,7 +9,7 @@ import {
getSelectionTypeDisplayText, getSelectionTypeDisplayText,
} from 'lib/selections' } from 'lib/selections'
import { modelingMachine } from 'machines/modelingMachine' import { modelingMachine } from 'machines/modelingMachine'
import { useCallback, useEffect, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { StateFrom } from 'xstate' import { StateFrom } from 'xstate'
const semanticEntityNames: { [key: string]: Array<Selection['type']> } = { const semanticEntityNames: { [key: string]: Array<Selection['type']> } = {
@ -48,15 +48,15 @@ function CommandBarSelectionInput({
const { commandBarState, commandBarSend } = useCommandsContext() const { commandBarState, commandBarSend } = useCommandsContext()
const [hasSubmitted, setHasSubmitted] = useState(false) const [hasSubmitted, setHasSubmitted] = useState(false)
const selection = useSelector(arg.machineActor, selectionSelector) const selection = useSelector(arg.machineActor, selectionSelector)
const initSelectionsByType = useCallback(() => { const selectionsByType = useMemo(() => {
const selectionRangeEnd = selection.codeBasedSelections[0]?.range[1] const selectionRangeEnd = selection.codeBasedSelections[0]?.range[1]
return !selectionRangeEnd || selectionRangeEnd === code.length return !selectionRangeEnd || selectionRangeEnd === code.length
? 'none' ? 'none'
: getSelectionType(selection) : getSelectionType(selection)
}, [selection, code]) }, [selection, code])
const selectionsByType = initSelectionsByType() const canSubmitSelection = useMemo<boolean>(
const [canSubmitSelection, setCanSubmitSelection] = useState<boolean>( () => canSubmitSelectionArg(selectionsByType, arg),
canSubmitSelectionArg(selectionsByType, arg) [selectionsByType]
) )
useEffect(() => { useEffect(() => {
@ -66,26 +66,18 @@ function CommandBarSelectionInput({
// Fast-forward through this arg if it's marked as skippable // Fast-forward through this arg if it's marked as skippable
// and we have a valid selection already // and we have a valid selection already
useEffect(() => { useEffect(() => {
console.log('selection input effect', {
selectionsByType,
canSubmitSelection,
arg,
})
setCanSubmitSelection(canSubmitSelectionArg(selectionsByType, arg))
const argValue = commandBarState.context.argumentsToSubmit[arg.name] const argValue = commandBarState.context.argumentsToSubmit[arg.name]
if (canSubmitSelection && arg.skip && argValue === undefined) { if (canSubmitSelection && arg.skip && argValue === undefined) {
handleSubmit({ handleSubmit()
preventDefault: () => {},
} as React.FormEvent<HTMLFormElement>)
} }
}, [selectionsByType, arg]) }, [canSubmitSelection])
function handleChange() { function handleChange() {
inputRef.current?.focus() inputRef.current?.focus()
} }
function handleSubmit(e: React.FormEvent<HTMLFormElement>) { function handleSubmit(e?: React.FormEvent<HTMLFormElement>) {
e.preventDefault() e?.preventDefault()
if (!canSubmitSelection) { if (!canSubmitSelection) {
setHasSubmitted(true) setHasSubmitted(true)

View File

@ -11,6 +11,7 @@ export function CommandBarOpenButton() {
<button <button
className="group rounded-full flex items-center justify-center gap-2 px-2 py-1 bg-primary/10 dark:bg-chalkboard-90 dark:backdrop-blur-sm border-primary hover:border-primary dark:border-chalkboard-50 dark:hover:border-inherit text-primary dark:text-inherit" className="group rounded-full flex items-center justify-center gap-2 px-2 py-1 bg-primary/10 dark:bg-chalkboard-90 dark:backdrop-blur-sm border-primary hover:border-primary dark:border-chalkboard-50 dark:hover:border-inherit text-primary dark:text-inherit"
onClick={() => commandBarSend({ type: 'Open' })} onClick={() => commandBarSend({ type: 'Open' })}
data-testid="command-bar-open-button"
> >
<span>Commands</span> <span>Commands</span>
<kbd className="bg-primary/10 dark:bg-chalkboard-80 dark:group-hover:bg-primary font-mono rounded-sm dark:text-inherit inline-block px-1 border-primary dark:border-chalkboard-90"> <kbd className="bg-primary/10 dark:bg-chalkboard-80 dark:group-hover:bg-primary font-mono rounded-sm dark:text-inherit inline-block px-1 border-primary dark:border-chalkboard-90">

View File

@ -681,6 +681,21 @@ const CustomIconMap = {
/> />
</svg> </svg>
), ),
logs: (
<svg
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-label="logs"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M6.5 15C6.5 14.1716 5.82843 13.5 5 13.5C4.17157 13.5 3.5 14.1716 3.5 15C3.5 15.8284 4.17157 16.5 5 16.5C5.82843 16.5 6.5 15.8284 6.5 15ZM6.5 10C6.5 9.17157 5.82843 8.5 5 8.5C4.17157 8.5 3.5 9.17157 3.5 10C3.5 10.8284 4.17157 11.5 5 11.5C5.82843 11.5 6.5 10.8284 6.5 10ZM5 3.5C5.82843 3.5 6.5 4.17157 6.5 5C6.5 5.82843 5.82843 6.5 5 6.5C4.17157 6.5 3.5 5.82843 3.5 5C3.5 4.17157 4.17157 3.5 5 3.5ZM8.5 5.5H16.5V4.5H8.5V5.5ZM8.5 10.5H16.5V9.5H8.5V10.5ZM16.5 15.5H8.5V14.5H16.5V15.5Z"
fill="currentColor"
/>
</svg>
),
'make-variable': ( 'make-variable': (
<svg <svg
viewBox="0 0 20 20" viewBox="0 0 20 20"

View File

@ -1,5 +1,6 @@
import { CommandLog } from 'lang/std/engineConnection' import { CommandLog } from 'lang/std/engineConnection'
import { engineCommandManager } from 'lib/singletons' import { engineCommandManager } from 'lib/singletons'
import { reportRejection } from 'lib/trap'
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
export function useEngineCommands(): [CommandLog[], () => void] { export function useEngineCommands(): [CommandLog[], () => void] {
@ -77,9 +78,11 @@ export const EngineCommands = () => {
/> />
<button <button
data-testid="custom-cmd-send-button" data-testid="custom-cmd-send-button"
onClick={() => onClick={() => {
engineCommandManager.sendSceneCommand(JSON.parse(customCmd)) engineCommandManager
} .sendSceneCommand(JSON.parse(customCmd))
.catch(reportRejection)
}}
> >
Send custom command Send custom command
</button> </button>

View File

@ -5,13 +5,12 @@ import { PATHS } from 'lib/paths'
import React, { createContext } from 'react' import React, { createContext } from 'react'
import { toast } from 'react-hot-toast' import { toast } from 'react-hot-toast'
import { import {
Actor,
AnyStateMachine, AnyStateMachine,
ContextFrom, ContextFrom,
EventFrom,
InterpreterFrom,
Prop, Prop,
StateFrom, StateFrom,
assign, fromPromise,
} from 'xstate' } from 'xstate'
import { useCommandsContext } from 'hooks/useCommandsContext' import { useCommandsContext } from 'hooks/useCommandsContext'
import { fileMachine } from 'machines/fileMachine' import { fileMachine } from 'machines/fileMachine'
@ -27,7 +26,7 @@ import { getNextDirName, getNextFileName } from 'lib/desktopFS'
type MachineContext<T extends AnyStateMachine> = { type MachineContext<T extends AnyStateMachine> = {
state: StateFrom<T> state: StateFrom<T>
context: ContextFrom<T> context: ContextFrom<T>
send: Prop<InterpreterFrom<T>, 'send'> send: Prop<Actor<T>, 'send'>
} }
export const FileContext = createContext( export const FileContext = createContext(
@ -43,70 +42,68 @@ export const FileMachineProvider = ({
const { commandBarSend } = useCommandsContext() const { commandBarSend } = useCommandsContext()
const { project, file } = useRouteLoaderData(PATHS.FILE) as IndexLoaderData const { project, file } = useRouteLoaderData(PATHS.FILE) as IndexLoaderData
const [state, send] = useMachine(fileMachine, { const [state, send] = useMachine(
context: { fileMachine.provide({
project,
selectedDirectory: project,
},
actions: { actions: {
navigateToFile: (context, event) => { renameToastSuccess: ({ event }) => {
if (event.data && 'name' in event.data) { if (event.type !== 'xstate.done.actor.rename-file') return
toast.success(event.output.message)
},
createToastSuccess: ({ event }) => {
if (event.type !== 'xstate.done.actor.create-and-open-file') return
toast.success(event.output.message)
},
toastSuccess: ({ event }) => {
if (
event.type !== 'xstate.done.actor.rename-file' &&
event.type !== 'xstate.done.actor.delete-file'
)
return
toast.success(event.output.message)
},
toastError: ({ event }) => {
if (event.type !== 'xstate.done.actor.rename-file') return
toast.error(event.output.message)
},
navigateToFile: ({ context, event }) => {
if (event.type !== 'xstate.done.actor.create-and-open-file') return
if (event.output && 'name' in event.output) {
commandBarSend({ type: 'Close' }) commandBarSend({ type: 'Close' })
navigate( navigate(
`..${PATHS.FILE}/${encodeURIComponent( `..${PATHS.FILE}/${encodeURIComponent(
context.selectedDirectory + context.selectedDirectory +
window.electron.path.sep + window.electron.path.sep +
event.data.name event.output.name
)}` )}`
) )
} else if ( } else if (
event.data && event.output &&
'path' in event.data && 'path' in event.output &&
event.data.path.endsWith(FILE_EXT) event.output.path.endsWith(FILE_EXT)
) { ) {
// Don't navigate to newly created directories // Don't navigate to newly created directories
navigate(`..${PATHS.FILE}/${encodeURIComponent(event.data.path)}`) navigate(`..${PATHS.FILE}/${encodeURIComponent(event.output.path)}`)
} }
}, },
addFileToRenamingQueue: assign({
itemsBeingRenamed: (context, event) => [
...context.itemsBeingRenamed,
event.data.path,
],
}),
removeFileFromRenamingQueue: assign({
itemsBeingRenamed: (
context,
event: EventFrom<typeof fileMachine, 'done.invoke.rename-file'>
) =>
context.itemsBeingRenamed.filter(
(path) => path !== event.data.oldPath
),
}),
renameToastSuccess: (_, event) => toast.success(event.data.message),
createToastSuccess: (_, event) => toast.success(event.data.message),
toastSuccess: (_, event) =>
event.data && toast.success((event.data || '') + ''),
toastError: (_, event) => toast.error((event.data || '') + ''),
}, },
services: { actors: {
readFiles: async (context: ContextFrom<typeof fileMachine>) => { readFiles: fromPromise(async ({ input }) => {
const newFiles = isDesktop() const newFiles =
? (await getProjectInfo(context.project.path)).children (isDesktop() ? (await getProjectInfo(input.path)).children : []) ??
: [] []
return { return {
...context.project, ...input,
children: newFiles, children: newFiles,
} }
}, }),
createAndOpenFile: async (context, event) => { createAndOpenFile: fromPromise(async ({ input }) => {
let createdName = event.data.name.trim() || DEFAULT_FILE_NAME let createdName = input.name.trim() || DEFAULT_FILE_NAME
let createdPath: string let createdPath: string
if (event.data.makeDir) { if (input.makeDir) {
let { name, path } = getNextDirName({ let { name, path } = getNextDirName({
entryName: createdName, entryName: createdName,
baseDir: context.selectedDirectory.path, baseDir: input.selectedDirectory.path,
}) })
createdName = name createdName = name
createdPath = path createdPath = path
@ -114,26 +111,26 @@ export const FileMachineProvider = ({
} else { } else {
const { name, path } = getNextFileName({ const { name, path } = getNextFileName({
entryName: createdName, entryName: createdName,
baseDir: context.selectedDirectory.path, baseDir: input.selectedDirectory.path,
}) })
createdName = name createdName = name
createdPath = path createdPath = path
await window.electron.writeFile(createdPath, event.data.content ?? '') await window.electron.writeFile(createdPath, input.content ?? '')
} }
return { return {
message: `Successfully created "${createdName}"`, message: `Successfully created "${createdName}"`,
path: createdPath, path: createdPath,
} }
}, }),
createFile: async (context, event) => { createFile: fromPromise(async ({ input }) => {
let createdName = event.data.name.trim() || DEFAULT_FILE_NAME let createdName = input.name.trim() || DEFAULT_FILE_NAME
let createdPath: string let createdPath: string
if (event.data.makeDir) { if (input.makeDir) {
let { name, path } = getNextDirName({ let { name, path } = getNextDirName({
entryName: createdName, entryName: createdName,
baseDir: context.selectedDirectory.path, baseDir: input.selectedDirectory.path,
}) })
createdName = name createdName = name
createdPath = path createdPath = path
@ -141,33 +138,30 @@ export const FileMachineProvider = ({
} else { } else {
const { name, path } = getNextFileName({ const { name, path } = getNextFileName({
entryName: createdName, entryName: createdName,
baseDir: context.selectedDirectory.path, baseDir: input.selectedDirectory.path,
}) })
createdName = name createdName = name
createdPath = path createdPath = path
await window.electron.writeFile(createdPath, event.data.content ?? '') await window.electron.writeFile(createdPath, input.content ?? '')
} }
return { return {
path: createdPath, path: createdPath,
} }
}, }),
renameFile: async ( renameFile: fromPromise(async ({ input }) => {
context: ContextFrom<typeof fileMachine>, const { oldName, newName, isDir } = input
event: EventFrom<typeof fileMachine, 'Rename file'>
) => {
const { oldName, newName, isDir } = event.data
const name = newName const name = newName
? newName.endsWith(FILE_EXT) || isDir ? newName.endsWith(FILE_EXT) || isDir
? newName ? newName
: newName + FILE_EXT : newName + FILE_EXT
: DEFAULT_FILE_NAME : DEFAULT_FILE_NAME
const oldPath = window.electron.path.join( const oldPath = window.electron.path.join(
context.selectedDirectory.path, input.selectedDirectory.path,
oldName oldName
) )
const newPath = window.electron.path.join( const newPath = window.electron.path.join(
context.selectedDirectory.path, input.selectedDirectory.path,
name name
) )
@ -213,22 +207,19 @@ export const FileMachineProvider = ({
newPath, newPath,
oldPath, oldPath,
} }
}, }),
deleteFile: async ( deleteFile: fromPromise(async ({ input }) => {
context: ContextFrom<typeof fileMachine>, const isDir = !!input.children
event: EventFrom<typeof fileMachine, 'Delete file'>
) => {
const isDir = !!event.data.children
if (isDir) { if (isDir) {
await window.electron await window.electron
.rm(event.data.path, { .rm(input.path, {
recursive: true, recursive: true,
}) })
.catch((e) => console.error('Error deleting directory', e)) .catch((e) => console.error('Error deleting directory', e))
} else { } else {
await window.electron await window.electron
.rm(event.data.path) .rm(input.path)
.catch((e) => console.error('Error deleting file', e)) .catch((e) => console.error('Error deleting file', e))
} }
@ -250,32 +241,35 @@ export const FileMachineProvider = ({
// the same path on the navigate, which doesn't cause anything to // the same path on the navigate, which doesn't cause anything to
// refresh, leaving a stale execution state. // refresh, leaving a stale execution state.
navigate(0) navigate(0)
return return {
message: 'No more files in project, created main.kcl',
}
} }
// If we just deleted the current file or one of its parent directories, // If we just deleted the current file or one of its parent directories,
// navigate to the project root // navigate to the project root
if ( if (
(event.data.path === file?.path || (input.path === file?.path || file?.path.includes(input.path)) &&
file?.path.includes(event.data.path)) &&
project?.path project?.path
) { ) {
navigate(`../${PATHS.FILE}/${encodeURIComponent(project.path)}`) navigate(`../${PATHS.FILE}/${encodeURIComponent(project.path)}`)
} }
return `Successfully deleted ${isDir ? 'folder' : 'file'} "${ return {
event.data.name message: `Successfully deleted ${isDir ? 'folder' : 'file'} "${
}"` input.name
}"`,
}
}),
}, },
}),
{
input: {
project,
selectedDirectory: project,
}, },
guards: { }
'Has at least 1 file': (_, event: EventFrom<typeof fileMachine>) => { )
if (event.type !== 'done.invoke.read-files') return false
return !!event?.data?.children && event.data.children.length > 0
},
'Is not silent': (_, event) => !event.data?.silent,
},
})
return ( return (
<FileContext.Provider <FileContext.Provider

Some files were not shown because too many files have changed in this diff Show More