Compare commits

...

42 Commits

Author SHA1 Message Date
82d1fe5436 Merge branch 'main' into pierremtb/shell-dry-run-validation-on-selections-and-extra-arguments 2025-02-03 13:22:08 -05:00
max
3e8ee3ffc4 Add Point-and-Click Deletion for Fillets and Chamfers (#5098)
* ast mod

* point and click test

* tsc

* test test

* unit test edit

* topLevelRange

* disable unit test

* remove bad imports

* fix typo

* Fix cyclic dependency hell with getNodePathFromSourceRange

* tsc

* fix ImportStatement

* fix isValueZero

* pre-emptively ==> preemptively

* yarn fmt-check

* reenable the unit test

* fmt

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* Trigger CI

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* Trigger CI

* add test

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* Trigger CI

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* Trigger CI

* several treatments

* consolidate

* typos

* fix imports, consolidate

* consolidate import

* fix imports

* add tests

* stress test CI

* fix test

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* Trigger CI

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* Trigger CI

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* Trigger CI

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* Trigger CI

* fix tests

* clean test for fillets

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* Trigger CI

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* test chamfers

* comments

* simplify main tests

* typo

* typo2

* remove import

* clean up comments

---------

Co-authored-by: 49lf <ircsurfer33@gmail.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-02-03 12:11:26 -05:00
a44516bc7e Turn on Share Link in nightly builds (#5153)
* WIP: Turn on link sharing in released apps with electron-builder
Fixes #5136

* Add import.meta.env defaults

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* Add convenience scripts for windows development; fix protocol name for electron; enable share cmd

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* Force release builds

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* Trigger CI

* Fix lint

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* CSC_FOR_PULL_REQUEST: true for release build testing

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* Adding ://

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* Back to debug builds

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* Back to debug builds

* WIP: origin

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* To revert: Add logs and custom package version for easier testing

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* More messing with env vars

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* Messing with help from deep links docs

* Removed alerts

* Working on macos

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* Working second window on windows. Cold start not yet working

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* Handle windows cold start

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* Clean up after macos testing

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* Replace tron:package (Forge) with tronb📦dev (Builder) for e2e

* Add new env var for web app link

* tronb:vite:dev for e2e

* Remove app.requestSingleInstanceLock() call

* Fix unit test

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* Revert snap changes

* Only nightly at first

* Remove test app

* Update package.json

* Update src/main.ts

* Remove fetch:wasm:windows

* Final line

* Clean up

* Back to test app for final test

* Fix tsc

* Back to https://app.dev.zoo.dev from vercel branch deploy

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-02-03 10:03:41 -05:00
ce62fe67cf Bump google-github-actions/auth from 2.1.7 to 2.1.8 (#5218)
Bumps [google-github-actions/auth](https://github.com/google-github-actions/auth) from 2.1.7 to 2.1.8.
- [Release notes](https://github.com/google-github-actions/auth/releases)
- [Changelog](https://github.com/google-github-actions/auth/blob/main/CHANGELOG.md)
- [Commits](https://github.com/google-github-actions/auth/compare/v2.1.7...v2.1.8)

---
updated-dependencies:
- dependency-name: google-github-actions/auth
  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>
2025-02-03 05:13:11 -05:00
763a1b6628 Bump google-github-actions/upload-cloud-storage from 2.2.1 to 2.2.2 (#5217)
Bumps [google-github-actions/upload-cloud-storage](https://github.com/google-github-actions/upload-cloud-storage) from 2.2.1 to 2.2.2.
- [Release notes](https://github.com/google-github-actions/upload-cloud-storage/releases)
- [Changelog](https://github.com/google-github-actions/upload-cloud-storage/blob/main/CHANGELOG.md)
- [Commits](https://github.com/google-github-actions/upload-cloud-storage/compare/v2.2.1...v2.2.2)

---
updated-dependencies:
- dependency-name: google-github-actions/upload-cloud-storage
  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>
2025-02-03 05:13:00 -05:00
3281e62e6b Bump google-github-actions/setup-gcloud from 2.1.2 to 2.1.4 (#5216)
Bumps [google-github-actions/setup-gcloud](https://github.com/google-github-actions/setup-gcloud) from 2.1.2 to 2.1.4.
- [Release notes](https://github.com/google-github-actions/setup-gcloud/releases)
- [Changelog](https://github.com/google-github-actions/setup-gcloud/blob/main/CHANGELOG.md)
- [Commits](https://github.com/google-github-actions/setup-gcloud/compare/v2.1.2...v2.1.4)

---
updated-dependencies:
- dependency-name: google-github-actions/setup-gcloud
  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>
2025-02-03 05:12:50 -05:00
f1a458f124 Add a trackball camera setting (#4764)
* Add a setting that does nothing

* Make the setting actually change the interaction type

* fmt

* Bump `@kittycad/lib` to get the proper camera drag interaction types

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* Fix camera orientation bugs to support proper camera resetting on "camera orbit" setting change (#5031)

* Add a setting that does nothing

* Make the setting actually change the interaction type

* fmt

* fix: up vector bug fix and camera reset fix. Pushing code to cleanup after debugging

* fix: deleting debugging code

* fix: removing debugging code

* fix: removing debugging console log

* fix: removing console log debugs

* fix: adding comment, restoring code from debugging

* fix: removed lookAt when the orientation is already set from the engine.. I do not think we should be recomputing it?

* fix: this fixes the bug because I was pointing to the getter not the value

* Remove unused imports

* Fix lint for unawaited Promise

* Remove pointless change

---------

Co-authored-by: Frank Noirot <frank@kittycad.io>
Co-authored-by: Frank Noirot <frankjohnson1993@gmail.com>

* Re-run CI

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* Re-run CI

* Add display attributes to try to fix cargo test

* Remove backwards compat test case

it's failing because I didn't add cameraOrbit to that type and I don't
want to

* Fix test value (prev user value would have been Spherical before Trackball)

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Kevin Nadro <nadr0@users.noreply.github.com>
Co-authored-by: Pierre Jacquier <pierre@zoo.dev>
2025-02-01 20:03:04 +00:00
229433126d Feature: Implemented thumbnail.png saving and load. Projects on homepage will have images (#5133)
* feature: implemented saving thumbnail.png to have project thumbnails in the home page

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* Trigger CI

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* bump

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* bump

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* bump

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* bump

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* bump

* Fix the failing test by increasing window height (related to toast covering now-larger project tiles)

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Pierre Jacquier <pierre@zoo.dev>
Co-authored-by: Pierre Jacquier <pierrejacquier39@gmail.com>
Co-authored-by: Frank Noirot <frank@zoo.dev>
2025-02-01 11:40:02 +00:00
b962b5fcb3 Feature: Release Revolve to all users, added E2E test for revolve in command bar (#5085)
* chore: implemented E2E test for revolve

* fix: revert testing code

* fix: codespell

* fix: added access via the toolbar

* fix: saving off bugging code

* fix: removing error message

* fix: cleaning up testing code

* chore: adding more e2e tests for revolve

---------

Co-authored-by: Pierre Jacquier <pierrejacquier39@gmail.com>
2025-02-01 06:02:43 -05:00
428d125139 Make point-and-click Sweep generally available (#5159)
* Make point-and-click Sweep generally available
Fixes #5156

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* Trigger CI

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* Trigger CI

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* Trigger CI

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* Remove replace /segment/face

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* Selections change will be done in separate PR #5183

* Toolbar button

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* Reset snaps

* Revert screenshot

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-02-01 05:21:25 -05:00
cffeb52b4b test: Add testing the artifact graph when there's an execution error (#5154)
* Add testing the artifact graph snapshots when there's an execution error

* Update output to check artifact graph in error cases

* Rename helper function to be clearer

* Add test that has meaningful output, followed by an error
2025-01-31 18:32:30 -05:00
e0ef10e7bb Bump express from 4.21.0 to 4.21.2 for path-to-regexp fix (#5188) 2025-01-31 22:43:58 +00:00
7095ce2377 Fix the '1 face' mislabelling of selection for sweep segments (#5183)
* Fix the '1 face' mislabelling of selection for sweep segments
Fixes #5182

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* Reset snapshots

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* Fix lint

* Revert snap

* Fix chamfer and fillet test selection

* Fix other test

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-01-31 16:49:57 -05:00
5b207d7d1a chore: adding unit test to test getNodeFromPath failures (#5134)
* chore: adding unit test to test the parsing of the code

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* chore: trying to console error more content for getNodeByPath since it is cryptic

* fix: removing testing unit test

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* fix: fmt

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* bump

* fix: ...

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* bump

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* bump

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* bump

* fix: found da bug

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-01-31 15:13:43 -06:00
2fac213c58 Add engine message to dry run validation error toasts (#5175)
* Add engine message to dry run validation error toasts
Fixes #5174

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* Trigger CI

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* Add unit tests

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* Reset snapshots

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* Revert snapshot changes

* Fix lint

* Fix test

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-01-31 16:13:35 -05:00
2f72a8ef14 Helper functions for meta settings (get/update) (#5200)
* get the units back out

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

* updates

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

* edit

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

* updates

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

* updates

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

* add to wasmts

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

* fmt

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

* updates

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

---------

Signed-off-by: Jess Frazelle <github@jessfraz.com>
2025-01-31 21:11:15 +00:00
27ce9f8aa4 Remove project.toml fetch during Open Sample (#5203)
* Fix units in tests

* Don't default to mm

* Last fix

* FIx lint

* Remove project.toml fetching from samples
2025-01-31 15:38:04 -05:00
b0426e3f94 Refactor: separate authMachine from React (#5110)
* Create a global appMachine

* Strip authMachine of side-effects

* Replace react-bound authMachine use with XState actor use

* Fix import goof

* Register auth commands directly!

* @lf94 feedback: conver `AuthNavigationHandler` to `useAuthNavigation`

* Uh, fix signing out thank you @lf94

* Fix tsc

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

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

This reverts commit 8dc50b6a26.

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-01-31 14:47:08 -05:00
d707c66e53 Migrate Extrude AST mod from XState action to actor (#5146)
* Migrate Extrude AST mod from XState action to actor

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* @lf94 feedback

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-01-31 18:34:38 +00:00
f8f44743fa Bug fix: open project command lists project names (#5176)
* Amend project open test to show failing case

* Fix command config to use live context value

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* Update src/lib/commandBarConfigs/projectsCommandConfig.ts

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

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Jonathan Tran <jonnytran@gmail.com>
2025-01-31 16:04:34 +00:00
f262eda12a Turn off snapshot commit bot (#5197)
Our team is actively investigating a recent spike in flaky WebRTC
connection behavior, which has impacted the web-only Playwright snapshot
tests particularly hard. The bot squashes other GH Actions in it's wake,
so I think we should turn it off so we can see the other E2E tests more
clearly
2025-01-31 10:57:52 -05:00
9e1136195a Fix: Center rectangle now works again, works with new LiteralValue structure (#5172)
* fix: new Literal data structure update

* fix: updated the LiteralValue dereferencing and added some type narrowing helpers

* fix: updating formatting

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* fix: implementing a safer dereference method until we update createLiteraly()

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* why is this fallback here

* fix: updating the type narrowing function

* fix: restore this... see if snapshots trigger again

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* bump

* bump

* Add number with units formatting as KCL (#5195)

* Add number with units formatting as KCL

* Change type assertion helper to check what we need

* Fix rectangle unit test

* fix: adding a wait for execution to prevent clicking before lines are rendered

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Jonathan Tran <jonnytran@gmail.com>
2025-01-31 09:45:39 -06:00
4ff07ddaee Fix: Clearing sketch DOM elements after redirecting to the home page (#4965)
* fix: clear the previous DOM elements after page redirect

* fix: removed await delay since it can take awhile to destroy the sketch

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* fix: added E2E test which actually caught a logic bug, moved the logic to the correct location

* fix: removing unused import

* fix: push main back...

* fix: restoring code to old state

* fix: moved cleanup code

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-01-31 07:01:51 -06:00
1e565379a7 allow setting units in subfiles/ turn on execution in modules (#5178)
* allow setting units in subfiles

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

* updates

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

* turn on execution in modules

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

* updates to tests

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

* updates

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

* cleanup bad snaps

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

* updates

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

* change some snapshots;

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

* fix tests

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

---------

Signed-off-by: Jess Frazelle <github@jessfraz.com>
2025-01-30 00:26:49 +00:00
76e34ac4da Ignore cloned kcl-samples (#5179) 2025-01-29 17:20:03 -05:00
4cd427bf91 fix: Remove link from code editor docs (#5170)
Remove link from the stdlib import() docs
2025-01-29 16:00:00 -05:00
f321ecdff0 add more unit names/abreevs (#5173)
* add more unit names

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

* typo

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

---------

Signed-off-by: Jess Frazelle <github@jessfraz.com>
2025-01-29 12:09:27 +11:00
d114ab798c fix subdirs (opening kcl-samples from kcl-samples dir) (#5171)
* WIP: Add the KCL file path into the executor

* reuse the stuff that works with settings.project_directory

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

* fixes on both sides

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

* fixes on both sides

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

* helper method

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

* fixes

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

* update kcl-samples tests to not change dirs

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

* update kcl-samples tests to not change dirs

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

* fmt

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

---------

Signed-off-by: Jess Frazelle <github@jessfraz.com>
Co-authored-by: Jonathan Tran <jonnytran@gmail.com>
2025-01-28 23:43:39 +00:00
69fec37107 prepare change in fillet api (#5168)
* updates

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

* bump version

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

* updates

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

* more snaps

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

---------

Signed-off-by: Jess Frazelle <github@jessfraz.com>
2025-01-28 22:22:19 +00:00
8ca8c49cc3 Fix to not error when using imported whole modules (#5161)
* Fix to not error when using imported whole modules

* Add TODO about adding a warning
2025-01-28 20:17:27 +00:00
b25fc302fd Support using import statements for non-KCL files (#5122)
* Improve checking of import paths

Signed-off-by: Nick Cameron <nrc@ncameron.org>

* Import path kinds in AST

Signed-off-by: Nick Cameron <nrc@ncameron.org>

* Use import statement for foreign files

Signed-off-by: Nick Cameron <nrc@ncameron.org>

---------

Signed-off-by: Nick Cameron <nrc@ncameron.org>
2025-01-29 08:28:32 +13:00
648b37c1dd Bump clap from 4.5.23 to 4.5.27 in /src/wasm-lib (#5152)
Bumps [clap](https://github.com/clap-rs/clap) from 4.5.23 to 4.5.27.
- [Release notes](https://github.com/clap-rs/clap/releases)
- [Changelog](https://github.com/clap-rs/clap/blob/master/CHANGELOG.md)
- [Commits](https://github.com/clap-rs/clap/compare/clap_complete-v4.5.23...clap_complete-v4.5.27)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-01-27 21:29:09 +00:00
18e5da5ca4 bump modeling commands (#5145)
* add length unit

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

* new images

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

* updates

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

---------

Signed-off-by: Jess Frazelle <github@jessfraz.com>
2025-01-27 11:36:00 -08:00
46524cda10 Bump vite from 5.4.11 to 5.4.14 in /packages/codemirror-lang-kcl (#5128)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 5.4.11 to 5.4.14.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v5.4.14/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v5.4.14/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-01-27 17:22:19 +00:00
max
4585671a5d Refactor getNodePathFromSourceRange (#5139)
* Refactor getNodePathFromSourceRange

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* Trigger CI

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* Trigger CI

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* Trigger CI

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* Trigger CI

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-01-27 08:24:28 -05:00
e7203b9e7a export ast visitor stubs (#5141) 2025-01-24 12:50:23 -05:00
ab375f4b92 Add in a Node::ptr_eq helper (#5140)
Add in a Node::ptr_eq helper

This looks a bit gross (I get it), but it's helpful when walking the
tree. It means we can construct nodes (via `into`) of borrows, and later
check to see if it's the same borrow later on if we need to walk the
tree multiple times or otherwise maintainer a pointer to a specific AST
entry.

The idea here is you could collect nodes, then traverse the tree again,
and know when you've reached the exact same node again.
2025-01-24 12:49:59 -05:00
04ed6f52ee Add ability to create and open URLs to create files (#4166)
* Rename `homeMachine` and accessories to `projectsMachine`

* Separate out `/home` route from `projectsMachine`

* Add logic to navigate out from deleted or renamed project

* Show a warning in the command palette for deleting a project

* Make it navigate when you create a project

* Update "New project" button to use command bar flow
Closes #2585

* More explicit warning message text

* Make projects watching code not run in web

* Tests first version: nested loops

* Tests second version: flattened

* Remove console logs

* Fix tsc

* @jtran feedback, use the type guard util

* 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 tests that relied on one-click, no-navigation project creation

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

This reverts commit 7545b61b49.

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

This reverts commit 3d2e48732c.

* Add a mask to the state indicator to client-side scale test

* 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: ubuntu-latest)

* Fix lint

* Fix tsc

* Add menu item to share link to file

* Forward query params while redirecting to /home or /file

* Add (broken) event logic and command triggering logic

* Fix a couple stray tests that still relied on the old way of creating projects

* De-flake another text that could be thrown off by toast-based selectors

* FMT

* Dumb test error because I was rushing

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

* Ahhh more flaky toasts, they're everywhere!

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

* Side quest: Only register commands once, power their disabled status while selecting commands via optional actor

* Get query-triggered command working in browser too

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

* Tests always run on localhost, don't expect the prod origin

* rerun CI

* wip

* wip

* Everything's pretty much done but url.zoo.dev has been broken and we need to think about how to test reliably

* Merge branch 'main' into franknoirot/4088/create-file-url

* Add useCreateFileLinkQuery on Home page

* Get primary user flow working on desktop

* Rework to open browser app first, then send along to the desktop app if asked

* Styling updates to OpenInDesktopAppHandler

* Clean up unecessary file

* Merge branch 'main' into franknoirot/4088/create-file-url

* Separate creating `createFileUrl` and shortlink so it is unit testable

* Add E2E test for importing file from URL

* Add a couple component tests for OpenInDesktopAppHandler

* Fix the "existing project" user flow

* Add E2E test for "add to existing project" user flow

* Undo mistaken or unecessary changes

* Lints, fmt, tsc

* Fix unit test

* Fix broken rename and delete project commands
Something about the `optionsFromContext` config no longer works with file I/O-related commands. I suspect this has to do with our read/write loop patching

* Fix unit test, use kebab-case for url query param

* Use dev urls everywhere when configured that way

I think we were just using some constants that ended up returning bad
values for dev, it seemed to return a working shortlink when I went
through the flow.

* Clean up unneeded PROD_TOKEN

* Fix browser command flow, because we had made the projectMachine desktop-only on main

* Make the test executor a bit more patient (#5004)

* Fix so that all artifact commands are returned regardless of caching (#5005)

* Fix so that all artifact commands are returned regardless of caching

* Add some more docs and fix up old ones

* Add new lint to disallow use of confusing isNaN (#4999)

* Point-and-click Sweep (first PR) (#4989)

* Refactor 'Delete selection' as actor
Will fix #4662

* WIP logging

* WIP: working Solid3dGetExtrusionFaceInfo for loft

* Working wall deletion of loft

* Add offset plane deletion

* Add feature tree deletion of shell

* Clean up

* Revert "Clean up"

This reverts commit 214763cc2b.

* Clean up rust changes, taking the sketch with the most paths

* Working cap selection and deletion

* Clean up

* Add test for loft and offset plane deletion via selection

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

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-macos-8-cores)

* Set reenter: false as it was originally

* Passing test

* Add shell deletion via feature tree test

* Revert the migration to promise actor

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* Trigger CI

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* Trigger CI

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* Trigger CI

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* Trigger CI

* Use cmd.id as solid_id after latest engine merge

* Add feature tree deletion of offset plane and fix lint

* Add feature tree deletion of loft

* Clean up

* Better comment

* Lint fix

* Remove sketch sorting

* WIP: sweep point-and-click

* Working sweep

* Add test

* Make sweep a development command

* Fix tsc error

* Clean up for review

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>

* Upgrade typescript-eslint from 5.62.0 to 8.19.1 and remove eslint-config-react-app (#5006)

* Fix lost lints and add new ones (#5011)

* Add eslint-plugin-jsx-a11y dependency

* Add jsx-a11y lint

* Add eslint-plugin-react-hooks dependency

* Add react hooks lints

* Ignore new react hooks lint in tests

* Add eslint-plugin-testing-library dependency

* Add testing-library lint

* Fix yarn lint to use all files recursively

* Developer workflow: added auto generated workspace file from vitest extension in vscode (#4997)

* chore: added auto generated workspace file from vitest extension in vscode

* fix: auto fmt fixes

* Change Dependabot PRs to always be made on Mondays (#5025)

* Add packages to Dependabot updates (#5024)

* Bump @lezer/generator from 1.7.1 to 1.7.2 (#5018)

Bumps [@lezer/generator](https://github.com/lezer-parser/generator) from 1.7.1 to 1.7.2.
- [Changelog](https://github.com/lezer-parser/generator/blob/main/CHANGELOG.md)
- [Commits](https://github.com/lezer-parser/generator/compare/1.7.1...1.7.2)

---
updated-dependencies:
- dependency-name: "@lezer/generator"
  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>

* Bump handlebars from 6.2.0 to 6.3.0 in /src/wasm-lib (#5012)

Bumps [handlebars](https://github.com/sunng87/handlebars-rust) from 6.2.0 to 6.3.0.
- [Release notes](https://github.com/sunng87/handlebars-rust/releases)
- [Changelog](https://github.com/sunng87/handlebars-rust/blob/master/CHANGELOG.md)
- [Commits](https://github.com/sunng87/handlebars-rust/compare/v6.2.0...v6.3.0)

---
updated-dependencies:
- dependency-name: handlebars
  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>

* Bump syn from 2.0.95 to 2.0.96 in /src/wasm-lib (#5015)

Bumps [syn](https://github.com/dtolnay/syn) from 2.0.95 to 2.0.96.
- [Release notes](https://github.com/dtolnay/syn/releases)
- [Commits](https://github.com/dtolnay/syn/compare/2.0.95...2.0.96)

---
updated-dependencies:
- dependency-name: syn
  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>

* Fix artifact types to be more accurate (#5022)

* Fix Cargo.lock to not have changes (#5034)

* Upgrade all wasm-bindgen dependencies together (#5037)

* Disable auto-updater on non-versioned builds (#5042)

* turns on helix from edge (#5036)

* updates for new lib

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

* autocomplete

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

* bump version

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

* bump all the things

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

* new samples

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>

* ci: Add yarn test of packages/codemirror-lang-kcl (#5035)

* ci: Add yarn test of packages/codemirror-lang-kcl

* Fix CI error running tests

* Fix postcss config error

* Bump xstate from 5.17.4 to 5.19.2 (#5027)

* Hook up chamfer UI with AST-mod (#4694)

* button

* config

* hook up with ast

* cmd bar test

* button states fix and test

* little naming fix

* xState action to actor

* remove button state test updates

* fixture-based approach

* nightly

Co-authored-by: Pierre Jacquier <pierrejacquier39@gmail.com>

* Update src/lib/toolbar.ts

Co-authored-by: Frank Noirot <frank@zoo.dev>

---------

Co-authored-by: Pierre Jacquier <pierrejacquier39@gmail.com>
Co-authored-by: Frank Noirot <frank@zoo.dev>

* Remove Redundant Fillet Button State Test (#5009)

delete obsolete test

* Bump @types/node from 20.14.9 to 22.10.6 in /packages/codemirror-lsp-client (#5041)

* custom axis and origin example for helix (#5057)

* custom axis and origin for helix

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

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* empty

---------

Signed-off-by: Jess Frazelle <github@jessfraz.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>

* Bump typescript from 5.7.2 to 5.7.3 (#5021)

Bumps [typescript](https://github.com/microsoft/TypeScript) from 5.7.2 to 5.7.3.
- [Release notes](https://github.com/microsoft/TypeScript/releases)
- [Changelog](https://github.com/microsoft/TypeScript/blob/main/azure-pipelines.release.yml)
- [Commits](https://github.com/microsoft/TypeScript/compare/v5.7.2...v5.7.3)

---
updated-dependencies:
- dependency-name: typescript
  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>

* Refactor: break out `copyFileShareLink` into standalone function

* Add "Share file" to command palette

* Update dumb use of site URL instead of prod app URL

* fmt

* @lf94 nit

* @pierremtb spinner feedback

* Hide share link command and disable menu item for now

* Just comment out the command config for now

---------

Signed-off-by: dependabot[bot] <support@github.com>
Signed-off-by: Jess Frazelle <github@jessfraz.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: 49lf <ircsurfer33@gmail.com>
Co-authored-by: Adam Sunderland <iterion@gmail.com>
Co-authored-by: Adam Sunderland <adam@kittycad.io>
Co-authored-by: Jonathan Tran <jonnytran@gmail.com>
Co-authored-by: Pierre Jacquier <pierrejacquier39@gmail.com>
Co-authored-by: Kevin Nadro <nadr0@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Jess Frazelle <jessfraz@users.noreply.github.com>
Co-authored-by: max <margorskyi@gmail.com>
2025-01-24 00:18:27 +00:00
2332338ca1 Show "No results found" for empty search results in command palette (#5143) 2025-01-23 18:49:50 -05:00
41b97de3d1 Fix: Badge indicator is now placed over the respective button in the sidebar (#4979)
* fix: positioning of the badge for the sidebar buttons

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* bump since image is wrong

* bump since image is wrong

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-01-23 14:20:41 -06:00
bfb0bd6997 Merge branch 'main' into pierremtb/shell-dry-run-validation-on-selections-and-extra-arguments 2025-01-22 17:12:34 +01:00
7aa6e58121 WIP: Shell dry-run validation with thickness arg 2025-01-20 16:53:13 +01:00
295 changed files with 12543 additions and 1838 deletions

View File

@ -2,8 +2,8 @@ NODE_ENV=development
DEV=true DEV=true
VITE_KC_API_WS_MODELING_URL=wss://api.dev.zoo.dev/ws/modeling/commands VITE_KC_API_WS_MODELING_URL=wss://api.dev.zoo.dev/ws/modeling/commands
VITE_KC_API_BASE_URL=https://api.dev.zoo.dev VITE_KC_API_BASE_URL=https://api.dev.zoo.dev
BASE_URL=https://api.dev.zoo.dev
VITE_KC_SITE_BASE_URL=https://dev.zoo.dev VITE_KC_SITE_BASE_URL=https://dev.zoo.dev
VITE_KC_SITE_APP_URL=https://app.dev.zoo.dev
VITE_KC_SKIP_AUTH=false VITE_KC_SKIP_AUTH=false
VITE_KC_CONNECTION_TIMEOUT_MS=5000 VITE_KC_CONNECTION_TIMEOUT_MS=5000
# ONLY add your token in .env.development.local if you want to skip auth, otherwise this token takes precedence! # ONLY add your token in .env.development.local if you want to skip auth, otherwise this token takes precedence!

View File

@ -1,5 +1,8 @@
NODE_ENV=production
DEV=false
VITE_KC_API_WS_MODELING_URL=wss://api.zoo.dev/ws/modeling/commands VITE_KC_API_WS_MODELING_URL=wss://api.zoo.dev/ws/modeling/commands
VITE_KC_API_BASE_URL=https://api.zoo.dev VITE_KC_API_BASE_URL=https://api.zoo.dev
VITE_KC_SITE_BASE_URL=https://zoo.dev VITE_KC_SITE_BASE_URL=https://zoo.dev
VITE_KC_SITE_APP_URL=https://app.zoo.dev
VITE_KC_SKIP_AUTH=false VITE_KC_SKIP_AUTH=false
VITE_KC_CONNECTION_TIMEOUT_MS=15000 VITE_KC_CONNECTION_TIMEOUT_MS=15000

View File

@ -134,8 +134,6 @@ jobs:
max_attempts: 3 max_attempts: 3
command: yarn install command: yarn install
- run: yarn tronb:vite
- name: Prepare certificate and variables (Windows only) - name: Prepare certificate and variables (Windows only)
if: ${{ (env.IS_RELEASE == 'true' || env.IS_NIGHTLY == 'true') && matrix.os == 'windows-2022' }} if: ${{ (env.IS_RELEASE == 'true' || env.IS_NIGHTLY == 'true') && matrix.os == 'windows-2022' }}
run: | run: |
@ -165,8 +163,8 @@ jobs:
- name: Build the app (debug) - name: Build the app (debug)
if: ${{ env.IS_RELEASE == 'false' && env.IS_NIGHTLY == 'false' }} if: ${{ env.IS_RELEASE == 'false' && env.IS_NIGHTLY == 'false' }}
# electron-builder doesn't have a concept of release vs debug, # electron-builder doesn't have a concept of release vs debug,
# this is just not doing any codesign or release yml generation # this is just not doing any codesign or release yml generation, and points to dev infra
run: yarn electron-builder --config run: yarn tronb:package:dev
- name: Build the app (release) - name: Build the app (release)
if: ${{ env.IS_RELEASE == 'true' || env.IS_NIGHTLY == 'true' }} if: ${{ env.IS_RELEASE == 'true' || env.IS_NIGHTLY == 'true' }}
@ -185,7 +183,7 @@ jobs:
with: with:
timeout_minutes: 10 timeout_minutes: 10
max_attempts: 3 max_attempts: 3
command: yarn electron-builder --config --publish always command: yarn tronb:package:prod
- name: List artifacts in out/ - name: List artifacts in out/
run: ls -R out run: ls -R out
@ -246,7 +244,7 @@ jobs:
with: with:
timeout_minutes: 10 timeout_minutes: 10
max_attempts: 3 max_attempts: 3
command: yarn electron-builder --config --publish always command: yarn tronb:package:prod
- uses: actions/upload-artifact@v4 - uses: actions/upload-artifact@v4
if: ${{ env.IS_RELEASE == 'true' }} if: ${{ env.IS_RELEASE == 'true' }}
@ -390,19 +388,19 @@ jobs:
- name: Authenticate to Google Cloud - name: Authenticate to Google Cloud
if: ${{ env.IS_NIGHTLY == 'true' }} if: ${{ env.IS_NIGHTLY == 'true' }}
uses: 'google-github-actions/auth@v2.1.7' uses: 'google-github-actions/auth@v2.1.8'
with: with:
credentials_json: '${{ secrets.GOOGLE_CLOUD_DL_SA }}' credentials_json: '${{ secrets.GOOGLE_CLOUD_DL_SA }}'
- name: Set up Google Cloud SDK - name: Set up Google Cloud SDK
if: ${{ env.IS_NIGHTLY == 'true' }} if: ${{ env.IS_NIGHTLY == 'true' }}
uses: google-github-actions/setup-gcloud@v2.1.2 uses: google-github-actions/setup-gcloud@v2.1.4
with: with:
project_id: ${{ env.GOOGLE_CLOUD_PROJECT_ID }} project_id: ${{ env.GOOGLE_CLOUD_PROJECT_ID }}
- name: Upload nightly files to public bucket - name: Upload nightly files to public bucket
if: ${{ env.IS_NIGHTLY == 'true' }} if: ${{ env.IS_NIGHTLY == 'true' }}
uses: google-github-actions/upload-cloud-storage@v2.2.1 uses: google-github-actions/upload-cloud-storage@v2.2.2
with: with:
path: out path: out
glob: '*' glob: '*'

View File

@ -123,23 +123,23 @@ jobs:
if: steps.download-wasm.outcome == 'failure' if: steps.download-wasm.outcome == 'failure'
shell: bash shell: bash
run: yarn build:wasm run: yarn build:wasm
- name: build electron - name: build web
shell: bash shell: bash
run: yarn tron:package run: yarn tronb:vite:dev
- name: Run ubuntu/chrome snapshots # - name: Run ubuntu/chrome snapshots
if: ${{ matrix.os == 'namespace-profile-ubuntu-8-cores' && matrix.shardIndex == 1 }} # if: ${{ matrix.os == 'namespace-profile-ubuntu-8-cores' && matrix.shardIndex == 1 }}
shell: bash # shell: bash
# TODO: break this in its own job, for now it's not slowing down the overall execution as ubuntu is the quickest, # # TODO: break this in its own job, for now it's not slowing down the overall execution as ubuntu is the quickest,
# but we could do better. This forces a large 1/1 shard of all 20 snapshot tests that runs in about 3 minutes. # # but we could do better. This forces a large 1/1 shard of all 20 snapshot tests that runs in about 3 minutes.
run: | # run: |
PLATFORM=web yarn playwright test --config=playwright.config.ts --retries="3" --update-snapshots --grep=@snapshot --shard=1/1 # PLATFORM=web yarn playwright test --config=playwright.config.ts --retries="3" --update-snapshots --grep=@snapshot --shard=1/1
env: # env:
CI: true # CI: 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
token: ${{ secrets.KITTYCAD_API_TOKEN_DEV }} # token: ${{ secrets.KITTYCAD_API_TOKEN_DEV }}
snapshottoken: ${{ secrets.KITTYCAD_API_TOKEN }} # snapshottoken: ${{ secrets.KITTYCAD_API_TOKEN }}
- uses: actions/upload-artifact@v4 - uses: actions/upload-artifact@v4
if: ${{ !cancelled() && (success() || failure()) }} if: ${{ !cancelled() && (success() || failure()) }}
with: with:
@ -162,20 +162,20 @@ jobs:
then echo "modified=true" >> $GITHUB_OUTPUT then echo "modified=true" >> $GITHUB_OUTPUT
else echo "modified=false" >> $GITHUB_OUTPUT else echo "modified=false" >> $GITHUB_OUTPUT
fi fi
- name: Commit changes, if any # - name: Commit changes, if any
if: steps.git-check.outputs.modified == 'true' # if: steps.git-check.outputs.modified == 'true'
shell: bash # shell: bash
run: | # run: |
git add . # git add .
git config --local user.email "github-actions[bot]@users.noreply.github.com" # git config --local user.email "github-actions[bot]@users.noreply.github.com"
git config --local user.name "github-actions[bot]" # git config --local user.name "github-actions[bot]"
git remote set-url origin https://${{ github.actor }}:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }}.git # git remote set-url origin https://${{ github.actor }}:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }}.git
git fetch origin # git fetch origin
echo ${{ github.head_ref }} # echo ${{ github.head_ref }}
git checkout ${{ github.head_ref }} # git checkout ${{ github.head_ref }}
git commit -am "A snapshot a day keeps the bugs away! 📷🐛 (OS: ${{matrix.os}})" || true # git commit -am "A snapshot a day keeps the bugs away! 📷🐛 (OS: ${{matrix.os}})" || true
git push # git push
git push origin ${{ github.head_ref }} # git push origin ${{ github.head_ref }}
# only upload artifacts if there's actually changes # only upload artifacts if there's actually changes
- uses: actions/upload-artifact@v4 - uses: actions/upload-artifact@v4
if: steps.git-check.outputs.modified == 'true' if: steps.git-check.outputs.modified == 'true'

View File

@ -108,17 +108,17 @@ jobs:
run: yarn files:set-notes run: yarn files:set-notes
- name: Authenticate to Google Cloud - name: Authenticate to Google Cloud
uses: 'google-github-actions/auth@v2.1.7' uses: 'google-github-actions/auth@v2.1.8'
with: with:
credentials_json: '${{ secrets.GOOGLE_CLOUD_DL_SA }}' credentials_json: '${{ secrets.GOOGLE_CLOUD_DL_SA }}'
- name: Set up Google Cloud SDK - name: Set up Google Cloud SDK
uses: google-github-actions/setup-gcloud@v2.1.2 uses: google-github-actions/setup-gcloud@v2.1.4
with: with:
project_id: ${{ env.GOOGLE_CLOUD_PROJECT_ID }} project_id: ${{ env.GOOGLE_CLOUD_PROJECT_ID }}
- name: Upload release files to public bucket - name: Upload release files to public bucket
uses: google-github-actions/upload-cloud-storage@v2.2.1 uses: google-github-actions/upload-cloud-storage@v2.2.2
with: with:
path: out path: out
glob: '*' glob: '*'

2
.gitignore vendored
View File

@ -44,7 +44,7 @@ e2e/playwright/temp3.png
e2e/playwright/export-snapshots/* e2e/playwright/export-snapshots/*
!e2e/playwright/export-snapshots/*.png !e2e/playwright/export-snapshots/*.png
/kcl-samples
/test-results/ /test-results/
/playwright-report/ /playwright-report/
/blob-report/ /blob-report/

View File

@ -101,7 +101,7 @@ This will start the application and hot-reload on changes.
Devtools can be opened with the usual Cmd-Opt-I (Mac) or Ctrl-Shift-I (Linux and Windows). Devtools can be opened with the usual Cmd-Opt-I (Mac) or Ctrl-Shift-I (Linux and Windows).
To build, run `yarn tron:package`. To build with electron-builder, run `yarn tronb:package:dev` (or `yarn tronb:package:prod` to point to the .env.production variables)
## Checking out commits / Bisecting ## Checking out commits / Bisecting

View File

@ -4,14 +4,16 @@ excerpt: "Import a CAD file."
layout: manual layout: manual
--- ---
**WARNING:** This function is deprecated.
Import a CAD file. Import a CAD file.
**DEPRECATED** Prefer to use import statements.
For formats lacking unit data (such as STL, OBJ, or PLY files), the default unit of measurement is millimeters. Alternatively you may specify the unit by passing your desired measurement unit in the options parameter. When importing a GLTF file, the bin file will be imported as well. Import paths are relative to the current project directory. For formats lacking unit data (such as STL, OBJ, or PLY files), the default unit of measurement is millimeters. Alternatively you may specify the unit by passing your desired measurement unit in the options parameter. When importing a GLTF file, the bin file will be imported as well. Import paths are relative to the current project directory.
Note: The import command currently only works when using the native Modeling App. Note: The import command currently only works when using the native Modeling App.
For importing KCL functions using the `import` statement, see the docs on [KCL modules](/docs/kcl/modules).
```js ```js
import(file_path: String, options?: ImportFormat) -> ImportedGeometry import(file_path: String, options?: ImportFormat) -> ImportedGeometry
``` ```

View File

@ -51,7 +51,6 @@ layout: manual
* [`helixRevolutions`](kcl/helixRevolutions) * [`helixRevolutions`](kcl/helixRevolutions)
* [`hole`](kcl/hole) * [`hole`](kcl/hole)
* [`hollow`](kcl/hollow) * [`hollow`](kcl/hollow)
* [`import`](kcl/import)
* [`inch`](kcl/inch) * [`inch`](kcl/inch)
* [`lastSegX`](kcl/lastSegX) * [`lastSegX`](kcl/lastSegX)
* [`lastSegY`](kcl/lastSegY) * [`lastSegY`](kcl/lastSegY)

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -92765,7 +92765,7 @@
{ {
"name": "import", "name": "import",
"summary": "Import a CAD file.", "summary": "Import a CAD file.",
"description": "For formats lacking unit data (such as STL, OBJ, or PLY files), the default unit of measurement is millimeters. Alternatively you may specify the unit by passing your desired measurement unit in the options parameter. When importing a GLTF file, the bin file will be imported as well. Import paths are relative to the current project directory.\n\nNote: The import command currently only works when using the native Modeling App.\n\nFor importing KCL functions using the `import` statement, see the docs on [KCL modules](/docs/kcl/modules).", "description": "**DEPRECATED** Prefer to use import statements.\n\nFor formats lacking unit data (such as STL, OBJ, or PLY files), the default unit of measurement is millimeters. Alternatively you may specify the unit by passing your desired measurement unit in the options parameter. When importing a GLTF file, the bin file will be imported as well. Import paths are relative to the current project directory.\n\nNote: The import command currently only works when using the native Modeling App.",
"tags": [], "tags": [],
"keywordArguments": false, "keywordArguments": false,
"args": [ "args": [
@ -93168,7 +93168,7 @@
"labelRequired": true "labelRequired": true
}, },
"unpublished": false, "unpublished": false,
"deprecated": false, "deprecated": true,
"examples": [ "examples": [
"model = import(\"tests/inputs/cube.obj\")", "model = import(\"tests/inputs/cube.obj\")",
"model = import(\"tests/inputs/cube.obj\", { format = \"obj\", units = \"m\" })", "model = import(\"tests/inputs/cube.obj\", { format = \"obj\", units = \"m\" })",

View File

@ -1,7 +1,8 @@
import { test, expect } from './zoo-test' import { test, expect } from './zoo-test'
import * as fsp from 'fs/promises'
import { getUtils } from './test-utils' import { executorInputPath, getUtils } from './test-utils'
import { KCL_DEFAULT_LENGTH } from 'lib/constants' import { KCL_DEFAULT_LENGTH } from 'lib/constants'
import path from 'path'
test.describe('Command bar tests', () => { test.describe('Command bar tests', () => {
test('Extrude from command bar selects extrude line after', async ({ test('Extrude from command bar selects extrude line after', async ({
@ -305,4 +306,132 @@ test.describe('Command bar tests', () => {
await arcToolCommand.click() await arcToolCommand.click()
await expect(arcToolButton).toHaveAttribute('aria-pressed', 'true') await expect(arcToolButton).toHaveAttribute('aria-pressed', 'true')
}) })
test(`Reacts to query param to open "import from URL" command`, async ({
page,
cmdBar,
editor,
homePage,
}) => {
await test.step(`Prepare and navigate to home page with query params`, async () => {
const targetURL = `?create-file&name=test&units=mm&code=ZXh0cnVzaW9uRGlzdGFuY2UgPSAxMg%3D%3D&ask-open-desktop`
await homePage.expectState({
projectCards: [],
sortBy: 'last-modified-desc',
})
await page.goto(page.url() + targetURL)
expect(page.url()).toContain(targetURL)
})
await test.step(`Submit the command`, async () => {
await cmdBar.expectState({
stage: 'arguments',
commandName: 'Import file from URL',
currentArgKey: 'method',
currentArgValue: '',
headerArguments: {
Method: '',
Name: 'test',
Code: '1 line',
},
highlightedHeaderArg: 'method',
})
await cmdBar.selectOption({ name: 'New Project' }).click()
await cmdBar.expectState({
stage: 'review',
commandName: 'Import file from URL',
headerArguments: {
Method: 'New project',
Name: 'test',
Code: '1 line',
},
})
await cmdBar.progressCmdBar()
})
await test.step(`Ensure we created the project and are in the modeling scene`, async () => {
await editor.expectEditor.toContain('extrusionDistance = 12')
})
})
test(`"import from URL" can add to existing project`, async ({
page,
cmdBar,
editor,
homePage,
toolbar,
context,
}) => {
await context.folderSetupFn(async (dir) => {
const testProjectDir = path.join(dir, 'testProjectDir')
await Promise.all([fsp.mkdir(testProjectDir, { recursive: true })])
await Promise.all([
fsp.copyFile(
executorInputPath('cylinder.kcl'),
path.join(testProjectDir, 'main.kcl')
),
])
})
await test.step(`Prepare and navigate to home page with query params`, async () => {
const targetURL = `?create-file&name=test&units=mm&code=ZXh0cnVzaW9uRGlzdGFuY2UgPSAxMg%3D%3D&ask-open-desktop`
await homePage.expectState({
projectCards: [
{
fileCount: 1,
title: 'testProjectDir',
},
],
sortBy: 'last-modified-desc',
})
await page.goto(page.url() + targetURL)
expect(page.url()).toContain(targetURL)
})
await test.step(`Submit the command`, async () => {
await cmdBar.expectState({
stage: 'arguments',
commandName: 'Import file from URL',
currentArgKey: 'method',
currentArgValue: '',
headerArguments: {
Method: '',
Name: 'test',
Code: '1 line',
},
highlightedHeaderArg: 'method',
})
await cmdBar.selectOption({ name: 'Existing Project' }).click()
await cmdBar.expectState({
stage: 'arguments',
commandName: 'Import file from URL',
currentArgKey: 'projectName',
currentArgValue: '',
headerArguments: {
Method: 'Existing project',
Name: 'test',
ProjectName: '',
Code: '1 line',
},
highlightedHeaderArg: 'projectName',
})
await cmdBar.selectOption({ name: 'testProjectDir' }).click()
await cmdBar.expectState({
stage: 'review',
commandName: 'Import file from URL',
headerArguments: {
Method: 'Existing project',
ProjectName: 'testProjectDir',
Name: 'test',
Code: '1 line',
},
})
await cmdBar.progressCmdBar()
})
await test.step(`Ensure we created the project and are in the modeling scene`, async () => {
await editor.expectEditor.toContain('extrusionDistance = 12')
await toolbar.openPane('files')
await toolbar.expectFileTreeState(['main.kcl', 'test.kcl'])
})
})
}) })

View File

@ -4,7 +4,6 @@ import { expect } from '@playwright/test'
type CmdBarSerialised = type CmdBarSerialised =
| { | {
stage: 'commandBarClosed' stage: 'commandBarClosed'
// TODO no more properties needed but needs to be implemented in _serialiseCmdBar
} }
| { | {
stage: 'pickCommand' stage: 'pickCommand'
@ -37,6 +36,9 @@ export class CmdBarFixture {
} }
private _serialiseCmdBar = async (): Promise<CmdBarSerialised> => { private _serialiseCmdBar = async (): Promise<CmdBarSerialised> => {
if (!(await this.page.getByTestId('command-bar-wrapper').isVisible())) {
return { stage: 'commandBarClosed' }
}
const reviewForm = this.page.locator('#review-form') const reviewForm = this.page.locator('#review-form')
const getHeaderArgs = async () => { const getHeaderArgs = async () => {
const inputs = await this.page.getByTestId('cmd-bar-input-tab').all() const inputs = await this.page.getByTestId('cmd-bar-input-tab').all()
@ -151,4 +153,11 @@ export class CmdBarFixture {
chooseCommand = async (commandName: string) => { chooseCommand = async (commandName: string) => {
await this.cmdOptions.getByText(commandName).click() await this.cmdOptions.getByText(commandName).click()
} }
/**
* Select an option from the command bar
*/
selectOption = (options: Parameters<typeof this.page.getByRole>[1]) => {
return this.page.getByRole('option', options)
}
} }

View File

@ -18,6 +18,7 @@ export class ToolbarFixture {
filletButton!: Locator filletButton!: Locator
chamferButton!: Locator chamferButton!: Locator
shellButton!: Locator shellButton!: Locator
revolveButton!: Locator
offsetPlaneButton!: Locator offsetPlaneButton!: Locator
startSketchBtn!: Locator startSketchBtn!: Locator
lineBtn!: Locator lineBtn!: Locator
@ -47,6 +48,7 @@ export class ToolbarFixture {
this.filletButton = page.getByTestId('fillet3d') this.filletButton = page.getByTestId('fillet3d')
this.chamferButton = page.getByTestId('chamfer3d') this.chamferButton = page.getByTestId('chamfer3d')
this.shellButton = page.getByTestId('shell') this.shellButton = page.getByTestId('shell')
this.revolveButton = page.getByTestId('revolve')
this.offsetPlaneButton = page.getByTestId('plane-offset') this.offsetPlaneButton = page.getByTestId('plane-offset')
this.startSketchBtn = page.getByTestId('sketch') this.startSketchBtn = page.getByTestId('sketch')
this.lineBtn = page.getByTestId('line') this.lineBtn = page.getByTestId('line')

View File

@ -1078,7 +1078,7 @@ sketch002 = startSketchOn('XZ')
await page.waitForTimeout(500) await page.waitForTimeout(500)
await cmdBar.progressCmdBar() await cmdBar.progressCmdBar()
await expect( await expect(
page.getByText('Unable to sweep with the provided selection') page.getByText('Unable to sweep with the current selection. Reason:')
).toBeVisible() ).toBeVisible()
}) })
}) })
@ -1183,7 +1183,7 @@ extrude001 = extrude(-12, sketch001)
currentArgKey: 'radius', currentArgKey: 'radius',
currentArgValue: '5', currentArgValue: '5',
headerArguments: { headerArguments: {
Selection: '1 face', Selection: '1 segment',
Radius: '', Radius: '',
}, },
stage: 'arguments', stage: 'arguments',
@ -1192,7 +1192,7 @@ extrude001 = extrude(-12, sketch001)
await cmdBar.expectState({ await cmdBar.expectState({
commandName: 'Fillet', commandName: 'Fillet',
headerArguments: { headerArguments: {
Selection: '1 face', Selection: '1 segment',
Radius: '5', Radius: '5',
}, },
stage: 'review', stage: 'review',
@ -1296,6 +1296,167 @@ extrude001 = extrude(-12, sketch001)
lowTolerance lowTolerance
) )
}) })
// Test 3: Delete fillets
await test.step('Delete fillet via feature tree selection', async () => {
await test.step('Open Feature Tree Pane', async () => {
await toolbar.openPane('feature-tree')
await page.waitForTimeout(500)
})
await test.step('Delete fillet via feature tree selection', async () => {
await editor.expectEditor.toContain(secondFilletDeclaration)
const operationButton = await toolbar.getFeatureTreeOperation('Fillet', 1)
await operationButton.click({ button: 'left' })
await page.keyboard.press('Backspace')
await page.waitForTimeout(500)
await scene.expectPixelColor(edgeColorWhite, secondEdgeLocation, 15) // deleted
await editor.expectEditor.not.toContain(secondFilletDeclaration)
await scene.expectPixelColor(filletColor, firstEdgeLocation, 15) // stayed
})
})
})
test(`Fillet point-and-click delete`, async ({
context,
page,
homePage,
scene,
editor,
toolbar,
}) => {
// Code samples
const initialCode = `sketch001 = startSketchOn('XY')
|> startProfileAt([-12, -6], %)
|> line([0, 12], %)
|> line([24, 0], %, $seg02)
|> line([0, -12], %)
|> lineTo([profileStartX(%), profileStartY(%)], %, $seg01)
|> close(%)
extrude001 = extrude(-12, sketch001)
|> fillet({ radius = 5, tags = [seg01] }, %) // fillet01
|> fillet({ radius = 5, tags = [seg02] }, %) // fillet02
fillet03 = fillet({ radius = 5, tags = [getOppositeEdge(seg01)]}, extrude001)
fillet04 = fillet({ radius = 5, tags = [getOppositeEdge(seg02)]}, extrude001)
`
const pipedFilletDeclaration = 'fillet({ radius = 5, tags = [seg01] }, %)'
const secondPipedFilletDeclaration =
'fillet({ radius = 5, tags = [seg02] }, %)'
const standaloneFilletDeclaration =
'fillet03 = fillet({ radius = 5, tags = [getOppositeEdge(seg01)]}, extrude001)'
const secondStandaloneFilletDeclaration =
'fillet04 = fillet({ radius = 5, tags = [getOppositeEdge(seg02)]}, extrude001)'
// Locators
const pipedFilletEdgeLocation = { x: 600, y: 193 }
const standaloneFilletEdgeLocation = { x: 600, y: 383 }
const bodyLocation = { x: 630, y: 290 }
// Colors
const edgeColorWhite: [number, number, number] = [248, 248, 248]
const bodyColor: [number, number, number] = [155, 155, 155]
const filletColor: [number, number, number] = [127, 127, 127]
const backgroundColor: [number, number, number] = [30, 30, 30]
const lowTolerance = 20
const highTolerance = 40
// Setup
await test.step(`Initial test setup`, async () => {
await context.addInitScript((initialCode) => {
localStorage.setItem('persistCode', initialCode)
}, initialCode)
await page.setBodyDimensions({ width: 1000, height: 500 })
await homePage.goToModelingScene()
// verify modeling scene is loaded
await scene.expectPixelColor(
backgroundColor,
standaloneFilletEdgeLocation,
lowTolerance
)
// wait for stream to load
await scene.expectPixelColor(bodyColor, bodyLocation, highTolerance)
})
// Test
await test.step('Delete fillet via feature tree selection', async () => {
await test.step('Open Feature Tree Pane', async () => {
await toolbar.openPane('feature-tree')
await page.waitForTimeout(500)
})
await test.step('Delete piped fillet via feature tree selection', async () => {
await test.step('Verify all fillets are present in the editor', async () => {
await editor.expectEditor.toContain(pipedFilletDeclaration)
await editor.expectEditor.toContain(secondPipedFilletDeclaration)
await editor.expectEditor.toContain(standaloneFilletDeclaration)
await editor.expectEditor.toContain(secondStandaloneFilletDeclaration)
})
await test.step('Verify test fillets are present in the scene', async () => {
await scene.expectPixelColor(
filletColor,
pipedFilletEdgeLocation,
lowTolerance
)
await scene.expectPixelColor(
backgroundColor,
standaloneFilletEdgeLocation,
lowTolerance
)
})
await test.step('Delete piped fillet', async () => {
const operationButton = await toolbar.getFeatureTreeOperation(
'Fillet',
0
)
await operationButton.click({ button: 'left' })
await page.keyboard.press('Backspace')
await page.waitForTimeout(500)
})
await test.step('Verify piped fillet is deleted but other fillets are not (in the editor)', async () => {
await editor.expectEditor.not.toContain(pipedFilletDeclaration)
await editor.expectEditor.toContain(secondPipedFilletDeclaration)
await editor.expectEditor.toContain(standaloneFilletDeclaration)
await editor.expectEditor.toContain(secondStandaloneFilletDeclaration)
})
await test.step('Verify piped fillet is deleted but non-piped is not (in the scene)', async () => {
await scene.expectPixelColor(
edgeColorWhite, // you see edge because fillet is deleted
pipedFilletEdgeLocation,
lowTolerance
)
await scene.expectPixelColor(
backgroundColor, // you see background because fillet is not deleted
standaloneFilletEdgeLocation,
lowTolerance
)
})
})
await test.step('Delete non-piped fillet via feature tree selection', async () => {
await test.step('Delete non-piped fillet', async () => {
const operationButton = await toolbar.getFeatureTreeOperation(
'Fillet',
1
)
await operationButton.click({ button: 'left' })
await page.keyboard.press('Backspace')
await page.waitForTimeout(500)
})
await test.step('Verify non-piped fillet is deleted but other two fillets are not (in the editor)', async () => {
await editor.expectEditor.toContain(secondPipedFilletDeclaration)
await editor.expectEditor.not.toContain(standaloneFilletDeclaration)
await editor.expectEditor.toContain(secondStandaloneFilletDeclaration)
})
await test.step('Verify non-piped fillet is deleted but piped is not (in the scene)', async () => {
await scene.expectPixelColor(
edgeColorWhite,
standaloneFilletEdgeLocation,
lowTolerance
)
})
})
})
}) })
test(`Chamfer point-and-click`, async ({ test(`Chamfer point-and-click`, async ({
@ -1398,7 +1559,7 @@ extrude001 = extrude(-12, sketch001)
currentArgKey: 'length', currentArgKey: 'length',
currentArgValue: '5', currentArgValue: '5',
headerArguments: { headerArguments: {
Selection: '1 face', Selection: '1 segment',
Length: '', Length: '',
}, },
stage: 'arguments', stage: 'arguments',
@ -1407,7 +1568,7 @@ extrude001 = extrude(-12, sketch001)
await cmdBar.expectState({ await cmdBar.expectState({
commandName: 'Chamfer', commandName: 'Chamfer',
headerArguments: { headerArguments: {
Selection: '1 face', Selection: '1 segment',
Length: '5', Length: '5',
}, },
stage: 'review', stage: 'review',
@ -1511,6 +1672,163 @@ extrude001 = extrude(-12, sketch001)
lowTolerance lowTolerance
) )
}) })
// Test 3: Delete chamfer via feature tree selection
await test.step('Open Feature Tree Pane', async () => {
await toolbar.openPane('feature-tree')
await page.waitForTimeout(500)
})
await test.step('Delete chamfer via feature tree selection', async () => {
const operationButton = await toolbar.getFeatureTreeOperation('Chamfer', 1)
await operationButton.click({ button: 'left' })
await page.keyboard.press('Backspace')
await page.waitForTimeout(500)
await scene.expectPixelColor(edgeColorWhite, secondEdgeLocation, 15) // deleted
await scene.expectPixelColor(chamferColor, firstEdgeLocation, 15) // stayed
})
})
test(`Chamfer point-and-click delete`, async ({
context,
page,
homePage,
scene,
editor,
toolbar,
}) => {
// Code samples
const initialCode = `sketch001 = startSketchOn('XY')
|> startProfileAt([-12, -6], %)
|> line([0, 12], %)
|> line([24, 0], %, $seg02)
|> line([0, -12], %)
|> lineTo([profileStartX(%), profileStartY(%)], %, $seg01)
|> close(%)
extrude001 = extrude(-12, sketch001)
|> chamfer({ length = 5, tags = [seg01] }, %) // chamfer01
|> chamfer({ length = 5, tags = [seg02] }, %) // chamfer02
chamfer03 = chamfer({ length = 5, tags = [getOppositeEdge(seg01)]}, extrude001)
chamfer04 = chamfer({ length = 5, tags = [getOppositeEdge(seg02)]}, extrude001)
`
const pipedChamferDeclaration = 'chamfer({ length = 5, tags = [seg01] }, %)'
const secondPipedChamferDeclaration =
'chamfer({ length = 5, tags = [seg02] }, %)'
const standaloneChamferDeclaration =
'chamfer03 = chamfer({ length = 5, tags = [getOppositeEdge(seg01)]}, extrude001)'
const secondStandaloneChamferDeclaration =
'chamfer04 = chamfer({ length = 5, tags = [getOppositeEdge(seg02)]}, extrude001)'
// Locators
const pipedChamferEdgeLocation = { x: 600, y: 193 }
const standaloneChamferEdgeLocation = { x: 600, y: 383 }
const bodyLocation = { x: 630, y: 290 }
// Colors
const edgeColorWhite: [number, number, number] = [248, 248, 248]
const bodyColor: [number, number, number] = [155, 155, 155]
const chamferColor: [number, number, number] = [168, 168, 168]
const backgroundColor: [number, number, number] = [30, 30, 30]
const lowTolerance = 20
const highTolerance = 40
// Setup
await test.step(`Initial test setup`, async () => {
await context.addInitScript((initialCode) => {
localStorage.setItem('persistCode', initialCode)
}, initialCode)
await page.setBodyDimensions({ width: 1000, height: 500 })
await homePage.goToModelingScene()
// verify modeling scene is loaded
await scene.expectPixelColor(
backgroundColor,
standaloneChamferEdgeLocation,
lowTolerance
)
// wait for stream to load
await scene.expectPixelColor(bodyColor, bodyLocation, highTolerance)
})
// Test
await test.step('Delete chamfer via feature tree selection', async () => {
await test.step('Open Feature Tree Pane', async () => {
await toolbar.openPane('feature-tree')
await page.waitForTimeout(500)
})
await test.step('Delete piped chamfer via feature tree selection', async () => {
await test.step('Verify all chamfers are present in the editor', async () => {
await editor.expectEditor.toContain(pipedChamferDeclaration)
await editor.expectEditor.toContain(secondPipedChamferDeclaration)
await editor.expectEditor.toContain(standaloneChamferDeclaration)
await editor.expectEditor.toContain(secondStandaloneChamferDeclaration)
})
await test.step('Verify test chamfers are present in the scene', async () => {
await scene.expectPixelColor(
chamferColor,
pipedChamferEdgeLocation,
lowTolerance
)
await scene.expectPixelColor(
backgroundColor,
standaloneChamferEdgeLocation,
lowTolerance
)
})
await test.step('Delete piped chamfer', async () => {
const operationButton = await toolbar.getFeatureTreeOperation(
'Chamfer',
0
)
await operationButton.click({ button: 'left' })
await page.keyboard.press('Backspace')
await page.waitForTimeout(500)
})
await test.step('Verify piped chamfer is deleted but other chamfers are not (in the editor)', async () => {
await editor.expectEditor.not.toContain(pipedChamferDeclaration)
await editor.expectEditor.toContain(secondPipedChamferDeclaration)
await editor.expectEditor.toContain(standaloneChamferDeclaration)
await editor.expectEditor.toContain(secondStandaloneChamferDeclaration)
})
await test.step('Verify piped chamfer is deleted but non-piped is not (in the scene)', async () => {
await scene.expectPixelColor(
edgeColorWhite, // you see edge color because chamfer is deleted
pipedChamferEdgeLocation,
lowTolerance
)
await scene.expectPixelColor(
backgroundColor, // you see background color instead of edge because it's chamfered
standaloneChamferEdgeLocation,
lowTolerance
)
})
})
await test.step('Delete non-piped chamfer via feature tree selection', async () => {
await test.step('Delete non-piped chamfer', async () => {
const operationButton = await toolbar.getFeatureTreeOperation(
'Chamfer',
1
)
await operationButton.click({ button: 'left' })
await page.keyboard.press('Backspace')
await page.waitForTimeout(500)
})
await test.step('Verify non-piped chamfer is deleted but other two chamfers are not (in the editor)', async () => {
await editor.expectEditor.toContain(secondPipedChamferDeclaration)
await editor.expectEditor.not.toContain(standaloneChamferDeclaration)
await editor.expectEditor.toContain(secondStandaloneChamferDeclaration)
})
await test.step('Verify non-piped chamfer is deleted but piped is not (in the scene)', async () => {
await scene.expectPixelColor(
edgeColorWhite,
standaloneChamferEdgeLocation,
lowTolerance
)
})
})
})
}) })
const shellPointAndClickCapCases = [ const shellPointAndClickCapCases = [
@ -1846,8 +2164,176 @@ sweep001 = sweep({ path = sketch002 }, sketch001)
await page.waitForTimeout(500) await page.waitForTimeout(500)
await cmdBar.progressCmdBar() await cmdBar.progressCmdBar()
await expect( await expect(
page.getByText('Unable to shell with the provided selection') page.getByText('Unable to shell with the current selection. Reason:')
).toBeVisible() ).toBeVisible()
await page.waitForTimeout(1000) await page.waitForTimeout(1000)
}) })
}) })
test.describe('Revolve point and click workflows', () => {
test('Base case workflow, auto spam continue in command bar', async ({
context,
page,
homePage,
scene,
editor,
toolbar,
cmdBar,
}) => {
const initialCode = `
sketch001 = startSketchOn('XZ')
|> startProfileAt([-100.0, 100.0], %)
|> angledLine([0, 200.0], %, $rectangleSegmentA001)
|> angledLine([segAng(rectangleSegmentA001) - 90, 200], %, $rectangleSegmentB001)
|> angledLine([
segAng(rectangleSegmentA001),
-segLen(rectangleSegmentA001)
], %, $rectangleSegmentC001)
|> lineTo([profileStartX(%), profileStartY(%)], %)
|> close(%)
extrude001 = extrude(200, sketch001)
sketch002 = startSketchOn(extrude001, rectangleSegmentA001)
|> startProfileAt([-66.77, 84.81], %)
|> angledLine([180, 27.08], %, $rectangleSegmentA002)
|> angledLine([
segAng(rectangleSegmentA002) - 90,
27.8
], %, $rectangleSegmentB002)
|> angledLine([
segAng(rectangleSegmentA002),
-segLen(rectangleSegmentA002)
], %, $rectangleSegmentC002)
|> lineTo([profileStartX(%), profileStartY(%)], %)
|> close(%)
`
await context.addInitScript((initialCode) => {
localStorage.setItem('persistCode', initialCode)
}, initialCode)
await page.setBodyDimensions({ width: 1000, height: 500 })
await homePage.goToModelingScene()
await scene.waitForExecutionDone()
// select line of code
const codeToSelecton = `segAng(rectangleSegmentA002) - 90,`
// revolve
await page.getByText(codeToSelecton).click()
await toolbar.revolveButton.click()
await cmdBar.progressCmdBar()
await cmdBar.progressCmdBar()
await cmdBar.progressCmdBar()
await cmdBar.progressCmdBar()
const newCodeToFind = `revolve001 = revolve({ angle = 360, axis = 'X' }, sketch002)`
expect(editor.expectEditor.toContain(newCodeToFind)).toBeTruthy()
})
test('revolve surface around edge from an extruded solid2d', async ({
context,
page,
homePage,
scene,
editor,
toolbar,
cmdBar,
}) => {
const initialCode = `
sketch001 = startSketchOn('XZ')
|> startProfileAt([-102.57, 101.72], %)
|> angledLine([0, 202.6], %, $rectangleSegmentA001)
|> angledLine([
segAng(rectangleSegmentA001) - 90,
202.6
], %, $rectangleSegmentB001)
|> angledLine([
segAng(rectangleSegmentA001),
-segLen(rectangleSegmentA001)
], %, $rectangleSegmentC001)
|> lineTo([profileStartX(%), profileStartY(%)], %)
|> close(%)
extrude001 = extrude(50, sketch001)
sketch002 = startSketchOn(extrude001, rectangleSegmentA001)
|> circle({
center = [-11.34, 10.0],
radius = 8.69
}, %)
`
await context.addInitScript((initialCode) => {
localStorage.setItem('persistCode', initialCode)
}, initialCode)
await page.setBodyDimensions({ width: 1000, height: 500 })
await homePage.goToModelingScene()
await scene.waitForExecutionDone()
// select line of code
const codeToSelecton = `center = [-11.34, 10.0]`
// revolve
await page.getByText(codeToSelecton).click()
await toolbar.revolveButton.click()
await page.getByText('Edge', { exact: true }).click()
const lineCodeToSelection = `|> angledLine([0, 202.6], %, $rectangleSegmentA001)`
await page.getByText(lineCodeToSelection).click()
await cmdBar.progressCmdBar()
await cmdBar.progressCmdBar()
await cmdBar.progressCmdBar()
await cmdBar.progressCmdBar()
await cmdBar.progressCmdBar()
const newCodeToFind = `revolve001 = revolve({angle = 360, axis = getOppositeEdge(rectangleSegmentA001)}, sketch002) `
expect(editor.expectEditor.toContain(newCodeToFind)).toBeTruthy()
})
test('revolve sketch circle around line segment from startProfileAt sketch', async ({
context,
page,
homePage,
scene,
editor,
toolbar,
cmdBar,
}) => {
const initialCode = `
sketch002 = startSketchOn('XY')
|> startProfileAt([-2.02, 1.79], %)
|> xLine(2.6, %)
sketch001 = startSketchOn('-XY')
|> startProfileAt([-0.48, 1.25], %)
|> angledLine([0, 2.38], %, $rectangleSegmentA001)
|> angledLine([segAng(rectangleSegmentA001) - 90, 2.4], %, $rectangleSegmentB001)
|> angledLine([
segAng(rectangleSegmentA001),
-segLen(rectangleSegmentA001)
], %, $rectangleSegmentC001)
|> lineTo([profileStartX(%), profileStartY(%)], %)
|> close(%)
extrude001 = extrude(5, sketch001)
sketch003 = startSketchOn(extrude001, 'START')
|> circle({
center = [-0.69, 0.56],
radius = 0.28
}, %)
`
await context.addInitScript((initialCode) => {
localStorage.setItem('persistCode', initialCode)
}, initialCode)
await page.setBodyDimensions({ width: 1000, height: 500 })
await homePage.goToModelingScene()
await scene.waitForExecutionDone()
// select line of code
const codeToSelecton = `center = [-0.69, 0.56]`
// revolve
await page.getByText(codeToSelecton).click()
await toolbar.revolveButton.click()
await page.getByText('Edge', { exact: true }).click()
const lineCodeToSelection = `|> xLine(2.6, %)`
await page.getByText(lineCodeToSelection).click()
await cmdBar.progressCmdBar()
await cmdBar.progressCmdBar()
await cmdBar.progressCmdBar()
await cmdBar.progressCmdBar()
await cmdBar.progressCmdBar()
const newCodeToFind = `revolve001 = revolve({ angle = 360, axis = seg01 }, sketch003)`
expect(editor.expectEditor.toContain(newCodeToFind)).toBeTruthy()
})
})

View File

@ -572,7 +572,7 @@ test(
fs.utimesSync(`${dir}/lego/main.kcl`, _1995, _1995) fs.utimesSync(`${dir}/lego/main.kcl`, _1995, _1995)
}) })
await page.setBodyDimensions({ width: 1200, height: 500 }) await page.setBodyDimensions({ width: 1200, height: 600 })
page.on('console', console.log) page.on('console', console.log)
@ -1525,7 +1525,7 @@ extrude001 = extrude(200, sketch001)`)
test( test(
'Opening a project should successfully load the stream, (regression test that this also works when switching between projects)', 'Opening a project should successfully load the stream, (regression test that this also works when switching between projects)',
{ tag: '@electron' }, { tag: '@electron' },
async ({ context, page }, testInfo) => { async ({ context, page, cmdBar, homePage }, testInfo) => {
await context.folderSetupFn(async (dir) => { await context.folderSetupFn(async (dir) => {
await Promise.all([ await Promise.all([
fsp.mkdir(path.join(dir, 'router-template-slate'), { recursive: true }), fsp.mkdir(path.join(dir, 'router-template-slate'), { recursive: true }),
@ -1563,11 +1563,38 @@ test(
const pointOnModel = { x: 630, y: 280 } const pointOnModel = { x: 630, y: 280 }
await test.step('Opening the bracket project should load the stream', async () => { await test.step('Opening the bracket project via command palette should load the stream', async () => {
// expect to see the text bracket await homePage.expectState({
await expect(page.getByText('bracket')).toBeVisible() projectCards: [
{
title: 'bracket',
fileCount: 1,
},
{
title: 'router-template-slate',
fileCount: 1,
},
],
sortBy: 'last-modified-desc',
})
await page.getByText('bracket').click() await cmdBar.openCmdBar()
await cmdBar.chooseCommand('open project')
await cmdBar.expectState({
stage: 'arguments',
commandName: 'Open project',
currentArgKey: 'name',
currentArgValue: '',
headerArguments: {
Name: '',
},
highlightedHeaderArg: 'name',
})
await cmdBar.argumentInput.fill('brac')
await cmdBar.progressCmdBar()
await cmdBar.expectState({
stage: 'commandBarClosed',
})
await u.waitForPageLoad() await u.waitForPageLoad()
@ -1588,7 +1615,7 @@ test(
await expect(page.getByText('Create project')).toBeVisible() await expect(page.getByText('Create project')).toBeVisible()
}) })
await test.step('Opening the router-template project should load the stream', async () => { await test.step('Opening the router-template project via link should load the stream', async () => {
// expect to see the text bracket // expect to see the text bracket
await expect(page.getByText('router-template-slate')).toBeVisible() await expect(page.getByText('router-template-slate')).toBeVisible()
@ -1605,16 +1632,26 @@ test(
.toBeLessThan(15) .toBeLessThan(15)
}) })
await test.step('Opening the router-template project should load the stream', async () => { await test.step('The projects on the home page should still be normal', async () => {
await page.getByTestId('project-sidebar-toggle').click() await page.getByTestId('project-sidebar-toggle').click()
await expect( await expect(
page.getByRole('button', { name: 'Go to Home' }) page.getByRole('button', { name: 'Go to Home' })
).toBeVisible() ).toBeVisible()
await page.getByRole('button', { name: 'Go to Home' }).click() await page.getByRole('button', { name: 'Go to Home' }).click()
await expect(page.getByRole('link', { name: 'bracket' })).toBeVisible() await homePage.expectState({
await expect(page.getByText('router-template-slate')).toBeVisible() projectCards: [
await expect(page.getByText('Create project')).toBeVisible() {
title: 'bracket',
fileCount: 1,
},
{
title: 'router-template-slate',
fileCount: 1,
},
],
sortBy: 'last-modified-desc',
})
}) })
} }
) )

View File

@ -34,7 +34,7 @@ test.describe('Sketch tests', () => {
screwRadius = 3 screwRadius = 3
wireRadius = 2 wireRadius = 2
wireOffset = 0.5 wireOffset = 0.5
screwHole = startSketchOn('XY') screwHole = startSketchOn('XY')
${startProfileAt1} ${startProfileAt1}
|> arc({ |> arc({
@ -42,7 +42,7 @@ test.describe('Sketch tests', () => {
angleStart = 0, angleStart = 0,
angleEnd = 360 angleEnd = 360
}, %) }, %)
part001 = startSketchOn('XY') part001 = startSketchOn('XY')
${startProfileAt2} ${startProfileAt2}
|> xLine(width * .5, %) |> xLine(width * .5, %)
@ -51,7 +51,7 @@ test.describe('Sketch tests', () => {
|> close(%) |> close(%)
|> hole(screwHole, %) |> hole(screwHole, %)
|> extrude(thickness, %) |> extrude(thickness, %)
part002 = startSketchOn('-XZ') part002 = startSketchOn('-XZ')
${startProfileAt3} ${startProfileAt3}
|> xLine(width / 4, %) |> xLine(width / 4, %)
@ -99,6 +99,7 @@ test.describe('Sketch tests', () => {
test('Can delete most of a sketch and the line tool will still work', async ({ test('Can delete most of a sketch and the line tool will still work', async ({
page, page,
homePage, homePage,
scene,
}) => { }) => {
const u = await getUtils(page) const u = await getUtils(page)
await page.addInitScript(async () => { await page.addInitScript(async () => {
@ -112,12 +113,13 @@ test.describe('Sketch tests', () => {
}) })
await homePage.goToModelingScene() await homePage.goToModelingScene()
await scene.waitForExecutionDone()
await expect(async () => { await expect(async () => {
await page.getByText('tangentialArcTo([24.95, -5.38], %)').click() await page.getByText('tangentialArcTo([24.95, -5.38], %)').click()
await expect( await expect(
page.getByRole('button', { name: 'Edit Sketch' }) page.getByRole('button', { name: 'Edit Sketch' })
).toBeEnabled({ timeout: 1000 }) ).toBeEnabled({ timeout: 2000 })
await page.getByRole('button', { name: 'Edit Sketch' }).click() await page.getByRole('button', { name: 'Edit Sketch' }).click()
}).toPass({ timeout: 40_000, intervals: [1_000] }) }).toPass({ timeout: 40_000, intervals: [1_000] })
@ -884,7 +886,7 @@ test.describe('Sketch tests', () => {
// sketch selection should already have been made. "Selection: 1 face" only show up when the selection has been made already // sketch selection should already have been made. "Selection: 1 face" only show up when the selection has been made already
// otherwise the cmdbar would be waiting for a selection. // otherwise the cmdbar would be waiting for a selection.
await expect( await expect(
page.getByRole('button', { name: 'selection : 1 face', exact: false }) page.getByRole('button', { name: 'selection : 1 segment', exact: false })
).toBeVisible({ ).toBeVisible({
timeout: 10_000, timeout: 10_000,
}) })
@ -1063,7 +1065,7 @@ test.describe('Sketch tests', () => {
`lugHeadLength = 0.25 `lugHeadLength = 0.25
lugDiameter = 0.5 lugDiameter = 0.5
lugLength = 2 lugLength = 2
fn lug = (origin, length, diameter, plane) => { fn lug = (origin, length, diameter, plane) => {
lugSketch = startSketchOn(plane) lugSketch = startSketchOn(plane)
|> startProfileAt([origin[0] + lugDiameter / 2, origin[1]], %) |> startProfileAt([origin[0] + lugDiameter / 2, origin[1]], %)
@ -1072,10 +1074,10 @@ test.describe('Sketch tests', () => {
|> yLineTo(0, %) |> yLineTo(0, %)
|> close(%) |> close(%)
|> revolve({ axis = "Y" }, %) |> revolve({ axis = "Y" }, %)
return lugSketch return lugSketch
} }
lug([0, 0], 10, .5, "XY")` lug([0, 0], 10, .5, "XY")`
) )
}) })
@ -1127,14 +1129,14 @@ test.describe('Sketch tests', () => {
`fn in2mm = (inches) => { `fn in2mm = (inches) => {
return inches * 25.4 return inches * 25.4
} }
const railTop = in2mm(.748) const railTop = in2mm(.748)
const railSide = in2mm(.024) const railSide = in2mm(.024)
const railBaseWidth = in2mm(.612) const railBaseWidth = in2mm(.612)
const railWideWidth = in2mm(.835) const railWideWidth = in2mm(.835)
const railBaseLength = in2mm(.200) const railBaseLength = in2mm(.200)
const railClampable = in2mm(.200) const railClampable = in2mm(.200)
const rail = startSketchOn('XZ') const rail = startSketchOn('XZ')
|> startProfileAt([ |> startProfileAt([
-railTop / 2, -railTop / 2,
@ -1405,3 +1407,46 @@ test.describe(`Click based selection don't brick the app when clicked out of ran
}) })
}) })
}) })
// Regression test for https://github.com/KittyCAD/modeling-app/issues/4372
test.describe('Redirecting to home page and back to the original file should clear sketch DOM elements', () => {
test('Can redirect to home page and back to original file and have a cleared DOM', async ({
context,
page,
scene,
toolbar,
editor,
homePage,
}) => {
// We seed the scene with a single offset plane
await context.addInitScript(() => {
localStorage.setItem(
'persistCode',
` sketch001 = startSketchOn('XZ')
|> startProfileAt([256.85, 14.41], %)
|> lineTo([0, 211.07], %)
`
)
})
await homePage.goToModelingScene()
await scene.waitForExecutionDone()
const [objClick] = scene.makeMouseHelpers(634, 274)
await objClick()
// Enter sketch mode
await toolbar.editSketch()
await expect(page.getByText('323.49')).toBeVisible()
// Open navigation side bar
await page.getByTestId('project-sidebar-toggle').click()
const goToHome = page.getByRole('button', {
name: 'Go to Home',
})
await goToHome.click()
await homePage.openProject('testDefault')
await expect(page.getByText('323.49')).not.toBeVisible()
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 145 KiB

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 129 KiB

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 47 KiB

View File

@ -69,7 +69,6 @@ test.describe('Testing in-app sample loading', () => {
await confirmButton.click() await confirmButton.click()
await editor.expectEditor.toContain('// ' + newSample.title) await editor.expectEditor.toContain('// ' + newSample.title)
await expect(unitsToast('in')).toBeVisible()
}) })
}) })
@ -158,7 +157,6 @@ test.describe('Testing in-app sample loading', () => {
await editor.expectEditor.toContain('// ' + sampleOne.title) await editor.expectEditor.toContain('// ' + sampleOne.title)
await expect(newlyCreatedFile(sampleOne.file)).toBeVisible() await expect(newlyCreatedFile(sampleOne.file)).toBeVisible()
await expect(projectMenuButton).toContainText(sampleOne.file) await expect(projectMenuButton).toContainText(sampleOne.file)
await expect(unitsToast('in')).toBeVisible()
}) })
await test.step(`Now overwrite the current file`, async () => { await test.step(`Now overwrite the current file`, async () => {
@ -188,7 +186,6 @@ test.describe('Testing in-app sample loading', () => {
await expect(newlyCreatedFile(sampleOne.file)).toBeVisible() await expect(newlyCreatedFile(sampleOne.file)).toBeVisible()
await expect(newlyCreatedFile(sampleTwo.file)).not.toBeVisible() await expect(newlyCreatedFile(sampleTwo.file)).not.toBeVisible()
await expect(projectMenuButton).toContainText(sampleOne.file) await expect(projectMenuButton).toContainText(sampleOne.file)
await expect(unitsToast('mm')).toBeVisible()
}) })
} }
) )

View File

@ -75,3 +75,6 @@ publish:
channel: latest channel: latest
releaseInfo: releaseInfo:
releaseNotesFile: release-notes.md releaseNotesFile: release-notes.md
protocols:
- name: Zoo Studio
schemes: ['zoo-studio']

View File

@ -9,23 +9,8 @@ const rootDir = process.cwd()
const config: ForgeConfig = { const config: ForgeConfig = {
packagerConfig: { packagerConfig: {
asar: true, asar: true,
osxSign: (process.env.BUILD_RELEASE === 'true' && {}) || undefined,
osxNotarize:
(process.env.BUILD_RELEASE === 'true' && {
appleId: process.env.APPLE_ID || '',
appleIdPassword: process.env.APPLE_PASSWORD || '',
teamId: process.env.APPLE_TEAM_ID || '',
}) ||
undefined,
executableName: 'zoo-modeling-app', executableName: 'zoo-modeling-app',
icon: path.resolve(rootDir, 'assets', 'icon'), icon: path.resolve(rootDir, 'assets', 'icon'),
protocols: [
{
name: 'Zoo Studio',
schemes: ['zoo-studio'],
},
],
extendInfo: 'Info.plist', // Information for file associations.
}, },
rebuildConfig: {}, rebuildConfig: {},
makers: [], makers: [],

1
interface.d.ts vendored
View File

@ -65,6 +65,7 @@ export interface IElectronAPI {
VITE_KC_API_WS_MODELING_URL: string VITE_KC_API_WS_MODELING_URL: string
VITE_KC_API_BASE_URL: string VITE_KC_API_BASE_URL: string
VITE_KC_SITE_BASE_URL: string VITE_KC_SITE_BASE_URL: string
VITE_KC_SITE_APP_URL: string
VITE_KC_SKIP_AUTH: string VITE_KC_SKIP_AUTH: string
VITE_KC_CONNECTION_TIMEOUT_MS: string VITE_KC_CONNECTION_TIMEOUT_MS: string
VITE_KC_DEV_TOKEN: string VITE_KC_DEV_TOKEN: string

View File

@ -103,11 +103,11 @@
"make:dev": "make dev", "make:dev": "make dev",
"generate:machine-api": "npx openapi-typescript ./openapi/machine-api.json -o src/lib/machine-api.d.ts", "generate:machine-api": "npx openapi-typescript ./openapi/machine-api.json -o src/lib/machine-api.d.ts",
"tron:start": "electron-forge start", "tron:start": "electron-forge start",
"tron:package": "electron-forge package",
"chrome:test": "PLATFORM=web NODE_ENV=development yarn playwright test --config=playwright.config.ts --project='Google Chrome' --grep-invert='@snapshot'", "chrome:test": "PLATFORM=web NODE_ENV=development yarn playwright test --config=playwright.config.ts --project='Google Chrome' --grep-invert='@snapshot'",
"tron:test": "NODE_ENV=development yarn playwright test --config=playwright.electron.config.ts --grep-invert='@snapshot'", "tronb:vite:dev": "vite build -c vite.main.config.ts -m development && vite build -c vite.preload.config.ts -m development && vite build -c vite.renderer.config.ts -m development",
"tronb:vite": "vite build -c vite.main.config.ts && vite build -c vite.preload.config.ts && vite build -c vite.renderer.config.ts", "tronb:vite:prod": "vite build -c vite.main.config.ts && vite build -c vite.preload.config.ts && vite build -c vite.renderer.config.ts",
"tronb:package": "electron-builder --config electron-builder.yml", "tronb:package:dev": "yarn tronb:vite:dev && electron-builder --config electron-builder.yml",
"tronb:package:prod": "yarn tronb:vite:prod && electron-builder --config electron-builder.yml --publish always",
"test-setup": "yarn install && yarn build:wasm", "test-setup": "yarn install && yarn build:wasm",
"test": "vitest --mode development", "test": "vitest --mode development",
"test:unit": "vitest run --mode development --exclude **/kclSamples.test.ts", "test:unit": "vitest run --mode development --exclude **/kclSamples.test.ts",
@ -116,10 +116,10 @@
"test:playwright:electron:windows": "playwright test --config=playwright.electron.config.ts --grep-invert=\"@skipWin|@snapshot\" --quiet", "test:playwright:electron:windows": "playwright test --config=playwright.electron.config.ts --grep-invert=\"@skipWin|@snapshot\" --quiet",
"test:playwright:electron:macos": "playwright test --config=playwright.electron.config.ts --grep-invert='@skipMacos|@snapshot' --quiet", "test:playwright:electron:macos": "playwright test --config=playwright.electron.config.ts --grep-invert='@skipMacos|@snapshot' --quiet",
"test:playwright:electron:ubuntu": "playwright test --config=playwright.electron.config.ts --grep-invert='@skipLinux|@snapshot' --quiet", "test:playwright:electron:ubuntu": "playwright test --config=playwright.electron.config.ts --grep-invert='@skipLinux|@snapshot' --quiet",
"test:playwright:electron:local": "yarn tron:package && NODE_ENV=development playwright test --config=playwright.electron.config.ts --grep-invert='@snapshot'", "test:playwright:electron:local": "yarn tronb:package:dev && NODE_ENV=development playwright test --config=playwright.electron.config.ts --grep-invert='@snapshot'",
"test:playwright:electron:windows:local": "yarn tron:package && NODE_ENV=development playwright test --config=playwright.electron.config.ts --grep-invert=\"@skipWin|@snapshot\"", "test:playwright:electron:windows:local": "yarn tronb:package:dev && set NODE_ENV='development' && playwright test --config=playwright.electron.config.ts --grep-invert=\"@skipWin|@snapshot\"",
"test:playwright:electron:macos:local": "yarn tron:package && NODE_ENV=development playwright test --config=playwright.electron.config.ts --grep-invert='@skipMacos|@snapshot'", "test:playwright:electron:macos:local": "yarn tronb:package:dev && NODE_ENV=development playwright test --config=playwright.electron.config.ts --grep-invert='@skipMacos|@snapshot'",
"test:playwright:electron:ubuntu:local": "yarn tron:package && NODE_ENV=development playwright test --config=playwright.electron.config.ts --grep-invert='@skipLinux|@snapshot'", "test:playwright:electron:ubuntu:local": "yarn tronb:package:dev && NODE_ENV=development playwright test --config=playwright.electron.config.ts --grep-invert='@skipLinux|@snapshot'",
"test:unit:local": "yarn simpleserver:bg && yarn test:unit; kill-port 3000", "test:unit:local": "yarn simpleserver:bg && yarn test:unit; kill-port 3000",
"test:unit:kcl-samples:local": "yarn simpleserver:bg && yarn test:unit:kcl-samples; kill-port 3000" "test:unit:kcl-samples:local": "yarn simpleserver:bg && yarn test:unit:kcl-samples; kill-port 3000"
}, },

View File

@ -683,9 +683,9 @@ vite-tsconfig-paths@^4.3.2:
tsconfck "^3.0.3" tsconfck "^3.0.3"
vite@^5.0.0: vite@^5.0.0:
version "5.4.11" version "5.4.14"
resolved "https://registry.yarnpkg.com/vite/-/vite-5.4.11.tgz#3b415cd4aed781a356c1de5a9ebafb837715f6e5" resolved "https://registry.yarnpkg.com/vite/-/vite-5.4.14.tgz#ff8255edb02134df180dcfca1916c37a6abe8408"
integrity sha512-c7jFQRklXua0mTzneGW9QVyxFjUgwcihC4bXEtujIo2ouWCe1Ajt/amn2PCxYnhYfd5k09JX3SB7OYWFKYqj8Q== integrity sha512-EK5cY7Q1D8JNhSaPKVK4pwBFvaTmZxEnoKXLG/U9gmdDcihQGNzFlgIvaxezFR4glP1LsuiedwMBqCXH3wZccA==
dependencies: dependencies:
esbuild "^0.21.3" esbuild "^0.21.3"
postcss "^8.4.43" postcss "^8.4.43"

View File

@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { useHotKeyListener } from './hooks/useHotKeyListener' import { useHotKeyListener } from './hooks/useHotKeyListener'
import { Stream } from './components/Stream' import { Stream } from './components/Stream'
import { AppHeader } from './components/AppHeader' import { AppHeader } from './components/AppHeader'
@ -22,13 +22,33 @@ import Gizmo from 'components/Gizmo'
import { CoreDumpManager } from 'lib/coredump' import { CoreDumpManager } from 'lib/coredump'
import { UnitsMenu } from 'components/UnitsMenu' import { UnitsMenu } from 'components/UnitsMenu'
import { CameraProjectionToggle } from 'components/CameraProjectionToggle' import { CameraProjectionToggle } from 'components/CameraProjectionToggle'
import { useCreateFileLinkQuery } from 'hooks/useCreateFileLinkQueryWatcher'
import { maybeWriteToDisk } from 'lib/telemetry' import { maybeWriteToDisk } from 'lib/telemetry'
import { takeScreenshotOfVideoStreamCanvas } from 'lib/screenshot'
import { writeProjectThumbnailFile } from 'lib/desktop'
import { useRouteLoaderData } from 'react-router-dom'
import { useEngineCommands } from 'components/EngineCommands'
import { commandBarActor } from 'machines/commandBarMachine'
import { useToken } from 'machines/appMachine'
maybeWriteToDisk() maybeWriteToDisk()
.then(() => {}) .then(() => {})
.catch(() => {}) .catch(() => {})
export function App() { export function App() {
const { project, file } = useLoaderData() as IndexLoaderData const { project, file } = useLoaderData() as IndexLoaderData
// Keep a lookout for a URL query string that invokes the 'import file from URL' command
useCreateFileLinkQuery((argDefaultValues) => {
commandBarActor.send({
type: 'Find and select command',
data: {
groupId: 'projects',
name: 'Import file from URL',
argDefaultValues,
},
})
})
useRefreshSettings(PATHS.FILE + 'SETTINGS') useRefreshSettings(PATHS.FILE + 'SETTINGS')
const navigate = useNavigate() const navigate = useNavigate()
const filePath = useAbsoluteFilePath() const filePath = useAbsoluteFilePath()
@ -39,14 +59,20 @@ export function App() {
const projectName = project?.name || null const projectName = project?.name || null
const projectPath = project?.path || null const projectPath = project?.path || null
const [commands] = useEngineCommands()
const [capturedCanvas, setCapturedCanvas] = useState(false)
const loaderData = useRouteLoaderData(PATHS.FILE) as IndexLoaderData
const lastCommandType = commands[commands.length - 1]?.type
useEffect(() => { useEffect(() => {
onProjectOpen({ name: projectName, path: projectPath }, file || null) onProjectOpen({ name: projectName, path: projectPath }, file || null)
}, [projectName, projectPath]) }, [projectName, projectPath])
useHotKeyListener() useHotKeyListener()
const { auth, settings } = useSettingsAuthContext() const { settings } = useSettingsAuthContext()
const token = auth?.context?.token const token = useToken()
const coreDumpManager = useMemo( const coreDumpManager = useMemo(
() => new CoreDumpManager(engineCommandManager, codeManager, token), () => new CoreDumpManager(engineCommandManager, codeManager, token),
@ -76,6 +102,28 @@ export function App() {
useEngineConnectionSubscriptions() useEngineConnectionSubscriptions()
// Generate thumbnail.png when loading the app
useEffect(() => {
if (!capturedCanvas && lastCommandType === 'execution-done') {
setTimeout(() => {
const projectDirectoryWithoutEndingSlash = loaderData?.project?.path
if (!projectDirectoryWithoutEndingSlash) {
return
}
const dataUrl: string = takeScreenshotOfVideoStreamCanvas()
// zoom to fit command does not wait, wait 500ms to see if zoom to fit finishes
writeProjectThumbnailFile(dataUrl, projectDirectoryWithoutEndingSlash)
.then(() => {})
.catch((e) => {
console.error(
`Failed to generate thumbnail for ${projectDirectoryWithoutEndingSlash}`
)
console.error(e)
})
}, 500)
}
}, [lastCommandType])
return ( return (
<div className="relative h-full flex flex-col" ref={ref}> <div className="relative h-full flex flex-col" ref={ref}>
<AppHeader <AppHeader

View File

@ -1,10 +1,10 @@
import { useAuthState } from 'machines/appMachine'
import Loading from './components/Loading' import Loading from './components/Loading'
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
// Wrapper around protected routes, used in src/Router.tsx // Wrapper around protected routes, used in src/Router.tsx
export const Auth = ({ children }: React.PropsWithChildren) => { export const Auth = ({ children }: React.PropsWithChildren) => {
const { auth } = useSettingsAuthContext() const authState = useAuthState()
const isLoggingIn = auth?.state.matches('checkIfLoggedIn') const isLoggingIn = authState.matches('checkIfLoggedIn')
return isLoggingIn ? ( return isLoggingIn ? (
<Loading> <Loading>

View File

@ -34,10 +34,9 @@ import {
import SettingsAuthProvider from 'components/SettingsAuthProvider' import SettingsAuthProvider from 'components/SettingsAuthProvider'
import LspProvider from 'components/LspProvider' import LspProvider from 'components/LspProvider'
import { KclContextProvider } from 'lang/KclProvider' import { KclContextProvider } from 'lang/KclProvider'
import { BROWSER_PROJECT_NAME } from 'lib/constants' import { ASK_TO_OPEN_QUERY_PARAM, BROWSER_PROJECT_NAME } from 'lib/constants'
import { CoreDumpManager } from 'lib/coredump' import { CoreDumpManager } from 'lib/coredump'
import { codeManager, engineCommandManager } from 'lib/singletons' import { codeManager, engineCommandManager } from 'lib/singletons'
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
import useHotkeyWrapper from 'lib/hotkeyWrapper' import useHotkeyWrapper from 'lib/hotkeyWrapper'
import toast from 'react-hot-toast' import toast from 'react-hot-toast'
import { coreDump } from 'lang/wasm' import { coreDump } from 'lang/wasm'
@ -46,6 +45,8 @@ import { AppStateProvider } from 'AppState'
import { reportRejection } from 'lib/trap' import { reportRejection } from 'lib/trap'
import { RouteProvider } from 'components/RouteProvider' import { RouteProvider } from 'components/RouteProvider'
import { ProjectsContextProvider } from 'components/ProjectsContextProvider' import { ProjectsContextProvider } from 'components/ProjectsContextProvider'
import { OpenInDesktopAppHandler } from 'components/OpenInDesktopAppHandler'
import { useToken } from 'machines/appMachine'
const createRouter = isDesktop() ? createHashRouter : createBrowserRouter const createRouter = isDesktop() ? createHashRouter : createBrowserRouter
@ -57,31 +58,42 @@ const router = createRouter([
/* Make sure auth is the outermost provider or else we will have /* Make sure auth is the outermost provider or else we will have
* inefficient re-renders, use the react profiler to see. */ * inefficient re-renders, use the react profiler to see. */
element: ( element: (
<RouteProvider> <OpenInDesktopAppHandler>
<SettingsAuthProvider> <RouteProvider>
<LspProvider> <SettingsAuthProvider>
<ProjectsContextProvider> <LspProvider>
<KclContextProvider> <ProjectsContextProvider>
<AppStateProvider> <KclContextProvider>
<MachineManagerProvider> <AppStateProvider>
<Outlet /> <MachineManagerProvider>
</MachineManagerProvider> <Outlet />
</AppStateProvider> </MachineManagerProvider>
</KclContextProvider> </AppStateProvider>
</ProjectsContextProvider> </KclContextProvider>
</LspProvider> </ProjectsContextProvider>
</SettingsAuthProvider> </LspProvider>
</RouteProvider> </SettingsAuthProvider>
</RouteProvider>
</OpenInDesktopAppHandler>
), ),
errorElement: <ErrorPage />, errorElement: <ErrorPage />,
children: [ children: [
{ {
path: PATHS.INDEX, path: PATHS.INDEX,
loader: async () => { loader: async ({ request }) => {
const onDesktop = isDesktop() const onDesktop = isDesktop()
return onDesktop const url = new URL(request.url)
? redirect(PATHS.HOME) if (onDesktop) {
: redirect(PATHS.FILE + '/%2F' + BROWSER_PROJECT_NAME) return redirect(PATHS.HOME + (url.search || ''))
} else {
const searchParams = new URLSearchParams(url.search)
if (!searchParams.has(ASK_TO_OPEN_QUERY_PARAM)) {
return redirect(
PATHS.FILE + '/%2F' + BROWSER_PROJECT_NAME + (url.search || '')
)
}
}
return null
}, },
}, },
{ {
@ -191,8 +203,7 @@ export const Router = () => {
} }
function CoreDump() { function CoreDump() {
const { auth } = useSettingsAuthContext() const token = useToken()
const token = auth?.context?.token
const coreDumpManager = useMemo( const coreDumpManager = useMemo(
() => new CoreDumpManager(engineCommandManager, codeManager, token), () => new CoreDumpManager(engineCommandManager, codeManager, token),
[] []

View File

@ -29,6 +29,7 @@ import * as TWEEN from '@tweenjs/tween.js'
import { isQuaternionVertical } from './helpers' import { isQuaternionVertical } from './helpers'
import { reportRejection } from 'lib/trap' import { reportRejection } from 'lib/trap'
import { CameraProjectionType } from 'wasm-lib/kcl/bindings/CameraProjectionType' import { CameraProjectionType } from 'wasm-lib/kcl/bindings/CameraProjectionType'
import { CameraDragInteractionType_type } from '@kittycad/lib/dist/types/src/models'
const ORTHOGRAPHIC_CAMERA_SIZE = 20 const ORTHOGRAPHIC_CAMERA_SIZE = 20
const FRAMES_TO_ANIMATE_IN = 30 const FRAMES_TO_ANIMATE_IN = 30
@ -406,7 +407,7 @@ export class CameraControls {
.sub(this.mouseDownPosition) .sub(this.mouseDownPosition)
this.mouseDownPosition.copy(this.mouseNewPosition) this.mouseDownPosition.copy(this.mouseNewPosition)
const interaction = this.getInteractionType(event) let interaction = this.getInteractionType(event)
if (interaction === 'none') return if (interaction === 'none') return
// If there's a valid interaction and the mouse is moving, // If there's a valid interaction and the mouse is moving,
@ -753,8 +754,6 @@ export class CameraControls {
didChange = true didChange = true
} }
this.safeLookAtTarget(this.camera.up)
// Update the camera's matrices // Update the camera's matrices
this.camera.updateMatrixWorld() this.camera.updateMatrixWorld()
if (didChange || forceUpdate) { if (didChange || forceUpdate) {
@ -1189,14 +1188,24 @@ 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: MouseEvent) => getInteractionType = (
_getInteractionType( event: MouseEvent
): CameraDragInteractionType_type | 'none' => {
const initialInteractionType = _getInteractionType(
this.interactionGuards, this.interactionGuards,
event, event,
this.enablePan, this.enablePan,
this.enableRotate, this.enableRotate,
this.enableZoom this.enableZoom
) )
if (
initialInteractionType === 'rotate' &&
this.engineCommandManager.settings.cameraOrbit === 'trackball'
) {
return 'rotatetrackball'
}
return initialInteractionType
}
} }
// Pure function helpers // Pure function helpers

View File

@ -124,6 +124,14 @@ export const ClientSideScene = ({
'mouseup', 'mouseup',
toSync(sceneInfra.onMouseUp, reportRejection) toSync(sceneInfra.onMouseUp, reportRejection)
) )
sceneEntitiesManager
.tearDownSketch()
.then(() => {
// no op
})
.catch((e) => {
console.error(e)
})
} }
}, []) }, [])

View File

@ -69,7 +69,8 @@ import {
codeManager, codeManager,
editorManager, editorManager,
} from 'lib/singletons' } from 'lib/singletons'
import { getNodeFromPath, getNodePathFromSourceRange } from 'lang/queryAst' import { getNodeFromPath } from 'lang/queryAst'
import { getNodePathFromSourceRange } from 'lang/queryAstNodePathUtils'
import { executeAst, ToolTip } from 'lang/langHelpers' import { executeAst, ToolTip } from 'lang/langHelpers'
import { import {
createProfileStartHandle, createProfileStartHandle,

View File

@ -2,11 +2,11 @@ import { Toolbar } from '../Toolbar'
import UserSidebarMenu from 'components/UserSidebarMenu' import UserSidebarMenu from 'components/UserSidebarMenu'
import { type IndexLoaderData } from 'lib/types' import { type IndexLoaderData } from 'lib/types'
import ProjectSidebarMenu from './ProjectSidebarMenu' import ProjectSidebarMenu from './ProjectSidebarMenu'
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
import styles from './AppHeader.module.css' import styles from './AppHeader.module.css'
import { RefreshButton } from 'components/RefreshButton' import { RefreshButton } from 'components/RefreshButton'
import { CommandBarOpenButton } from './CommandBarOpenButton' import { CommandBarOpenButton } from './CommandBarOpenButton'
import { isDesktop } from 'lib/isDesktop' import { isDesktop } from 'lib/isDesktop'
import { useUser } from 'machines/appMachine'
interface AppHeaderProps extends React.PropsWithChildren { interface AppHeaderProps extends React.PropsWithChildren {
showToolbar?: boolean showToolbar?: boolean
@ -24,8 +24,7 @@ export const AppHeader = ({
style, style,
enableMenu = false, enableMenu = false,
}: AppHeaderProps) => { }: AppHeaderProps) => {
const { auth } = useSettingsAuthContext() const user = useUser()
const user = auth?.context?.user
return ( return (
<header <header

View File

@ -1,6 +1,7 @@
import { useModelingContext } from 'hooks/useModelingContext' import { useModelingContext } from 'hooks/useModelingContext'
import { editorManager, engineCommandManager, kclManager } from 'lib/singletons' import { editorManager, engineCommandManager, kclManager } from 'lib/singletons'
import { getNodeFromPath, getNodePathFromSourceRange } from 'lang/queryAst' import { getNodeFromPath } from 'lang/queryAst'
import { getNodePathFromSourceRange } from 'lang/queryAstNodePathUtils'
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { trap } from 'lib/trap' import { trap } from 'lib/trap'
import { codeToIdSelections } from 'lib/selections' import { codeToIdSelections } from 'lib/selections'

View File

@ -129,6 +129,7 @@ function CommandArgOptionInput({
<label <label
htmlFor="option-input" htmlFor="option-input"
className="capitalize px-2 py-1 rounded-l bg-chalkboard-100 dark:bg-chalkboard-80 text-chalkboard-10 border-b border-b-chalkboard-100 dark:border-b-chalkboard-80" className="capitalize px-2 py-1 rounded-l bg-chalkboard-100 dark:bg-chalkboard-80 text-chalkboard-10 border-b border-b-chalkboard-100 dark:border-b-chalkboard-80"
data-testid="cmd-bar-arg-name"
> >
{argName} {argName}
</label> </label>

View File

@ -98,6 +98,7 @@ export const CommandBar = () => {
'fixed inset-0 z-50 overflow-y-auto pb-4 pt-1 ' + 'fixed inset-0 z-50 overflow-y-auto pb-4 pt-1 ' +
(isSelectionArgument ? 'pointer-events-none' : '') (isSelectionArgument ? 'pointer-events-none' : '')
} }
data-testid="command-bar-wrapper"
> >
<Transition.Child <Transition.Child
enter="duration-100 ease-out" enter="duration-100 ease-out"

View File

@ -75,34 +75,40 @@ function CommandComboBox({
autoFocus autoFocus
/> />
</div> </div>
<Combobox.Options {filteredOptions?.length ? (
static <Combobox.Options
className="overflow-y-auto max-h-96 cursor-pointer" static
> className="overflow-y-auto max-h-96 cursor-pointer"
{filteredOptions?.map((option) => ( >
<Combobox.Option {filteredOptions?.map((option) => (
key={option.groupId + option.name + (option.displayName || '')} <Combobox.Option
value={option} key={option.groupId + option.name + (option.displayName || '')}
className="flex items-center gap-4 px-4 py-1.5 first:mt-2 last:mb-2 ui-active:bg-primary/10 dark:ui-active:bg-chalkboard-90 ui-disabled:!text-chalkboard-50" value={option}
disabled={optionIsDisabled(option)} className="flex items-center gap-4 px-4 py-1.5 first:mt-2 last:mb-2 ui-active:bg-primary/10 dark:ui-active:bg-chalkboard-90 ui-disabled:!text-chalkboard-50"
data-testid={`cmd-bar-option`} disabled={optionIsDisabled(option)}
> data-testid={`cmd-bar-option`}
{'icon' in option && option.icon && ( >
<CustomIcon name={option.icon} className="w-5 h-5" /> {'icon' in option && option.icon && (
)} <CustomIcon name={option.icon} className="w-5 h-5" />
<div className="flex-grow flex flex-col">
<p className="my-0 leading-tight">
{option.displayName || option.name}{' '}
</p>
{option.description && (
<p className="my-0 text-xs text-chalkboard-60 dark:text-chalkboard-50">
{option.description}
</p>
)} )}
</div> <div className="flex-grow flex flex-col">
</Combobox.Option> <p className="my-0 leading-tight">
))} {option.displayName || option.name}{' '}
</Combobox.Options> </p>
{option.description && (
<p className="my-0 text-xs text-chalkboard-60 dark:text-chalkboard-50">
{option.description}
</p>
)}
</div>
</Combobox.Option>
))}
</Combobox.Options>
) : (
<p className="px-4 pt-2 text-chalkboard-60 dark:text-chalkboard-50">
No results found
</p>
)}
</Combobox> </Combobox>
) )
} }

View File

@ -30,6 +30,7 @@ import {
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext' import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
import { markOnce } from 'lib/performance' import { markOnce } from 'lib/performance'
import { commandBarActor } from 'machines/commandBarMachine' import { commandBarActor } from 'machines/commandBarMachine'
import { useToken } from 'machines/appMachine'
type MachineContext<T extends AnyStateMachine> = { type MachineContext<T extends AnyStateMachine> = {
state: StateFrom<T> state: StateFrom<T>
@ -48,7 +49,9 @@ export const FileMachineProvider = ({
}) => { }) => {
const navigate = useNavigate() const navigate = useNavigate()
const { settings } = useSettingsAuthContext() const { settings } = useSettingsAuthContext()
const { project, file } = useRouteLoaderData(PATHS.FILE) as IndexLoaderData const token = useToken()
const projectData = useRouteLoaderData(PATHS.FILE) as IndexLoaderData
const { project, file } = projectData
const [kclSamples, setKclSamples] = React.useState<KclSamplesManifestItem[]>( const [kclSamples, setKclSamples] = React.useState<KclSamplesManifestItem[]>(
[] []
) )
@ -295,40 +298,47 @@ export const FileMachineProvider = ({
const kclCommandMemo = useMemo( const kclCommandMemo = useMemo(
() => () =>
kclCommands( kclCommands({
async (data) => { authToken: token ?? '',
if (data.method === 'overwrite') { projectData,
codeManager.updateCodeStateEditor(data.code) settings: {
await kclManager.executeCode(true) defaultUnit: settings?.context?.modeling.defaultUnit.current ?? 'mm',
await codeManager.writeToFile()
} else if (data.method === 'newFile' && isDesktop()) {
send({
type: 'Create file',
data: {
name: data.sampleName,
content: data.code,
makeDir: false,
},
})
}
// Either way, we want to overwrite the defaultUnit project setting
// with the sample's setting.
if (data.sampleUnits) {
settings.send({
type: 'set.modeling.defaultUnit',
data: {
level: 'project',
value: data.sampleUnits,
},
})
}
}, },
kclSamples.map((sample) => ({ specialPropsForSampleCommand: {
value: sample.pathFromProjectDirectoryToFirstFile, onSubmit: async (data) => {
name: sample.title, if (data.method === 'overwrite') {
})) codeManager.updateCodeStateEditor(data.code)
).filter( await kclManager.executeCode(true)
await codeManager.writeToFile()
} else if (data.method === 'newFile' && isDesktop()) {
send({
type: 'Create file',
data: {
name: data.sampleName,
content: data.code,
makeDir: false,
},
})
}
// Either way, we want to overwrite the defaultUnit project setting
// with the sample's setting.
if (data.sampleUnits) {
settings.send({
type: 'set.modeling.defaultUnit',
data: {
level: 'project',
value: data.sampleUnits,
},
})
}
},
providedOptions: kclSamples.map((sample) => ({
value: sample.pathFromProjectDirectoryToFirstFile,
name: sample.title,
})),
},
}).filter(
(command) => kclSamples.length || command.name !== 'open-kcl-example' (command) => kclSamples.length || command.name !== 'open-kcl-example'
), ),
[codeManager, kclManager, send, kclSamples] [codeManager, kclManager, send, kclSamples]

View File

@ -27,6 +27,7 @@ import { PROJECT_ENTRYPOINT } from 'lib/constants'
import { err } from 'lib/trap' import { err } from 'lib/trap'
import { isDesktop } from 'lib/isDesktop' import { isDesktop } from 'lib/isDesktop'
import { codeManager } from 'lib/singletons' import { codeManager } from 'lib/singletons'
import { useToken } from 'machines/appMachine'
function getWorkspaceFolders(): LSP.WorkspaceFolder[] { function getWorkspaceFolders(): LSP.WorkspaceFolder[] {
return [] return []
@ -69,8 +70,7 @@ export const LspProvider = ({ children }: { children: React.ReactNode }) => {
const [isKclLspReady, setIsKclLspReady] = useState(false) const [isKclLspReady, setIsKclLspReady] = useState(false)
const [isCopilotLspReady, setIsCopilotLspReady] = useState(false) const [isCopilotLspReady, setIsCopilotLspReady] = useState(false)
const { auth } = useSettingsAuthContext() const token = useToken()
const token = auth?.context.token
const navigate = useNavigate() const navigate = useNavigate()
// So this is a bit weird, we need to initialize the lsp server and client. // So this is a bit weird, we need to initialize the lsp server and client.

View File

@ -1,10 +1,8 @@
import { useEngineCommands } from './EngineCommands' import { useEngineCommands } from './EngineCommands'
import { Spinner } from './Spinner' import { Spinner } from './Spinner'
import { CustomIcon } from './CustomIcon' import { CustomIcon } from './CustomIcon'
export const ModelStateIndicator = () => { export const ModelStateIndicator = () => {
const [commands] = useEngineCommands() const [commands] = useEngineCommands()
const lastCommandType = commands[commands.length - 1]?.type const lastCommandType = commands[commands.length - 1]?.type
let className = 'w-6 h-6 ' let className = 'w-6 h-6 '

View File

@ -68,11 +68,8 @@ import {
startSketchOnDefault, startSketchOnDefault,
} from 'lang/modifyAst' } from 'lang/modifyAst'
import { PathToNode, Program, parse, recast, resultIsOk } from 'lang/wasm' import { PathToNode, Program, parse, recast, resultIsOk } from 'lang/wasm'
import { import { artifactIsPlaneWithPaths, isSingleCursorInPipe } from 'lang/queryAst'
artifactIsPlaneWithPaths, import { getNodePathFromSourceRange } from 'lang/queryAstNodePathUtils'
getNodePathFromSourceRange,
isSingleCursorInPipe,
} from 'lang/queryAst'
import { exportFromEngine } from 'lib/exportFromEngine' import { exportFromEngine } from 'lib/exportFromEngine'
import { Models } from '@kittycad/lib/dist/types/src' import { Models } from '@kittycad/lib/dist/types/src'
import toast from 'react-hot-toast' import toast from 'react-hot-toast'
@ -92,6 +89,7 @@ import { Node } from 'wasm-lib/kcl/bindings/Node'
import { promptToEditFlow } from 'lib/promptToEdit' import { promptToEditFlow } from 'lib/promptToEdit'
import { kclEditorActor } from 'machines/kclEditorMachine' import { kclEditorActor } from 'machines/kclEditorMachine'
import { commandBarActor } from 'machines/commandBarMachine' import { commandBarActor } from 'machines/commandBarMachine'
import { useToken } from 'machines/appMachine'
type MachineContext<T extends AnyStateMachine> = { type MachineContext<T extends AnyStateMachine> = {
state: StateFrom<T> state: StateFrom<T>
@ -113,7 +111,6 @@ export const ModelingMachineProvider = ({
children: React.ReactNode children: React.ReactNode
}) => { }) => {
const { const {
auth,
settings: { settings: {
context: { context: {
app: { theme, enableSSAO, allowOrbitInSketchMode }, app: { theme, enableSSAO, allowOrbitInSketchMode },
@ -122,6 +119,7 @@ export const ModelingMachineProvider = ({
cameraProjection, cameraProjection,
highlightEdges, highlightEdges,
showScaleGrid, showScaleGrid,
cameraOrbit,
}, },
}, },
}, },
@ -130,7 +128,7 @@ export const ModelingMachineProvider = ({
const navigate = useNavigate() const navigate = useNavigate()
const { context, send: fileMachineSend } = useFileContext() const { context, send: fileMachineSend } = useFileContext()
const { file } = useLoaderData() as IndexLoaderData const { file } = useLoaderData() as IndexLoaderData
const token = auth?.context?.token const token = useToken()
const streamRef = useRef<HTMLDivElement>(null) const streamRef = useRef<HTMLDivElement>(null)
const persistedContext = useMemo(() => getPersistedContext(), []) const persistedContext = useMemo(() => getPersistedContext(), [])
@ -1157,6 +1155,7 @@ export const ModelingMachineProvider = ({
enableSSAO: enableSSAO.current, enableSSAO: enableSSAO.current,
showScaleGrid: showScaleGrid.current, showScaleGrid: showScaleGrid.current,
cameraProjection: cameraProjection.current, cameraProjection: cameraProjection.current,
cameraOrbit: cameraOrbit.current,
}, },
token token
) )
@ -1186,6 +1185,13 @@ export const ModelingMachineProvider = ({
editorManager.selectionRanges = modelingState.context.selectionRanges editorManager.selectionRanges = modelingState.context.selectionRanges
}, [modelingState.context.selectionRanges]) }, [modelingState.context.selectionRanges])
// When changing camera modes reset the camera to the default orientation to correct
// the up vector otherwise the conconical orientation for the camera modes will be
// wrong
useEffect(() => {
sceneInfra.camControls.resetCameraPosition().catch(reportRejection)
}, [cameraOrbit.current])
useEffect(() => { useEffect(() => {
const onConnectionStateChanged = ({ detail }: CustomEvent) => { const onConnectionStateChanged = ({ detail }: CustomEvent) => {
// If we are in sketch mode we need to exit it. // If we are in sketch mode we need to exit it.

View File

@ -297,7 +297,7 @@ function ModelingPaneButton({
}) })
return ( return (
<div id={paneConfig.id + '-button-holder'}> <div id={paneConfig.id + '-button-holder'} className="relative">
<button <button
className="group pointer-events-auto flex items-center justify-center border-transparent dark:border-transparent disabled:!border-transparent p-0 m-0 rounded-sm !outline-0 focus-visible:border-primary" className="group pointer-events-auto flex items-center justify-center border-transparent dark:border-transparent disabled:!border-transparent p-0 m-0 rounded-sm !outline-0 focus-visible:border-primary"
onClick={onClick} onClick={onClick}
@ -339,7 +339,7 @@ function ModelingPaneButton({
<p <p
id={`${paneConfig.id}-badge`} id={`${paneConfig.id}-badge`}
className={ className={
'absolute m-0 p-0 top-1 right-0 w-3 h-3 flex items-center justify-center text-[10px] font-semibold text-white bg-primary hue-rotate-90 rounded-full border border-chalkboard-10 dark:border-chalkboard-80 z-50 hover:cursor-pointer hover:scale-[2] transition-transform duration-200' 'absolute m-0 p-0 bottom-4 left-4 w-3 h-3 flex items-center justify-center text-[10px] font-semibold text-white bg-primary hue-rotate-90 rounded-full border border-chalkboard-10 dark:border-chalkboard-80 z-50 hover:cursor-pointer hover:scale-[2] transition-transform duration-200'
} }
onClick={showBadge.onClick} onClick={showBadge.onClick}
title={`Click to view ${showBadge.value} notification${ title={`Click to view ${showBadge.value} notification${

View File

@ -0,0 +1,68 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { BrowserRouter, Route, Routes } from 'react-router-dom'
import { OpenInDesktopAppHandler } from './OpenInDesktopAppHandler'
/**
* The behavior under test requires a router,
* so we wrap the component in a minimal router setup.
*/
function TestingMinimalRouterWrapper({
children,
location,
}: {
location?: string
children: React.ReactNode
}) {
return (
<Routes location={location}>
<Route
path="/"
element={<OpenInDesktopAppHandler>{children}</OpenInDesktopAppHandler>}
/>
</Routes>
)
}
describe('OpenInDesktopAppHandler tests', () => {
test(`does not render the modal if no query param is present`, () => {
render(
<BrowserRouter>
<TestingMinimalRouterWrapper>
<p>Dummy app contents</p>
</TestingMinimalRouterWrapper>
</BrowserRouter>
)
const dummyAppContents = screen.getByText('Dummy app contents')
const modalContents = screen.queryByText('Open in desktop app')
expect(dummyAppContents).toBeInTheDocument()
expect(modalContents).not.toBeInTheDocument()
})
test(`renders the modal if the query param is present`, () => {
render(
<BrowserRouter>
<TestingMinimalRouterWrapper location="/?ask-open-desktop">
<p>Dummy app contents</p>
</TestingMinimalRouterWrapper>
</BrowserRouter>
)
let dummyAppContents = screen.queryByText('Dummy app contents')
let modalButton = screen.queryByText('Continue to web app')
// Starts as disconnected
expect(dummyAppContents).not.toBeInTheDocument()
expect(modalButton).not.toBeFalsy()
expect(modalButton).toBeInTheDocument()
fireEvent.click(modalButton as Element)
// I don't like that you have to re-query the screen here
dummyAppContents = screen.queryByText('Dummy app contents')
modalButton = screen.queryByText('Continue to web app')
expect(dummyAppContents).toBeInTheDocument()
expect(modalButton).not.toBeInTheDocument()
})
})

View File

@ -0,0 +1,125 @@
import { getSystemTheme, Themes } from 'lib/theme'
import { ZOO_STUDIO_PROTOCOL } from 'lib/constants'
import { isDesktop } from 'lib/isDesktop'
import { useSearchParams } from 'react-router-dom'
import { ASK_TO_OPEN_QUERY_PARAM } from 'lib/constants'
import { VITE_KC_SITE_BASE_URL } from 'env'
import { ActionButton } from './ActionButton'
import { Transition } from '@headlessui/react'
/**
* This component is a handler that checks if a certain query parameter
* is present, and if so, it will show a modal asking the user if they
* want to open the current page in the desktop app.
*/
export const OpenInDesktopAppHandler = (props: React.PropsWithChildren) => {
const theme = getSystemTheme()
const buttonClasses =
'bg-transparent flex-0 hover:bg-primary/10 dark:hover:bg-primary/10'
const pathLogomarkSvg = `${isDesktop() ? '.' : ''}/zma-logomark${
theme === Themes.Light ? '-dark' : ''
}.svg`
const [searchParams, setSearchParams] = useSearchParams()
// We also ignore this param on desktop, as it is redundant
const hasAskToOpenParam =
!isDesktop() && searchParams.has(ASK_TO_OPEN_QUERY_PARAM)
/**
* This function removes the query param to ask to open in desktop app
* and then navigates to the same route but with our custom protocol
* `zoo-studio:` instead of `https://${BASE_URL}`, to trigger the user's
* desktop app to open.
*/
function onOpenInDesktopApp() {
const newSearchParams = new URLSearchParams(globalThis.location.search)
newSearchParams.delete(ASK_TO_OPEN_QUERY_PARAM)
const newURL = `${ZOO_STUDIO_PROTOCOL}://${globalThis.location.pathname.replace(
'/',
''
)}${searchParams.size > 0 ? `?${newSearchParams.toString()}` : ''}`
globalThis.location.href = newURL
}
/**
* Just remove the query param to ask to open in desktop app
* and continue to the web app.
*/
function continueToWebApp() {
searchParams.delete(ASK_TO_OPEN_QUERY_PARAM)
setSearchParams(searchParams)
}
return hasAskToOpenParam ? (
<Transition
appear
show={true}
as="div"
className={
theme +
` fixed inset-0 grid p-4 place-content-center ${
theme === Themes.Dark ? '!bg-chalkboard-110 text-chalkboard-20' : ''
}`
}
>
<Transition.Child
as="div"
className={`max-w-3xl py-6 px-10 flex flex-col items-center gap-8
mx-auto border rounded-lg shadow-lg dark:bg-chalkboard-100`}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
style={{ zIndex: 10 }}
>
<div>
<h1 className="text-2xl">
Launching{' '}
<img
src={pathLogomarkSvg}
className="w-48"
alt="Zoo Modeling App"
/>
</h1>
</div>
<p className="text-primary flex items-center gap-2">
Choose where to open this link...
</p>
<div className="flex flex-col md:flex-row items-start justify-between gap-4 xl:gap-8">
<div className="flex flex-col gap-2">
<ActionButton
Element="button"
className={buttonClasses + ' !text-base'}
onClick={onOpenInDesktopApp}
iconEnd={{ icon: 'arrowRight' }}
>
Open in desktop app
</ActionButton>
<ActionButton
Element="externalLink"
className={
buttonClasses +
' text-sm border-transparent justify-center dark:bg-transparent'
}
to={`${VITE_KC_SITE_BASE_URL}/modeling-app/download`}
iconEnd={{ icon: 'link', bgClassName: '!bg-transparent' }}
>
Download desktop app
</ActionButton>
</div>
<ActionButton
Element="button"
className={buttonClasses + ' -order-1 !text-base'}
onClick={continueToWebApp}
iconStart={{ icon: 'arrowLeft' }}
>
Continue to web app
</ActionButton>
</div>
</Transition.Child>
</Transition>
) : (
props.children
)
}

View File

@ -2,7 +2,7 @@ import { FormEvent, useEffect, useRef, useState } from 'react'
import { PATHS } from 'lib/paths' import { PATHS } from 'lib/paths'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { ActionButton } from '../ActionButton' import { ActionButton } from '../ActionButton'
import { FILE_EXT } from 'lib/constants' import { FILE_EXT, PROJECT_IMAGE_NAME } from 'lib/constants'
import { useHotkeys } from 'react-hotkeys-hook' import { useHotkeys } from 'react-hotkeys-hook'
import Tooltip from '../Tooltip' import Tooltip from '../Tooltip'
import { DeleteConfirmationDialog } from './DeleteProjectDialog' import { DeleteConfirmationDialog } from './DeleteProjectDialog'
@ -29,7 +29,7 @@ function ProjectCard({
const [isConfirmingDelete, setIsConfirmingDelete] = useState(false) const [isConfirmingDelete, setIsConfirmingDelete] = useState(false)
const [numberOfFiles, setNumberOfFiles] = useState(1) const [numberOfFiles, setNumberOfFiles] = useState(1)
const [numberOfFolders, setNumberOfFolders] = useState(0) const [numberOfFolders, setNumberOfFolders] = useState(0)
// const [imageUrl, setImageUrl] = useState('') const [imageUrl, setImageUrl] = useState('')
let inputRef = useRef<HTMLInputElement>(null) let inputRef = useRef<HTMLInputElement>(null)
@ -53,18 +53,21 @@ function ProjectCard({
setNumberOfFolders(project.directory_count) setNumberOfFolders(project.directory_count)
} }
// async function setupImageUrl() { async function setupImageUrl() {
// const projectImagePath = await join(project.file.path, PROJECT_IMAGE_NAME) const projectImagePath = window.electron.path.join(
// if (await exists(projectImagePath)) { project.path,
// const imageData = await readFile(projectImagePath) PROJECT_IMAGE_NAME
// const blob = new Blob([imageData], { type: 'image/jpg' }) )
// const imageUrl = URL.createObjectURL(blob) if (await window.electron.exists(projectImagePath)) {
// setImageUrl(imageUrl) const imageData = await window.electron.readFile(projectImagePath)
// } const blob = new Blob([imageData], { type: 'image/png' })
// } const imageUrl = URL.createObjectURL(blob)
setImageUrl(imageUrl)
}
}
void getNumberOfFiles() void getNumberOfFiles()
// void setupImageUrl() void setupImageUrl()
}, [project.kcl_file_count, project.directory_count]) }, [project.kcl_file_count, project.directory_count])
useEffect(() => { useEffect(() => {
@ -84,7 +87,7 @@ function ProjectCard({
to={`${PATHS.FILE}/${encodeURIComponent(project.default_file)}`} to={`${PATHS.FILE}/${encodeURIComponent(project.default_file)}`}
className="flex flex-col flex-1 !no-underline !text-chalkboard-110 dark:!text-chalkboard-10 group-hover:!hue-rotate-0 min-h-[5em] divide-y divide-primary/40 dark:divide-chalkboard-80 group-hover:!divide-primary" className="flex flex-col flex-1 !no-underline !text-chalkboard-110 dark:!text-chalkboard-10 group-hover:!hue-rotate-0 min-h-[5em] divide-y divide-primary/40 dark:divide-chalkboard-80 group-hover:!divide-primary"
> >
{/* <div className="h-36 relative overflow-hidden bg-gradient-to-b from-transparent to-primary/10 rounded-t-sm"> <div className="h-36 relative overflow-hidden bg-gradient-to-b from-transparent to-primary/10 rounded-t-sm">
{imageUrl && ( {imageUrl && (
<img <img
src={imageUrl} src={imageUrl}
@ -92,7 +95,7 @@ function ProjectCard({
className="h-full w-full transition-transform group-hover:scale-105 object-cover" className="h-full w-full transition-transform group-hover:scale-105 object-cover"
/> />
)} )}
</div> */} </div>
<div className="pb-2 flex flex-col flex-grow flex-auto gap-2 rounded-b-sm"> <div className="pb-2 flex flex-col flex-grow flex-auto gap-2 rounded-b-sm">
{isEditing ? ( {isEditing ? (
<ProjectCardRenameForm <ProjectCardRenameForm

View File

@ -9,7 +9,7 @@ import { Logo } from './Logo'
import { APP_NAME } from 'lib/constants' import { APP_NAME } from 'lib/constants'
import { CustomIcon } from './CustomIcon' import { CustomIcon } from './CustomIcon'
import { useLspContext } from './LspProvider' import { useLspContext } from './LspProvider'
import { engineCommandManager, kclManager } from 'lib/singletons' import { codeManager, engineCommandManager, kclManager } from 'lib/singletons'
import { MachineManagerContext } from 'components/MachineManagerProvider' import { MachineManagerContext } from 'components/MachineManagerProvider'
import usePlatform from 'hooks/usePlatform' import usePlatform from 'hooks/usePlatform'
import { useAbsoluteFilePath } from 'hooks/useAbsoluteFilePath' import { useAbsoluteFilePath } from 'hooks/useAbsoluteFilePath'
@ -17,6 +17,10 @@ import Tooltip from './Tooltip'
import { SnapshotFrom } from 'xstate' import { SnapshotFrom } from 'xstate'
import { commandBarActor } from 'machines/commandBarMachine' import { commandBarActor } from 'machines/commandBarMachine'
import { useSelector } from '@xstate/react' import { useSelector } from '@xstate/react'
import { copyFileShareLink } from 'lib/links'
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
import { IS_NIGHTLY_OR_DEBUG } from 'routes/Settings'
import { useToken } from 'machines/appMachine'
const ProjectSidebarMenu = ({ const ProjectSidebarMenu = ({
project, project,
@ -100,12 +104,15 @@ function ProjectMenuPopover({
const location = useLocation() const location = useLocation()
const navigate = useNavigate() const navigate = useNavigate()
const filePath = useAbsoluteFilePath() const filePath = useAbsoluteFilePath()
const { settings } = useSettingsAuthContext()
const token = useToken()
const machineManager = useContext(MachineManagerContext) const machineManager = useContext(MachineManagerContext)
const commands = useSelector(commandBarActor, commandsSelector) const commands = useSelector(commandBarActor, commandsSelector)
const { onProjectClose } = useLspContext() const { onProjectClose } = useLspContext()
const exportCommandInfo = { name: 'Export', groupId: 'modeling' } const exportCommandInfo = { name: 'Export', groupId: 'modeling' }
const makeCommandInfo = { name: 'Make', groupId: 'modeling' } const makeCommandInfo = { name: 'Make', groupId: 'modeling' }
const shareCommandInfo = { name: 'share-file-link', groupId: 'code' }
const findCommand = (obj: { name: string; groupId: string }) => const findCommand = (obj: { name: string; groupId: string }) =>
Boolean( Boolean(
commands.find((c) => c.name === obj.name && c.groupId === obj.groupId) commands.find((c) => c.name === obj.name && c.groupId === obj.groupId)
@ -158,7 +165,6 @@ function ProjectMenuPopover({
data: exportCommandInfo, data: exportCommandInfo,
}), }),
}, },
'break',
{ {
id: 'make', id: 'make',
Element: 'button', Element: 'button',
@ -184,6 +190,20 @@ function ProjectMenuPopover({
}) })
}, },
}, },
{
id: 'share-link',
Element: 'button',
children: 'Share link to file',
disabled: IS_NIGHTLY_OR_DEBUG || !findCommand(shareCommandInfo),
onClick: async () => {
await copyFileShareLink({
token: token ?? '',
code: codeManager.code,
name: project?.name || '',
units: settings.context.modeling.defaultUnit.current,
})
},
},
'break', 'break',
{ {
id: 'go-home', id: 'go-home',

View File

@ -2,11 +2,11 @@ import { useMachine } from '@xstate/react'
import { useFileSystemWatcher } from 'hooks/useFileSystemWatcher' import { useFileSystemWatcher } from 'hooks/useFileSystemWatcher'
import { useProjectsLoader } from 'hooks/useProjectsLoader' import { useProjectsLoader } from 'hooks/useProjectsLoader'
import { projectsMachine } from 'machines/projectsMachine' import { projectsMachine } from 'machines/projectsMachine'
import { createContext, useEffect, useState } from 'react' import { createContext, useCallback, useEffect, useState } from 'react'
import { Actor, AnyStateMachine, fromPromise, Prop, StateFrom } from 'xstate' import { Actor, AnyStateMachine, fromPromise, Prop, StateFrom } from 'xstate'
import { useLspContext } from './LspProvider' import { useLspContext } from './LspProvider'
import toast from 'react-hot-toast' import toast from 'react-hot-toast'
import { useLocation, useNavigate } from 'react-router-dom' import { useLocation, useNavigate, useSearchParams } from 'react-router-dom'
import { PATHS } from 'lib/paths' import { PATHS } from 'lib/paths'
import { import {
createNewProjectDirectory, createNewProjectDirectory,
@ -18,12 +18,28 @@ import {
interpolateProjectNameWithIndex, interpolateProjectNameWithIndex,
doesProjectNameNeedInterpolated, doesProjectNameNeedInterpolated,
getUniqueProjectName, getUniqueProjectName,
getNextFileName,
} from 'lib/desktopFS' } from 'lib/desktopFS'
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext' import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
import useStateMachineCommands from 'hooks/useStateMachineCommands' import useStateMachineCommands from 'hooks/useStateMachineCommands'
import { projectsCommandBarConfig } from 'lib/commandBarConfigs/projectsCommandConfig' import { projectsCommandBarConfig } from 'lib/commandBarConfigs/projectsCommandConfig'
import { isDesktop } from 'lib/isDesktop' import { isDesktop } from 'lib/isDesktop'
import { commandBarActor } from 'machines/commandBarMachine' import { commandBarActor } from 'machines/commandBarMachine'
import {
CREATE_FILE_URL_PARAM,
FILE_EXT,
PROJECT_ENTRYPOINT,
} from 'lib/constants'
import { DeepPartial } from 'lib/types'
import { Configuration } from 'wasm-lib/kcl/bindings/Configuration'
import { codeManager } from 'lib/singletons'
import {
loadAndValidateSettings,
projectConfigurationToSettingsPayload,
saveSettings,
setSettingsAtLevel,
} from 'lib/settings/settingsUtils'
import { Project } from 'lib/project'
type MachineContext<T extends AnyStateMachine> = { type MachineContext<T extends AnyStateMachine> = {
state?: StateFrom<T> state?: StateFrom<T>
@ -53,12 +69,110 @@ export const ProjectsContextProvider = ({
) )
} }
/**
* We need some of the functionality of the ProjectsContextProvider in the web version
* but we can't perform file system operations in the browser,
* so most of the behavior of this machine is stubbed out.
*/
const ProjectsContextWeb = ({ children }: { children: React.ReactNode }) => { const ProjectsContextWeb = ({ children }: { children: React.ReactNode }) => {
const [searchParams, setSearchParams] = useSearchParams()
const clearImportSearchParams = useCallback(() => {
// Clear the search parameters related to the "Import file from URL" command
// or we'll never be able cancel or submit it.
searchParams.delete(CREATE_FILE_URL_PARAM)
searchParams.delete('code')
searchParams.delete('name')
searchParams.delete('units')
setSearchParams(searchParams)
}, [searchParams, setSearchParams])
const {
settings: { context: settings, send: settingsSend },
} = useSettingsAuthContext()
const [state, send, actor] = useMachine(
projectsMachine.provide({
actions: {
navigateToProject: () => {},
navigateToProjectIfNeeded: () => {},
navigateToFile: () => {},
toastSuccess: ({ event }) =>
toast.success(
('data' in event && typeof event.data === 'string' && event.data) ||
('output' in event &&
'message' in event.output &&
typeof event.output.message === 'string' &&
event.output.message) ||
''
),
toastError: ({ event }) =>
toast.error(
('data' in event && typeof event.data === 'string' && event.data) ||
('output' in event &&
typeof event.output === 'string' &&
event.output) ||
''
),
},
actors: {
readProjects: fromPromise(async () => [] as Project[]),
createProject: fromPromise(async () => ({
message: 'not implemented on web',
})),
renameProject: fromPromise(async () => ({
message: 'not implemented on web',
oldName: '',
newName: '',
})),
deleteProject: fromPromise(async () => ({
message: 'not implemented on web',
name: '',
})),
createFile: fromPromise(async ({ input }) => {
// Browser version doesn't navigate, just overwrites the current file
clearImportSearchParams()
codeManager.updateCodeStateEditor(input.code || '')
await codeManager.writeToFile()
settingsSend({
type: 'set.modeling.defaultUnit',
data: {
level: 'project',
value: input.units,
},
})
return {
message: 'File and units overwritten successfully',
fileName: input.name,
projectName: '',
}
}),
},
}),
{
input: {
projects: [],
defaultProjectName: settings.projects.defaultProjectName.current,
defaultDirectory: settings.app.projectDirectory.current,
},
}
)
// register all project-related command palette commands
useStateMachineCommands({
machineId: 'projects',
send,
state,
commandBarConfig: projectsCommandBarConfig,
actor,
onCancel: clearImportSearchParams,
})
return ( return (
<ProjectsMachineContext.Provider <ProjectsMachineContext.Provider
value={{ value={{
state: undefined, state,
send: () => {}, send,
}} }}
> >
{children} {children}
@ -73,18 +187,21 @@ const ProjectsContextDesktop = ({
}) => { }) => {
const navigate = useNavigate() const navigate = useNavigate()
const location = useLocation() const location = useLocation()
const [searchParams, setSearchParams] = useSearchParams()
const clearImportSearchParams = useCallback(() => {
// Clear the search parameters related to the "Import file from URL" command
// or we'll never be able cancel or submit it.
searchParams.delete(CREATE_FILE_URL_PARAM)
searchParams.delete('code')
searchParams.delete('name')
searchParams.delete('units')
setSearchParams(searchParams)
}, [searchParams, setSearchParams])
const { onProjectOpen } = useLspContext() const { onProjectOpen } = useLspContext()
const { const {
settings: { context: settings }, settings: { context: settings },
} = useSettingsAuthContext() } = useSettingsAuthContext()
useEffect(() => {
console.log(
'project directory changed',
settings.app.projectDirectory.current
)
}, [settings.app.projectDirectory.current])
const [projectsLoaderTrigger, setProjectsLoaderTrigger] = useState(0) const [projectsLoaderTrigger, setProjectsLoaderTrigger] = useState(0)
const { projectPaths, projectsDir } = useProjectsLoader([ const { projectPaths, projectsDir } = useProjectsLoader([
projectsLoaderTrigger, projectsLoaderTrigger,
@ -168,6 +285,31 @@ const ProjectsContextDesktop = ({
} }
} }
}, },
navigateToFile: ({ context, event }) => {
if (event.type !== 'xstate.done.actor.create-file') return
// For now, the browser version of create-file doesn't need to navigate
// since it just overwrites the current file.
if (!isDesktop()) return
let projectPath = window.electron.join(
context.defaultDirectory,
event.output.projectName
)
let filePath = window.electron.join(
projectPath,
event.output.fileName
)
onProjectOpen(
{
name: event.output.projectName,
path: projectPath,
},
null
)
const pathToNavigateTo = `${PATHS.FILE}/${encodeURIComponent(
filePath
)}`
navigate(pathToNavigateTo)
},
toastSuccess: ({ event }) => toastSuccess: ({ event }) =>
toast.success( toast.success(
('data' in event && typeof event.data === 'string' && event.data) || ('data' in event && typeof event.data === 'string' && event.data) ||
@ -217,8 +359,6 @@ const ProjectsContextDesktop = ({
name = interpolateProjectNameWithIndex(name, nextIndex) name = interpolateProjectNameWithIndex(name, nextIndex)
} }
console.log('from Project')
await renameProjectDirectory( await renameProjectDirectory(
window.electron.path.join(defaultDirectory, oldName), window.electron.path.join(defaultDirectory, oldName),
name name
@ -241,13 +381,82 @@ const ProjectsContextDesktop = ({
name: input.name, name: input.name,
} }
}), }),
}, createFile: fromPromise(async ({ input }) => {
guards: { let projectName =
'Has at least 1 project': ({ event }) => { (input.method === 'newProject' ? input.name : input.projectName) ||
if (event.type !== 'xstate.done.actor.read-projects') return false settings.projects.defaultProjectName.current
console.log(`from has at least 1 project: ${event.output.length}`) let fileName =
return event.output.length ? event.output.length >= 1 : false input.method === 'newProject'
}, ? PROJECT_ENTRYPOINT
: input.name.endsWith(FILE_EXT)
? input.name
: input.name + FILE_EXT
let message = 'File created successfully'
const unitsConfiguration: DeepPartial<Configuration> = {
settings: {
project: {
directory: settings.app.projectDirectory.current,
},
modeling: {
base_unit: input.units,
},
},
}
const needsInterpolated = doesProjectNameNeedInterpolated(projectName)
if (needsInterpolated) {
const nextIndex = getNextProjectIndex(projectName, input.projects)
projectName = interpolateProjectNameWithIndex(
projectName,
nextIndex
)
}
// Create the project around the file if newProject
if (input.method === 'newProject') {
await createNewProjectDirectory(
projectName,
input.code,
unitsConfiguration
)
message = `Project "${projectName}" created successfully with link contents`
} else {
let projectPath = window.electron.join(
settings.app.projectDirectory.current,
projectName
)
message = `File "${fileName}" created successfully`
const existingConfiguration = await loadAndValidateSettings(
projectPath
)
const settingsToSave = setSettingsAtLevel(
existingConfiguration.settings,
'project',
projectConfigurationToSettingsPayload(unitsConfiguration)
)
await saveSettings(settingsToSave, projectPath)
}
// Create the file
let baseDir = window.electron.join(
settings.app.projectDirectory.current,
projectName
)
const { name, path } = getNextFileName({
entryName: fileName,
baseDir,
})
fileName = name
await window.electron.writeFile(path, input.code || '')
return {
message,
fileName,
projectName,
}
}),
}, },
}), }),
{ {
@ -270,6 +479,7 @@ const ProjectsContextDesktop = ({
state, state,
commandBarConfig: projectsCommandBarConfig, commandBarConfig: projectsCommandBarConfig,
actor, actor,
onCancel: clearImportSearchParams,
}) })
return ( return (

View File

@ -8,10 +8,10 @@ import Tooltip from './Tooltip'
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext' import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
import { reportRejection } from 'lib/trap' import { reportRejection } from 'lib/trap'
import { toSync } from 'lib/utils' import { toSync } from 'lib/utils'
import { useToken } from 'machines/appMachine'
export const RefreshButton = ({ children }: React.PropsWithChildren) => { export const RefreshButton = ({ children }: React.PropsWithChildren) => {
const { auth } = useSettingsAuthContext() const token = useToken()
const token = auth?.context?.token
const coreDumpManager = useMemo( const coreDumpManager = useMemo(
() => new CoreDumpManager(engineCommandManager, codeManager, token), () => new CoreDumpManager(engineCommandManager, codeManager, token),
[] []

View File

@ -2,13 +2,16 @@ import { useEffect, useState, createContext, ReactNode } from 'react'
import { useNavigation, useLocation } from 'react-router-dom' import { useNavigation, useLocation } from 'react-router-dom'
import { PATHS } from 'lib/paths' import { PATHS } from 'lib/paths'
import { markOnce } from 'lib/performance' import { markOnce } from 'lib/performance'
import { useAuthNavigation } from 'hooks/useAuthNavigation'
export const RouteProviderContext = createContext({}) export const RouteProviderContext = createContext({})
export function RouteProvider({ children }: { children: ReactNode }) { export function RouteProvider({ children }: { children: ReactNode }) {
useAuthNavigation()
const [first, setFirstState] = useState(true) const [first, setFirstState] = useState(true)
const navigation = useNavigation() const navigation = useNavigation()
const location = useLocation() const location = useLocation()
useEffect(() => { useEffect(() => {
// On initialization, the react-router-dom does not send a 'loading' state event. // On initialization, the react-router-dom does not send a 'loading' state event.
// it sends an idle event first. // it sends an idle event first.

View File

@ -2,10 +2,7 @@ import { trap } from 'lib/trap'
import { useMachine, useSelector } from '@xstate/react' import { useMachine, useSelector } from '@xstate/react'
import { useNavigate, useRouteLoaderData, useLocation } from 'react-router-dom' import { useNavigate, useRouteLoaderData, useLocation } from 'react-router-dom'
import { PATHS, BROWSER_PATH } from 'lib/paths' import { PATHS, BROWSER_PATH } from 'lib/paths'
import { authMachine, TOKEN_PERSIST_KEY } from '../machines/authMachine'
import withBaseUrl from '../lib/withBaseURL'
import React, { createContext, useEffect, useState } from 'react' import React, { createContext, useEffect, useState } from 'react'
import useStateMachineCommands from '../hooks/useStateMachineCommands'
import { settingsMachine } from 'machines/settingsMachine' import { settingsMachine } from 'machines/settingsMachine'
import { toast } from 'react-hot-toast' import { toast } from 'react-hot-toast'
import { import {
@ -16,7 +13,6 @@ import {
} from 'lib/theme' } from 'lib/theme'
import decamelize from 'decamelize' import decamelize from 'decamelize'
import { Actor, AnyStateMachine, ContextFrom, Prop, StateFrom } from 'xstate' import { Actor, AnyStateMachine, ContextFrom, Prop, StateFrom } from 'xstate'
import { authCommandBarConfig } from 'lib/commandBarConfigs/authCommandConfig'
import { import {
kclManager, kclManager,
sceneInfra, sceneInfra,
@ -50,7 +46,6 @@ type MachineContext<T extends AnyStateMachine> = {
} }
type SettingsAuthContextType = { type SettingsAuthContextType = {
auth: MachineContext<typeof authMachine>
settings: MachineContext<typeof settingsMachine> settings: MachineContext<typeof settingsMachine>
} }
@ -370,40 +365,9 @@ export const SettingsAuthProviderBase = ({
) )
}, [settingsState.context.textEditor.blinkingCursor.current]) }, [settingsState.context.textEditor.blinkingCursor.current])
// Auth machine setup
const [authState, authSend, authActor] = useMachine(
authMachine.provide({
actions: {
goToSignInPage: () => {
navigate(PATHS.SIGN_IN)
// eslint-disable-next-line @typescript-eslint/no-floating-promises
logout()
},
goToIndexPage: () => {
if (location.pathname.includes(PATHS.SIGN_IN)) {
navigate(PATHS.INDEX)
}
},
},
})
)
useStateMachineCommands({
machineId: 'auth',
state: authState,
send: authSend,
commandBarConfig: authCommandBarConfig,
actor: authActor,
})
return ( return (
<SettingsAuthContext.Provider <SettingsAuthContext.Provider
value={{ value={{
auth: {
state: authState,
context: authState.context,
send: authSend,
},
settings: { settings: {
state: settingsState, state: settingsState,
context: settingsState.context, context: settingsState.context,
@ -417,12 +381,3 @@ export const SettingsAuthProviderBase = ({
} }
export default SettingsAuthProvider export default SettingsAuthProvider
export async function logout() {
localStorage.removeItem(TOKEN_PERSIST_KEY)
if (isDesktop()) return Promise.resolve(null)
return fetch(withBaseUrl('/logout'), {
method: 'POST',
credentials: 'include',
})
}

View File

@ -1,10 +1,8 @@
import { toolTips } from 'lang/langHelpers' import { toolTips } from 'lang/langHelpers'
import { Selections } from 'lib/selections' import { Selections } from 'lib/selections'
import { Program, Expr, VariableDeclarator } from '../../lang/wasm' import { Program, Expr, VariableDeclarator } from '../../lang/wasm'
import { import { getNodeFromPath } from '../../lang/queryAst'
getNodePathFromSourceRange, import { getNodePathFromSourceRange } from 'lang/queryAstNodePathUtils'
getNodeFromPath,
} from '../../lang/queryAst'
import { isSketchVariablesLinked } from '../../lang/std/sketchConstraints' import { isSketchVariablesLinked } from '../../lang/std/sketchConstraints'
import { import {
transformSecondarySketchLinesTagFirst, transformSecondarySketchLinesTagFirst,

View File

@ -4,12 +4,12 @@ import { useLocation, useNavigate } from 'react-router-dom'
import { Fragment, useMemo, useState } from 'react' import { Fragment, useMemo, useState } from 'react'
import { PATHS } from 'lib/paths' import { PATHS } from 'lib/paths'
import { Models } from '@kittycad/lib' import { Models } from '@kittycad/lib'
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
import { useAbsoluteFilePath } from 'hooks/useAbsoluteFilePath' import { useAbsoluteFilePath } from 'hooks/useAbsoluteFilePath'
import Tooltip from './Tooltip' import Tooltip from './Tooltip'
import usePlatform from 'hooks/usePlatform' import usePlatform from 'hooks/usePlatform'
import { isDesktop } from 'lib/isDesktop' import { isDesktop } from 'lib/isDesktop'
import { CustomIcon } from './CustomIcon' import { CustomIcon } from './CustomIcon'
import { authActor } from 'machines/appMachine'
type User = Models['User_type'] type User = Models['User_type']
@ -20,7 +20,7 @@ const UserSidebarMenu = ({ user }: { user?: User }) => {
const displayedName = getDisplayName(user) const displayedName = getDisplayName(user)
const [imageLoadFailed, setImageLoadFailed] = useState(false) const [imageLoadFailed, setImageLoadFailed] = useState(false)
const navigate = useNavigate() const navigate = useNavigate()
const send = useSettingsAuthContext()?.auth?.send const send = authActor.send
// We filter this memoized list so that no orphan "break" elements are rendered. // We filter this memoized list so that no orphan "break" elements are rendered.
const userMenuItems = useMemo<(ActionButtonProps | 'break')[]>( const userMenuItems = useMemo<(ActionButtonProps | 'break')[]>(

View File

@ -10,6 +10,7 @@ export const VITE_KC_API_WS_MODELING_URL = env.VITE_KC_API_WS_MODELING_URL as
| undefined | undefined
export const VITE_KC_API_BASE_URL = env.VITE_KC_API_BASE_URL as string export const VITE_KC_API_BASE_URL = env.VITE_KC_API_BASE_URL as string
export const VITE_KC_SITE_BASE_URL = env.VITE_KC_SITE_BASE_URL as string export const VITE_KC_SITE_BASE_URL = env.VITE_KC_SITE_BASE_URL as string
export const VITE_KC_SITE_APP_URL = env.VITE_KC_SITE_APP_URL as string
export const VITE_KC_SKIP_AUTH = env.VITE_KC_SKIP_AUTH as string | undefined export const VITE_KC_SKIP_AUTH = env.VITE_KC_SKIP_AUTH as string | undefined
export const VITE_KC_CONNECTION_TIMEOUT_MS = export const VITE_KC_CONNECTION_TIMEOUT_MS =
env.VITE_KC_CONNECTION_TIMEOUT_MS as string | undefined env.VITE_KC_CONNECTION_TIMEOUT_MS as string | undefined

View File

@ -0,0 +1,29 @@
import { PATHS } from 'lib/paths'
import { useAuthState } from 'machines/appMachine'
import { useEffect } from 'react'
import { useLocation, useNavigate } from 'react-router-dom'
/**
* A simple hook that listens to the auth state of the app and navigates
* accordingly.
*/
export function useAuthNavigation() {
const navigate = useNavigate()
const location = useLocation()
const authState = useAuthState()
// Subscribe to the auth state of the app and navigate accordingly.
useEffect(() => {
if (
authState.matches('loggedIn') &&
location.pathname.includes(PATHS.SIGN_IN)
) {
navigate(PATHS.INDEX)
} else if (
authState.matches('loggedOut') &&
!location.pathname.includes(PATHS.SIGN_IN)
) {
navigate(PATHS.SIGN_IN)
}
}, [authState])
}

View File

@ -0,0 +1,65 @@
import { base64ToString } from 'lib/base64'
import { CREATE_FILE_URL_PARAM, DEFAULT_FILE_NAME } from 'lib/constants'
import { useEffect } from 'react'
import { useSearchParams } from 'react-router-dom'
import { useSettingsAuthContext } from './useSettingsAuthContext'
import { isDesktop } from 'lib/isDesktop'
import { FileLinkParams } from 'lib/links'
import { ProjectsCommandSchema } from 'lib/commandBarConfigs/projectsCommandConfig'
import { baseUnitsUnion } from 'lib/settings/settingsTypes'
// For initializing the command arguments, we actually want `method` to be undefined
// so that we don't skip it in the command palette.
export type CreateFileSchemaMethodOptional = Omit<
ProjectsCommandSchema['Import file from URL'],
'method'
> & {
method?: 'newProject' | 'existingProject'
}
/**
* companion to createFileLink. This hook runs an effect on mount that
* checks the URL for the CREATE_FILE_URL_PARAM and triggers the "Create file"
* command if it is present, loading the command's default values from the other
* URL parameters.
*/
export function useCreateFileLinkQuery(
callback: (args: CreateFileSchemaMethodOptional) => void
) {
const [searchParams] = useSearchParams()
const { settings } = useSettingsAuthContext()
useEffect(() => {
const createFileParam = searchParams.has(CREATE_FILE_URL_PARAM)
if (createFileParam) {
const params: FileLinkParams = {
code: base64ToString(
decodeURIComponent(searchParams.get('code') ?? '')
),
name: searchParams.get('name') ?? DEFAULT_FILE_NAME,
units:
(baseUnitsUnion.find((unit) => searchParams.get('units') === unit) ||
settings.context.modeling.defaultUnit.default) ??
settings.context.modeling.defaultUnit.current,
}
const argDefaultValues: CreateFileSchemaMethodOptional = {
name: params.name
? isDesktop()
? params.name.replace('.kcl', '')
: params.name
: isDesktop()
? settings.context.projects.defaultProjectName.current
: DEFAULT_FILE_NAME,
code: params.code || '',
units: params.units,
method: isDesktop() ? undefined : 'existingProject',
}
callback(argDefaultValues)
}
}, [searchParams])
}

View File

@ -17,7 +17,8 @@ import {
} from 'lang/std/artifactGraph' } from 'lang/std/artifactGraph'
import { err, reportRejection } from 'lib/trap' import { err, reportRejection } from 'lib/trap'
import { DefaultPlaneStr, getFaceDetails } from 'clientSideScene/sceneEntities' import { DefaultPlaneStr, getFaceDetails } from 'clientSideScene/sceneEntities'
import { getNodeFromPath, getNodePathFromSourceRange } from 'lang/queryAst' import { getNodeFromPath } from 'lang/queryAst'
import { getNodePathFromSourceRange } from 'lang/queryAstNodePathUtils'
import { CallExpression, defaultSourceRange } from 'lang/wasm' import { CallExpression, defaultSourceRange } from 'lang/wasm'
import { EdgeCutInfo, ExtrudeFacePlane } from 'machines/modelingMachine' import { EdgeCutInfo, ExtrudeFacePlane } from 'machines/modelingMachine'

View File

@ -14,7 +14,7 @@ export const useProjectsLoader = (deps?: [number]) => {
useEffect(() => { useEffect(() => {
// Useless on web, until we get fake filesystems over there. // Useless on web, until we get fake filesystems over there.
if (!isDesktop) return if (!isDesktop()) return
if (deps && deps[0] === lastTs) return if (deps && deps[0] === lastTs) return

View File

@ -16,14 +16,15 @@ export function useSetupEngineManager(
streamRef: React.RefObject<HTMLDivElement>, streamRef: React.RefObject<HTMLDivElement>,
modelingSend: ReturnType<typeof useModelingContext>['send'], modelingSend: ReturnType<typeof useModelingContext>['send'],
modelingContext: ReturnType<typeof useModelingContext>['context'], modelingContext: ReturnType<typeof useModelingContext>['context'],
settings = { settings: SettingsViaQueryString = {
pool: null, pool: null,
theme: Themes.System, theme: Themes.System,
highlightEdges: true, highlightEdges: true,
enableSSAO: true, enableSSAO: true,
showScaleGrid: false, showScaleGrid: false,
cameraProjection: 'perspective', cameraProjection: 'perspective',
} as SettingsViaQueryString, cameraOrbit: 'spherical',
},
token?: string token?: string
) { ) {
const networkContext = useNetworkContext() const networkContext = useNetworkContext()

View File

@ -322,6 +322,7 @@ export class KclManager {
await this.ensureWasmInit() await this.ensureWasmInit()
const { logs, errors, execState, isInterrupted } = await executeAst({ const { logs, errors, execState, isInterrupted } = await executeAst({
ast, ast,
path: codeManager.currentFilePath || undefined,
engineCommandManager: this.engineCommandManager, engineCommandManager: this.engineCommandManager,
}) })

View File

@ -80,6 +80,10 @@ export default class CodeManager {
})) }))
} }
get currentFilePath(): string | null {
return this._currentFilePath
}
updateCurrentFilePath(path: string) { updateCurrentFilePath(path: string) {
this._currentFilePath = path this._currentFilePath = path
} }

View File

@ -1,4 +1,5 @@
import { getNodePathFromSourceRange, getNodeFromPath } from './queryAst' import { getNodeFromPath } from './queryAst'
import { getNodePathFromSourceRange } from 'lang/queryAstNodePathUtils'
import { import {
Identifier, Identifier,
assertParse, assertParse,

View File

@ -52,27 +52,22 @@ afterAll(async () => {
} catch (e) {} } catch (e) {}
}) })
afterEach(() => { describe('Test KCL Samples from public Github repository', () => {
process.chdir('..') describe('when performing enginelessExecutor', () => {
})
// The tests have to be sequential because we need to change directories
// to support `import` working properly.
// @ts-expect-error
describe.sequential('Test KCL Samples from public Github repository', () => {
// @ts-expect-error
describe.sequential('when performing enginelessExecutor', () => {
manifest.forEach((file: KclSampleFile) => { manifest.forEach((file: KclSampleFile) => {
// @ts-expect-error it(
it.sequential(
`should execute ${file.title} (${file.file}) successfully`, `should execute ${file.title} (${file.file}) successfully`,
async () => { async () => {
const [dirProject, fileKcl] = const code = await fs.readFile(
file.pathFromProjectDirectoryToFirstFile.split('/') file.pathFromProjectDirectoryToFirstFile,
process.chdir(dirProject) 'utf-8'
const code = await fs.readFile(fileKcl, 'utf-8') )
const ast = assertParse(code) const ast = assertParse(code)
await enginelessExecutor(ast, programMemoryInit()) await enginelessExecutor(
ast,
programMemoryInit(),
file.pathFromProjectDirectoryToFirstFile
)
}, },
files.length * 1000 files.length * 1000
) )

View File

@ -46,12 +46,14 @@ export const toolTips: Array<ToolTip> = [
export async function executeAst({ export async function executeAst({
ast, ast,
path,
engineCommandManager, engineCommandManager,
// If you set programMemoryOverride we assume you mean mock mode. Since that // If you set programMemoryOverride we assume you mean mock mode. Since that
// is the only way to go about it. // is the only way to go about it.
programMemoryOverride, programMemoryOverride,
}: { }: {
ast: Node<Program> ast: Node<Program>
path?: string
engineCommandManager: EngineCommandManager engineCommandManager: EngineCommandManager
programMemoryOverride?: ProgramMemory programMemoryOverride?: ProgramMemory
isInterrupted?: boolean isInterrupted?: boolean
@ -63,8 +65,8 @@ export async function executeAst({
}> { }> {
try { try {
const execState = await (programMemoryOverride const execState = await (programMemoryOverride
? enginelessExecutor(ast, programMemoryOverride) ? enginelessExecutor(ast, programMemoryOverride, path)
: executor(ast, engineCommandManager)) : executor(ast, engineCommandManager, path))
await engineCommandManager.waitForAllCommands() await engineCommandManager.waitForAllCommands()

View File

@ -5,6 +5,8 @@ import {
Identifier, Identifier,
SourceRange, SourceRange,
topLevelRange, topLevelRange,
LiteralValue,
Literal,
} from './wasm' } from './wasm'
import { import {
createLiteral, createLiteral,
@ -25,7 +27,8 @@ import {
deleteFromSelection, deleteFromSelection,
} from './modifyAst' } from './modifyAst'
import { enginelessExecutor } from '../lib/testHelpers' import { enginelessExecutor } from '../lib/testHelpers'
import { findUsesOfTagInPipe, getNodePathFromSourceRange } from './queryAst' import { findUsesOfTagInPipe } from './queryAst'
import { getNodePathFromSourceRange } from 'lang/queryAstNodePathUtils'
import { err } from 'lib/trap' import { err } from 'lib/trap'
import { SimplifiedArgDetails } from './std/stdTypes' import { SimplifiedArgDetails } from './std/stdTypes'
import { Node } from 'wasm-lib/kcl/bindings/Node' import { Node } from 'wasm-lib/kcl/bindings/Node'
@ -36,10 +39,26 @@ beforeAll(async () => {
}) })
describe('Testing createLiteral', () => { describe('Testing createLiteral', () => {
it('should create a literal', () => { it('should create a literal number without units', () => {
const result = createLiteral(5) const result = createLiteral(5)
expect(result.type).toBe('Literal') expect(result.type).toBe('Literal')
expect((result as any).value.value).toBe(5) expect((result as any).value.value).toBe(5)
expect((result as any).value.suffix).toBe('None')
expect((result as Literal).raw).toBe('5')
})
it('should create a literal number with units', () => {
const lit: LiteralValue = { value: 5, suffix: 'Mm' }
const result = createLiteral(lit)
expect(result.type).toBe('Literal')
expect((result as any).value.value).toBe(5)
expect((result as any).value.suffix).toBe('Mm')
expect((result as Literal).raw).toBe('5mm')
})
it('should create a literal boolean', () => {
const result = createLiteral(false)
expect(result.type).toBe('Literal')
expect((result as Literal).value).toBe(false)
expect((result as Literal).raw).toBe('false')
}) })
}) })
describe('Testing createIdentifier', () => { describe('Testing createIdentifier', () => {

View File

@ -20,16 +20,17 @@ import {
SourceRange, SourceRange,
sketchFromKclValue, sketchFromKclValue,
isPathToNodeNumber, isPathToNodeNumber,
formatNumber,
} from './wasm' } from './wasm'
import { import {
isNodeSafeToReplacePath, isNodeSafeToReplacePath,
findAllPreviousVariables, findAllPreviousVariables,
findAllPreviousVariablesPath, findAllPreviousVariablesPath,
getNodeFromPath, getNodeFromPath,
getNodePathFromSourceRange,
isNodeSafeToReplace, isNodeSafeToReplace,
traverse, traverse,
} from './queryAst' } from './queryAst'
import { getNodePathFromSourceRange } from 'lang/queryAstNodePathUtils'
import { addTagForSketchOnFace, getConstraintInfo } from './std/sketch' import { addTagForSketchOnFace, getConstraintInfo } from './std/sketch'
import { import {
PathToNodeMap, PathToNodeMap,
@ -46,6 +47,7 @@ import { Models } from '@kittycad/lib'
import { ExtrudeFacePlane } from 'machines/modelingMachine' import { ExtrudeFacePlane } from 'machines/modelingMachine'
import { Node } from 'wasm-lib/kcl/bindings/Node' import { Node } from 'wasm-lib/kcl/bindings/Node'
import { KclExpressionWithVariable } from 'lib/commandTypes' import { KclExpressionWithVariable } from 'lib/commandTypes'
import { deleteEdgeTreatment } from './modifyAst/addEdgeTreatment'
export function startSketchOnDefault( export function startSketchOnDefault(
node: Node<Program>, node: Node<Program>,
@ -743,11 +745,26 @@ export function splitPathAtPipeExpression(pathToNode: PathToNode): {
return splitPathAtPipeExpression(pathToNode.slice(0, -1)) return splitPathAtPipeExpression(pathToNode.slice(0, -1))
} }
/**
* Note: This depends on WASM, but it's not async. Callers are responsible for
* awaiting init of the WASM module.
*/
export function createLiteral(value: LiteralValue | number): Node<Literal> { export function createLiteral(value: LiteralValue | number): Node<Literal> {
const raw = `${value}`
if (typeof value === 'number') { if (typeof value === 'number') {
value = { value, suffix: 'None' } value = { value, suffix: 'None' }
} }
let raw: string
if (typeof value === 'string') {
// TODO: Should we handle escape sequences?
raw = `${value}`
} else if (typeof value === 'boolean') {
raw = `${value}`
} else if (typeof value.value === 'number' && value.suffix === 'None') {
// Fast path for numbers when there are no units.
raw = `${value.value}`
} else {
raw = formatNumber(value.value, value.suffix)
}
return { return {
type: 'Literal', type: 'Literal',
start: 0, start: 0,
@ -1355,6 +1372,8 @@ export async function deleteFromSelection(
} }
// await prom // await prom
return astClone return astClone
} else if (selection.artifact?.type === 'edgeCut') {
return deleteEdgeTreatment(astClone, selection)
} else if (varDec.node.init.type === 'PipeExpression') { } else if (varDec.node.init.type === 'PipeExpression') {
const pipeBody = varDec.node.init.body const pipeBody = varDec.node.init.body
if ( if (

View File

@ -20,8 +20,10 @@ import {
FilletParameters, FilletParameters,
ChamferParameters, ChamferParameters,
EdgeTreatmentParameters, EdgeTreatmentParameters,
deleteEdgeTreatment,
} from './addEdgeTreatment' } from './addEdgeTreatment'
import { getNodeFromPath, getNodePathFromSourceRange } from '../queryAst' import { getNodeFromPath } from '../queryAst'
import { getNodePathFromSourceRange } from 'lang/queryAstNodePathUtils'
import { createLiteral } from 'lang/modifyAst' import { createLiteral } from 'lang/modifyAst'
import { err } from 'lib/trap' import { err } from 'lib/trap'
import { Selection, Selections } from 'lib/selections' import { Selection, Selections } from 'lib/selections'
@ -286,7 +288,7 @@ const runModifyAstCloneWithEdgeTreatmentAndTag = async (
otherSelections: [], otherSelections: [],
} }
// apply edge treatment to seleciton // apply edge treatment to selection
const result = modifyAstWithEdgeTreatmentAndTag(ast, selection, parameters) const result = modifyAstWithEdgeTreatmentAndTag(ast, selection, parameters)
if (err(result)) { if (err(result)) {
return result return result
@ -297,6 +299,46 @@ const runModifyAstCloneWithEdgeTreatmentAndTag = async (
expect(newCode).toContain(expectedCode) expect(newCode).toContain(expectedCode)
} }
const runDeleteEdgeTreatmentTest = async (
code: string,
edgeTreatmentSnippet: string,
expectedCode: string
) => {
// parse ast
const ast = assertParse(code)
// update artifact graph
await kclManager.executeAst({ ast })
const artifactGraph = engineCommandManager.artifactGraph
// define snippet range
const edgeTreatmentRange = topLevelRange(
code.indexOf(edgeTreatmentSnippet),
code.indexOf(edgeTreatmentSnippet) + edgeTreatmentSnippet.length
)
// find artifact
const maybeArtifact = [...artifactGraph].find(([, artifact]) => {
if (!('codeRef' in artifact)) return false
return isOverlap(artifact.codeRef.range, edgeTreatmentRange)
})
// build selection
const selection: Selection = {
codeRef: codeRefFromRange(edgeTreatmentRange, ast),
artifact: maybeArtifact ? maybeArtifact[1] : undefined,
}
// delete edge treatment
const result = await deleteEdgeTreatment(ast, selection)
if (err(result)) {
return result
}
// recast and check
const newCode = recast(result)
expect(newCode).toContain(expectedCode)
}
const createFilletParameters = (radiusValue: number): FilletParameters => ({ const createFilletParameters = (radiusValue: number): FilletParameters => ({
type: EdgeTreatmentType.Fillet, type: EdgeTreatmentType.Fillet,
radius: { radius: {
@ -573,6 +615,191 @@ extrude002 = extrude(-25, sketch002)
) )
}) })
}) })
describe(`Testing deleteEdgeTreatment with ${edgeTreatmentType}s`, () => {
// simple cases
it(`should delete a piped ${edgeTreatmentType} from a single segment`, async () => {
const code = `sketch001 = startSketchOn('XY')
|> startProfileAt([-10, 10], %)
|> line([20, 0], %)
|> line([0, -20], %)
|> line([-20, 0], %, $seg01)
|> lineTo([profileStartX(%), profileStartY(%)], %)
|> close(%)
extrude001 = extrude(-15, sketch001)
|> ${edgeTreatmentType}({ ${parameterName} = 3, tags = [seg01] }, %)`
const edgeTreatmentSnippet = `${edgeTreatmentType}({ ${parameterName} = 3, tags = [seg01] }, %)`
const expectedCode = `sketch001 = startSketchOn('XY')
|> startProfileAt([-10, 10], %)
|> line([20, 0], %)
|> line([0, -20], %)
|> line([-20, 0], %, $seg01)
|> lineTo([profileStartX(%), profileStartY(%)], %)
|> close(%)
extrude001 = extrude(-15, sketch001)`
await runDeleteEdgeTreatmentTest(
code,
edgeTreatmentSnippet,
expectedCode
)
})
it(`should delete a non-piped ${edgeTreatmentType} from a single segment`, async () => {
const code = `sketch001 = startSketchOn('XY')
|> startProfileAt([-10, 10], %)
|> line([20, 0], %)
|> line([0, -20], %)
|> line([-20, 0], %, $seg01)
|> lineTo([profileStartX(%), profileStartY(%)], %)
|> close(%)
extrude001 = extrude(-15, sketch001)
fillet001 = ${edgeTreatmentType}({ ${parameterName} = 3, tags = [seg01] }, extrude001)`
const edgeTreatmentSnippet = `fillet001 = ${edgeTreatmentType}({ ${parameterName} = 3, tags = [seg01] }, extrude001)`
const expectedCode = `sketch001 = startSketchOn('XY')
|> startProfileAt([-10, 10], %)
|> line([20, 0], %)
|> line([0, -20], %)
|> line([-20, 0], %, $seg01)
|> lineTo([profileStartX(%), profileStartY(%)], %)
|> close(%)
extrude001 = extrude(-15, sketch001)`
await runDeleteEdgeTreatmentTest(
code,
edgeTreatmentSnippet,
expectedCode
)
})
// getOppositeEdge and getNextAdjacentEdge cases
it(`should delete a piped ${edgeTreatmentType} tagged with getOppositeEdge`, async () => {
const code = `sketch001 = startSketchOn('XY')
|> startProfileAt([-10, 10], %)
|> line([20, 0], %)
|> line([0, -20], %)
|> line([-20, 0], %, $seg01)
|> lineTo([profileStartX(%), profileStartY(%)], %)
|> close(%)
extrude001 = extrude(-15, sketch001)
fillet001 = ${edgeTreatmentType}({ ${parameterName} = 3, tags = [getOppositeEdge(seg01)] }, extrude001)`
const edgeTreatmentSnippet = `fillet001 = ${edgeTreatmentType}({ ${parameterName} = 3, tags = [getOppositeEdge(seg01)] }, extrude001)`
const expectedCode = `sketch001 = startSketchOn('XY')
|> startProfileAt([-10, 10], %)
|> line([20, 0], %)
|> line([0, -20], %)
|> line([-20, 0], %, $seg01)
|> lineTo([profileStartX(%), profileStartY(%)], %)
|> close(%)
extrude001 = extrude(-15, sketch001)`
await runDeleteEdgeTreatmentTest(
code,
edgeTreatmentSnippet,
expectedCode
)
})
it(`should delete a non-piped ${edgeTreatmentType} tagged with getNextAdjacentEdge`, async () => {
const code = `sketch001 = startSketchOn('XY')
|> startProfileAt([-10, 10], %)
|> line([20, 0], %)
|> line([0, -20], %)
|> line([-20, 0], %, $seg01)
|> lineTo([profileStartX(%), profileStartY(%)], %)
|> close(%)
extrude001 = extrude(-15, sketch001)
fillet001 = ${edgeTreatmentType}({ ${parameterName} = 3, tags = [getNextAdjacentEdge(seg01)] }, extrude001)`
const edgeTreatmentSnippet = `fillet001 = ${edgeTreatmentType}({ ${parameterName} = 3, tags = [getNextAdjacentEdge(seg01)] }, extrude001)`
const expectedCode = `sketch001 = startSketchOn('XY')
|> startProfileAt([-10, 10], %)
|> line([20, 0], %)
|> line([0, -20], %)
|> line([-20, 0], %, $seg01)
|> lineTo([profileStartX(%), profileStartY(%)], %)
|> close(%)
extrude001 = extrude(-15, sketch001)`
await runDeleteEdgeTreatmentTest(
code,
edgeTreatmentSnippet,
expectedCode
)
})
// cases with several edge treatments
it(`should delete a piped ${edgeTreatmentType} from a body with multiple treatments`, async () => {
const code = `sketch001 = startSketchOn('XY')
|> startProfileAt([-10, 10], %)
|> line([20, 0], %, $seg01)
|> line([0, -20], %)
|> line([-20, 0], %, $seg02)
|> lineTo([profileStartX(%), profileStartY(%)], %)
|> close(%)
extrude001 = extrude(-15, sketch001)
|> ${edgeTreatmentType}({ ${parameterName} = 3, tags = [seg01] }, %)
|> fillet({ radius = 5, tags = [getOppositeEdge(seg02)] }, %)
fillet001 = ${edgeTreatmentType}({ ${parameterName} = 6, tags = [seg02] }, extrude001)
chamfer001 = chamfer({ length = 5, tags = [getOppositeEdge(seg01)] }, extrude001)`
const edgeTreatmentSnippet = `${edgeTreatmentType}({ ${parameterName} = 3, tags = [seg01] }, %)`
const expectedCode = `sketch001 = startSketchOn('XY')
|> startProfileAt([-10, 10], %)
|> line([20, 0], %, $seg01)
|> line([0, -20], %)
|> line([-20, 0], %, $seg02)
|> lineTo([profileStartX(%), profileStartY(%)], %)
|> close(%)
extrude001 = extrude(-15, sketch001)
|> fillet({
radius = 5,
tags = [getOppositeEdge(seg02)]
}, %)
fillet001 = ${edgeTreatmentType}({ ${parameterName} = 6, tags = [seg02] }, extrude001)
chamfer001 = chamfer({
length = 5,
tags = [getOppositeEdge(seg01)]
}, extrude001)`
await runDeleteEdgeTreatmentTest(
code,
edgeTreatmentSnippet,
expectedCode
)
})
it(`should delete a non-piped ${edgeTreatmentType} from a body with multiple treatments`, async () => {
const code = `sketch001 = startSketchOn('XY')
|> startProfileAt([-10, 10], %)
|> line([20, 0], %, $seg01)
|> line([0, -20], %)
|> line([-20, 0], %, $seg02)
|> lineTo([profileStartX(%), profileStartY(%)], %)
|> close(%)
extrude001 = extrude(-15, sketch001)
|> ${edgeTreatmentType}({ ${parameterName} = 3, tags = [seg01] }, %)
|> fillet({ radius = 5, tags = [getOppositeEdge(seg02)] }, %)
fillet001 = ${edgeTreatmentType}({ ${parameterName} = 6, tags = [seg02] }, extrude001)
chamfer001 = chamfer({ length = 5, tags = [getOppositeEdge(seg01)] }, extrude001)`
const edgeTreatmentSnippet = `fillet001 = ${edgeTreatmentType}({ ${parameterName} = 6, tags = [seg02] }, extrude001)`
const expectedCode = `sketch001 = startSketchOn('XY')
|> startProfileAt([-10, 10], %)
|> line([20, 0], %, $seg01)
|> line([0, -20], %)
|> line([-20, 0], %, $seg02)
|> lineTo([profileStartX(%), profileStartY(%)], %)
|> close(%)
extrude001 = extrude(-15, sketch001)
|> ${edgeTreatmentType}({ ${parameterName} = 3, tags = [seg01] }, %)
|> fillet({
radius = 5,
tags = [getOppositeEdge(seg02)]
}, %)
chamfer001 = chamfer({
length = 5,
tags = [getOppositeEdge(seg01)]
}, extrude001)`
await runDeleteEdgeTreatmentTest(
code,
edgeTreatmentSnippet,
expectedCode
)
})
})
} }
) )

View File

@ -5,6 +5,7 @@ import {
Identifier, Identifier,
ObjectExpression, ObjectExpression,
PathToNode, PathToNode,
PipeExpression,
Program, Program,
VariableDeclaration, VariableDeclaration,
VariableDeclarator, VariableDeclarator,
@ -20,10 +21,10 @@ import {
} from '../modifyAst' } from '../modifyAst'
import { import {
getNodeFromPath, getNodeFromPath,
getNodePathFromSourceRange,
hasSketchPipeBeenExtruded, hasSketchPipeBeenExtruded,
traverse, traverse,
} from '../queryAst' } from '../queryAst'
import { getNodePathFromSourceRange } from 'lang/queryAstNodePathUtils'
import { import {
addTagForSketchOnFace, addTagForSketchOnFace,
getTagFromCallExpression, getTagFromCallExpression,
@ -722,3 +723,148 @@ export const isTagUsedInEdgeTreatment = ({
return edges return edges
} }
// Delete Edge Treatment
export async function deleteEdgeTreatment(
ast: Node<Program>,
selection: Selection
): Promise<Node<Program> | Error> {
/**
* Deletes an edge treatment (fillet or chamfer)
* from the AST based on the selection.
* Handles both standalone treatments
* and those within a PipeExpression.
*
* Supported cases:
* [+] fillet and chamfer
* [+] piped and non-piped edge treatments
* [-] delete single tag from array of tags (currently whole expression is deleted)
* [-] multiple selections with different edge treatments (currently single selection is supported)
*/
// 1. Validate Selection Type
const { artifact } = selection
if (!artifact || artifact.type !== 'edgeCut') {
return new Error('Selection is not an edge cut')
}
const { subType: edgeTreatmentType } = artifact
if (
!edgeTreatmentType ||
!['fillet', 'chamfer'].includes(edgeTreatmentType)
) {
return new Error('Unsupported or missing edge treatment type')
}
// 2. Clone ast and retrieve the VariableDeclarator
const astClone = structuredClone(ast)
const varDec = getNodeFromPath<VariableDeclarator>(
ast,
selection?.codeRef?.pathToNode,
'VariableDeclarator'
)
if (err(varDec)) return varDec
// 3: Check if edge treatment is in a pipe
const inPipe = varDec.node.init.type === 'PipeExpression'
// 4A. Handle standalone edge treatment
if (!inPipe) {
const varDecPathStep = varDec.shallowPath[1]
if (
!Array.isArray(varDecPathStep) ||
typeof varDecPathStep[0] !== 'number'
) {
return new Error(
'Invalid shallowPath structure: expected a number at shallowPath[1][0]'
)
}
const varDecIndex: number = varDecPathStep[0]
// Remove entire VariableDeclarator from the ast
astClone.body.splice(varDecIndex, 1)
return astClone
}
// 4B. Handle edge treatment within pipe
if (inPipe) {
// Retrieve the CallExpression path
const callExp =
getNodeFromPath<CallExpression>(
ast,
selection?.codeRef?.pathToNode,
'CallExpression'
) ?? null
if (err(callExp)) return callExp
const shallowPath = callExp.shallowPath
// Initialize variables to hold the PipeExpression path and callIndex
let pipeExpressionPath: PathToNode | null = null
let callIndex: number | null = null
// Iterate through the shallowPath to find the PipeExpression and callIndex
for (let i = 0; i < shallowPath.length - 1; i++) {
const [key, value] = shallowPath[i]
if (key === 'body' && value === 'PipeExpression') {
pipeExpressionPath = shallowPath.slice(0, i + 1)
const nextStep = shallowPath[i + 1]
if (
nextStep &&
nextStep[1] === 'index' &&
typeof nextStep[0] === 'number'
) {
callIndex = nextStep[0]
}
break
}
}
if (!pipeExpressionPath) {
return new Error('PipeExpression not found in path')
}
if (callIndex === null) {
return new Error('Failed to extract CallExpression index')
}
// Retrieve the PipeExpression node
const pipeExpressionNode = getNodeFromPath<PipeExpression>(
astClone,
pipeExpressionPath,
'PipeExpression'
)
if (err(pipeExpressionNode)) return pipeExpressionNode
// Ensure that the PipeExpression.body is an array
if (!Array.isArray(pipeExpressionNode.node.body)) {
return new Error('PipeExpression body is not an array')
}
// Remove the CallExpression at the specified index
pipeExpressionNode.node.body.splice(callIndex, 1)
// Remove VariableDeclarator if PipeExpression.body is empty
if (pipeExpressionNode.node.body.length === 0) {
const varDecPathStep = varDec.shallowPath[1]
if (
!Array.isArray(varDecPathStep) ||
typeof varDecPathStep[0] !== 'number'
) {
return new Error(
'Invalid shallowPath structure: expected a number at shallowPath[1][0]'
)
}
const varDecIndex: number = varDecPathStep[0]
astClone.body.splice(varDecIndex, 1)
}
return astClone
}
return Error('Delete fillets not implemented')
}

View File

@ -19,7 +19,8 @@ import {
findUniqueName, findUniqueName,
createVariableDeclaration, createVariableDeclaration,
} from 'lang/modifyAst' } from 'lang/modifyAst'
import { getNodeFromPath, getNodePathFromSourceRange } from 'lang/queryAst' import { getNodeFromPath } from 'lang/queryAst'
import { getNodePathFromSourceRange } from 'lang/queryAstNodePathUtils'
import { import {
mutateAstWithTagForSketchSegment, mutateAstWithTagForSketchSegment,
getEdgeTagCall, getEdgeTagCall,

View File

@ -5,12 +5,15 @@ import {
PathToNode, PathToNode,
Identifier, Identifier,
topLevelRange, topLevelRange,
PipeExpression,
CallExpression,
VariableDeclarator,
} from './wasm' } from './wasm'
import { ProgramMemory } from 'lang/wasm'
import { import {
findAllPreviousVariables, findAllPreviousVariables,
isNodeSafeToReplace, isNodeSafeToReplace,
isTypeInValue, isTypeInValue,
getNodePathFromSourceRange,
hasExtrudeSketch, hasExtrudeSketch,
findUsesOfTagInPipe, findUsesOfTagInPipe,
hasSketchPipeBeenExtruded, hasSketchPipeBeenExtruded,
@ -19,15 +22,18 @@ import {
getNodeFromPath, getNodeFromPath,
doesSceneHaveExtrudedSketch, doesSceneHaveExtrudedSketch,
} from './queryAst' } from './queryAst'
import { getNodePathFromSourceRange } from 'lang/queryAstNodePathUtils'
import { enginelessExecutor } from '../lib/testHelpers' import { enginelessExecutor } from '../lib/testHelpers'
import { import {
createArrayExpression, createArrayExpression,
createCallExpression, createCallExpression,
createLiteral, createLiteral,
createPipeSubstitution, createPipeSubstitution,
createCallExpressionStdLib,
} from './modifyAst' } from './modifyAst'
import { err } from 'lib/trap' import { err } from 'lib/trap'
import { codeRefFromRange } from './std/artifactGraph' import { codeRefFromRange } from './std/artifactGraph'
import { addCallExpressionsToPipe, addCloseToPipe } from 'lang/std/sketch'
beforeAll(async () => { beforeAll(async () => {
await initPromise await initPromise
@ -680,3 +686,115 @@ myNestedVar = [
expect(pathToNode).toEqual(pathToNode2) expect(pathToNode).toEqual(pathToNode2)
}) })
}) })
describe('Testing specific sketch getNodeFromPath workflow', () => {
it('should parse the code', () => {
const openSketch = `sketch001 = startSketchOn('XZ')
|> startProfileAt([0.02, 0.22], %)
|> xLine(0.39, %)
|> line([0.02, -0.17], %)
|> yLine(-0.15, %)
|> line([-0.21, -0.02], %)
|> xLine(-0.15, %)
|> line([-0.02, 0.21], %)
|> line([-0.08, 0.05], %)`
const ast = assertParse(openSketch)
expect(ast.start).toEqual(0)
expect(ast.end).toEqual(227)
})
it('should find the location to add new lineTo', () => {
const openSketch = `sketch001 = startSketchOn('XZ')
|> startProfileAt([0.02, 0.22], %)
|> xLine(0.39, %)
|> line([0.02, -0.17], %)
|> yLine(-0.15, %)
|> line([-0.21, -0.02], %)
|> xLine(-0.15, %)
|> line([-0.02, 0.21], %)
|> line([-0.08, 0.05], %)`
const ast = assertParse(openSketch)
const sketchSnippet = `startProfileAt([0.02, 0.22], %)`
const sketchRange = topLevelRange(
openSketch.indexOf(sketchSnippet),
openSketch.indexOf(sketchSnippet) + sketchSnippet.length
)
const sketchPathToNode = getNodePathFromSourceRange(ast, sketchRange)
const modifiedAst = addCallExpressionsToPipe({
node: ast,
programMemory: ProgramMemory.empty(),
pathToNode: sketchPathToNode,
expressions: [
createCallExpressionStdLib(
'lineTo', // We are forcing lineTo!
[
createArrayExpression([
createCallExpressionStdLib('profileStartX', [
createPipeSubstitution(),
]),
createCallExpressionStdLib('profileStartY', [
createPipeSubstitution(),
]),
]),
createPipeSubstitution(),
]
),
],
})
if (err(modifiedAst)) throw modifiedAst
const recasted = recast(modifiedAst)
const expectedCode = `sketch001 = startSketchOn('XZ')
|> startProfileAt([0.02, 0.22], %)
|> xLine(0.39, %)
|> line([0.02, -0.17], %)
|> yLine(-0.15, %)
|> line([-0.21, -0.02], %)
|> xLine(-0.15, %)
|> line([-0.02, 0.21], %)
|> line([-0.08, 0.05], %)
|> lineTo([profileStartX(%), profileStartY(%)], %)
`
expect(recasted).toEqual(expectedCode)
})
it('it should find the location to add close', () => {
const openSketch = `sketch001 = startSketchOn('XZ')
|> startProfileAt([0.02, 0.22], %)
|> xLine(0.39, %)
|> line([0.02, -0.17], %)
|> yLine(-0.15, %)
|> line([-0.21, -0.02], %)
|> xLine(-0.15, %)
|> line([-0.02, 0.21], %)
|> line([-0.08, 0.05], %)
|> lineTo([profileStartX(%), profileStartY(%)], %)
`
const ast = assertParse(openSketch)
const sketchSnippet = `startProfileAt([0.02, 0.22], %)`
const sketchRange = topLevelRange(
openSketch.indexOf(sketchSnippet),
openSketch.indexOf(sketchSnippet) + sketchSnippet.length
)
const sketchPathToNode = getNodePathFromSourceRange(ast, sketchRange)
const modifiedAst = addCloseToPipe({
node: ast,
programMemory: ProgramMemory.empty(),
pathToNode: sketchPathToNode,
})
if (err(modifiedAst)) throw modifiedAst
const recasted = recast(modifiedAst)
const expectedCode = `sketch001 = startSketchOn('XZ')
|> startProfileAt([0.02, 0.22], %)
|> xLine(0.39, %)
|> line([0.02, -0.17], %)
|> yLine(-0.15, %)
|> line([-0.21, -0.02], %)
|> xLine(-0.15, %)
|> line([-0.02, 0.21], %)
|> line([-0.08, 0.05], %)
|> lineTo([profileStartX(%), profileStartY(%)], %)
|> close(%)
`
expect(recasted).toEqual(expectedCode)
})
})

View File

@ -21,7 +21,9 @@ import {
topLevelRange, topLevelRange,
VariableDeclaration, VariableDeclaration,
VariableDeclarator, VariableDeclarator,
recast,
} from './wasm' } from './wasm'
import { getNodePathFromSourceRange } from 'lang/queryAstNodePathUtils'
import { createIdentifier, splitPathAtLastIndex } from './modifyAst' import { createIdentifier, splitPathAtLastIndex } from './modifyAst'
import { getSketchSegmentFromSourceRange } from './std/sketchConstraints' import { getSketchSegmentFromSourceRange } from './std/sketchConstraints'
import { getAngle } from '../lib/utils' import { getAngle } from '../lib/utils'
@ -67,7 +69,28 @@ export function getNodeFromPath<T>(
deepPath: successfulPaths, deepPath: successfulPaths,
} }
} }
return new Error('not an object') const stackTraceError = new Error()
const sourceCode = recast(node)
const levels = stackTraceError.stack?.split('\n')
const aFewFunctionNames: string[] = []
let tree = ''
levels?.forEach((val, index) => {
const fnName = val.trim().split(' ')[1]
const ending = index === levels.length - 1 ? ' ' : ' > '
tree += fnName + ending
if (index < 3) {
aFewFunctionNames.push(fnName)
}
})
const error = new Error(
`Failed to stopAt ${stopAt}, ${aFewFunctionNames
.filter((a) => a)
.join(' > ')}`
)
console.error(tree)
console.error(sourceCode)
console.error(error.stack)
return error
} }
currentNode = currentNode?.[pathItem[0]] currentNode = currentNode?.[pathItem[0]]
successfulPaths.push(pathItem) successfulPaths.push(pathItem)
@ -125,311 +148,6 @@ export function getNodeFromPathCurry(
} }
} }
function moreNodePathFromSourceRange(
node: Node<
| Expr
| ImportStatement
| ExpressionStatement
| VariableDeclaration
| ReturnStatement
>,
sourceRange: SourceRange,
previousPath: PathToNode = [['body', '']]
): PathToNode {
const [start, end] = sourceRange
let path: PathToNode = [...previousPath]
const _node = { ...node }
if (start < _node.start || end > _node.end) return path
const isInRange = _node.start <= start && _node.end >= end
if (
(_node.type === 'Identifier' ||
_node.type === 'Literal' ||
_node.type === 'TagDeclarator') &&
isInRange
) {
return path
}
if (_node.type === 'CallExpression' && isInRange) {
const { callee, arguments: args } = _node
if (
callee.type === 'Identifier' &&
callee.start <= start &&
callee.end >= end
) {
path.push(['callee', 'CallExpression'])
return path
}
if (args.length > 0) {
for (let argIndex = 0; argIndex < args.length; argIndex++) {
const arg = args[argIndex]
if (arg.start <= start && arg.end >= end) {
path.push(['arguments', 'CallExpression'])
path.push([argIndex, 'index'])
return moreNodePathFromSourceRange(arg, sourceRange, path)
}
}
}
return path
}
if (_node.type === 'CallExpressionKw' && isInRange) {
const { callee, arguments: args } = _node
if (
callee.type === 'Identifier' &&
callee.start <= start &&
callee.end >= end
) {
path.push(['callee', 'CallExpressionKw'])
return path
}
if (args.length > 0) {
for (let argIndex = 0; argIndex < args.length; argIndex++) {
const arg = args[argIndex].arg
if (arg.start <= start && arg.end >= end) {
path.push(['arguments', 'CallExpressionKw'])
path.push([argIndex, 'index'])
return moreNodePathFromSourceRange(arg, sourceRange, path)
}
}
}
return path
}
if (_node.type === 'BinaryExpression' && isInRange) {
const { left, right } = _node
if (left.start <= start && left.end >= end) {
path.push(['left', 'BinaryExpression'])
return moreNodePathFromSourceRange(left, sourceRange, path)
}
if (right.start <= start && right.end >= end) {
path.push(['right', 'BinaryExpression'])
return moreNodePathFromSourceRange(right, sourceRange, path)
}
return path
}
if (_node.type === 'PipeExpression' && isInRange) {
const { body } = _node
for (let i = 0; i < body.length; i++) {
const pipe = body[i]
if (pipe.start <= start && pipe.end >= end) {
path.push(['body', 'PipeExpression'])
path.push([i, 'index'])
return moreNodePathFromSourceRange(pipe, sourceRange, path)
}
}
return path
}
if (_node.type === 'ArrayExpression' && isInRange) {
const { elements } = _node
for (let elIndex = 0; elIndex < elements.length; elIndex++) {
const element = elements[elIndex]
if (element.start <= start && element.end >= end) {
path.push(['elements', 'ArrayExpression'])
path.push([elIndex, 'index'])
return moreNodePathFromSourceRange(element, sourceRange, path)
}
}
return path
}
if (_node.type === 'ObjectExpression' && isInRange) {
const { properties } = _node
for (let propIndex = 0; propIndex < properties.length; propIndex++) {
const property = properties[propIndex]
if (property.start <= start && property.end >= end) {
path.push(['properties', 'ObjectExpression'])
path.push([propIndex, 'index'])
if (property.key.start <= start && property.key.end >= end) {
path.push(['key', 'Property'])
return moreNodePathFromSourceRange(property.key, sourceRange, path)
}
if (property.value.start <= start && property.value.end >= end) {
path.push(['value', 'Property'])
return moreNodePathFromSourceRange(property.value, sourceRange, path)
}
}
}
return path
}
if (_node.type === 'ExpressionStatement' && isInRange) {
const { expression } = _node
path.push(['expression', 'ExpressionStatement'])
return moreNodePathFromSourceRange(expression, sourceRange, path)
}
if (_node.type === 'VariableDeclaration' && isInRange) {
const declaration = _node.declaration
if (declaration.start <= start && declaration.end >= end) {
path.push(['declaration', 'VariableDeclaration'])
const init = declaration.init
if (init.start <= start && init.end >= end) {
path.push(['init', ''])
return moreNodePathFromSourceRange(init, sourceRange, path)
}
}
}
if (_node.type === 'VariableDeclaration' && isInRange) {
const declaration = _node.declaration
if (declaration.start <= start && declaration.end >= end) {
const init = declaration.init
if (init.start <= start && init.end >= end) {
path.push(['declaration', 'VariableDeclaration'])
path.push(['init', ''])
return moreNodePathFromSourceRange(init, sourceRange, path)
}
}
return path
}
if (_node.type === 'UnaryExpression' && isInRange) {
const { argument } = _node
if (argument.start <= start && argument.end >= end) {
path.push(['argument', 'UnaryExpression'])
return moreNodePathFromSourceRange(argument, sourceRange, path)
}
return path
}
if (_node.type === 'FunctionExpression' && isInRange) {
for (let i = 0; i < _node.params.length; i++) {
const param = _node.params[i]
if (param.identifier.start <= start && param.identifier.end >= end) {
path.push(['params', 'FunctionExpression'])
path.push([i, 'index'])
return moreNodePathFromSourceRange(param.identifier, sourceRange, path)
}
}
if (_node.body.start <= start && _node.body.end >= end) {
path.push(['body', 'FunctionExpression'])
const fnBody = _node.body.body
for (let i = 0; i < fnBody.length; i++) {
const statement = fnBody[i]
if (statement.start <= start && statement.end >= end) {
path.push(['body', 'FunctionExpression'])
path.push([i, 'index'])
return moreNodePathFromSourceRange(statement, sourceRange, path)
}
}
}
return path
}
if (_node.type === 'ReturnStatement' && isInRange) {
const { argument } = _node
if (argument.start <= start && argument.end >= end) {
path.push(['argument', 'ReturnStatement'])
return moreNodePathFromSourceRange(argument, sourceRange, path)
}
return path
}
if (_node.type === 'MemberExpression' && isInRange) {
const { object, property } = _node
if (object.start <= start && object.end >= end) {
path.push(['object', 'MemberExpression'])
return moreNodePathFromSourceRange(object, sourceRange, path)
}
if (property.start <= start && property.end >= end) {
path.push(['property', 'MemberExpression'])
return moreNodePathFromSourceRange(property, sourceRange, path)
}
return path
}
if (_node.type === 'PipeSubstitution' && isInRange) return path
if (_node.type === 'IfExpression' && isInRange) {
const { cond, then_val, else_ifs, final_else } = _node
if (cond.start <= start && cond.end >= end) {
path.push(['cond', 'IfExpression'])
return moreNodePathFromSourceRange(cond, sourceRange, path)
}
if (then_val.start <= start && then_val.end >= end) {
path.push(['then_val', 'IfExpression'])
path.push(['body', 'IfExpression'])
return getNodePathFromSourceRange(then_val, sourceRange, path)
}
for (let i = 0; i < else_ifs.length; i++) {
const else_if = else_ifs[i]
if (else_if.start <= start && else_if.end >= end) {
path.push(['else_ifs', 'IfExpression'])
path.push([i, 'index'])
const { cond, then_val } = else_if
if (cond.start <= start && cond.end >= end) {
path.push(['cond', 'IfExpression'])
return moreNodePathFromSourceRange(cond, sourceRange, path)
}
path.push(['then_val', 'IfExpression'])
path.push(['body', 'IfExpression'])
return getNodePathFromSourceRange(then_val, sourceRange, path)
}
}
if (final_else.start <= start && final_else.end >= end) {
path.push(['final_else', 'IfExpression'])
path.push(['body', 'IfExpression'])
return getNodePathFromSourceRange(final_else, sourceRange, path)
}
return path
}
if (_node.type === 'ImportStatement' && isInRange) {
if (_node.selector && _node.selector.type === 'List') {
path.push(['selector', 'ImportStatement'])
const { items } = _node.selector
for (let i = 0; i < items.length; i++) {
const item = items[i]
if (item.start <= start && item.end >= end) {
path.push(['items', 'ImportSelector'])
path.push([i, 'index'])
if (item.name.start <= start && item.name.end >= end) {
path.push(['name', 'ImportItem'])
return path
}
if (
item.alias &&
item.alias.start <= start &&
item.alias.end >= end
) {
path.push(['alias', 'ImportItem'])
return path
}
return path
}
}
return path
}
return path
}
console.error('not implemented: ' + node.type)
return path
}
export function getNodePathFromSourceRange(
node: Program,
sourceRange: SourceRange,
previousPath: PathToNode = [['body', '']]
): PathToNode {
const [start, end] = sourceRange || []
let path: PathToNode = [...previousPath]
const _node = { ...node }
// loop over each statement in body getting the index with a for loop
for (
let statementIndex = 0;
statementIndex < _node.body.length;
statementIndex++
) {
const statement = _node.body[statementIndex]
if (statement.start <= start && statement.end >= end) {
path.push([statementIndex, 'index'])
return moreNodePathFromSourceRange(statement, sourceRange, path)
}
}
return path
}
type KCLNode = Node< type KCLNode = Node<
| Expr | Expr
| ExpressionStatement | ExpressionStatement

View File

@ -0,0 +1,316 @@
import {
Expr,
ExpressionStatement,
VariableDeclaration,
ReturnStatement,
SourceRange,
PathToNode,
Program,
} from './wasm'
import { ImportStatement } from 'wasm-lib/kcl/bindings/ImportStatement'
import { Node } from 'wasm-lib/kcl/bindings/Node'
function moreNodePathFromSourceRange(
node: Node<
| Expr
| ImportStatement
| ExpressionStatement
| VariableDeclaration
| ReturnStatement
>,
sourceRange: SourceRange,
previousPath: PathToNode = [['body', '']]
): PathToNode {
const [start, end] = sourceRange
let path: PathToNode = [...previousPath]
const _node = { ...node }
if (start < _node.start || end > _node.end) return path
const isInRange = _node.start <= start && _node.end >= end
if (
(_node.type === 'Identifier' ||
_node.type === 'Literal' ||
_node.type === 'TagDeclarator') &&
isInRange
) {
return path
}
if (_node.type === 'CallExpression' && isInRange) {
const { callee, arguments: args } = _node
if (
callee.type === 'Identifier' &&
callee.start <= start &&
callee.end >= end
) {
path.push(['callee', 'CallExpression'])
return path
}
if (args.length > 0) {
for (let argIndex = 0; argIndex < args.length; argIndex++) {
const arg = args[argIndex]
if (arg.start <= start && arg.end >= end) {
path.push(['arguments', 'CallExpression'])
path.push([argIndex, 'index'])
return moreNodePathFromSourceRange(arg, sourceRange, path)
}
}
}
return path
}
if (_node.type === 'CallExpressionKw' && isInRange) {
const { callee, arguments: args } = _node
if (
callee.type === 'Identifier' &&
callee.start <= start &&
callee.end >= end
) {
path.push(['callee', 'CallExpressionKw'])
return path
}
if (args.length > 0) {
for (let argIndex = 0; argIndex < args.length; argIndex++) {
const arg = args[argIndex].arg
if (arg.start <= start && arg.end >= end) {
path.push(['arguments', 'CallExpressionKw'])
path.push([argIndex, 'index'])
return moreNodePathFromSourceRange(arg, sourceRange, path)
}
}
}
return path
}
if (_node.type === 'BinaryExpression' && isInRange) {
const { left, right } = _node
if (left.start <= start && left.end >= end) {
path.push(['left', 'BinaryExpression'])
return moreNodePathFromSourceRange(left, sourceRange, path)
}
if (right.start <= start && right.end >= end) {
path.push(['right', 'BinaryExpression'])
return moreNodePathFromSourceRange(right, sourceRange, path)
}
return path
}
if (_node.type === 'PipeExpression' && isInRange) {
const { body } = _node
for (let i = 0; i < body.length; i++) {
const pipe = body[i]
if (pipe.start <= start && pipe.end >= end) {
path.push(['body', 'PipeExpression'])
path.push([i, 'index'])
return moreNodePathFromSourceRange(pipe, sourceRange, path)
}
}
return path
}
if (_node.type === 'ArrayExpression' && isInRange) {
const { elements } = _node
for (let elIndex = 0; elIndex < elements.length; elIndex++) {
const element = elements[elIndex]
if (element.start <= start && element.end >= end) {
path.push(['elements', 'ArrayExpression'])
path.push([elIndex, 'index'])
return moreNodePathFromSourceRange(element, sourceRange, path)
}
}
return path
}
if (_node.type === 'ObjectExpression' && isInRange) {
const { properties } = _node
for (let propIndex = 0; propIndex < properties.length; propIndex++) {
const property = properties[propIndex]
if (property.start <= start && property.end >= end) {
path.push(['properties', 'ObjectExpression'])
path.push([propIndex, 'index'])
if (property.key.start <= start && property.key.end >= end) {
path.push(['key', 'Property'])
return moreNodePathFromSourceRange(property.key, sourceRange, path)
}
if (property.value.start <= start && property.value.end >= end) {
path.push(['value', 'Property'])
return moreNodePathFromSourceRange(property.value, sourceRange, path)
}
}
}
return path
}
if (_node.type === 'ExpressionStatement' && isInRange) {
const { expression } = _node
path.push(['expression', 'ExpressionStatement'])
return moreNodePathFromSourceRange(expression, sourceRange, path)
}
if (_node.type === 'VariableDeclaration' && isInRange) {
const declaration = _node.declaration
if (declaration.start <= start && declaration.end >= end) {
path.push(['declaration', 'VariableDeclaration'])
const init = declaration.init
if (init.start <= start && init.end >= end) {
path.push(['init', ''])
return moreNodePathFromSourceRange(init, sourceRange, path)
}
}
}
if (_node.type === 'VariableDeclaration' && isInRange) {
const declaration = _node.declaration
if (declaration.start <= start && declaration.end >= end) {
const init = declaration.init
if (init.start <= start && init.end >= end) {
path.push(['declaration', 'VariableDeclaration'])
path.push(['init', ''])
return moreNodePathFromSourceRange(init, sourceRange, path)
}
}
return path
}
if (_node.type === 'UnaryExpression' && isInRange) {
const { argument } = _node
if (argument.start <= start && argument.end >= end) {
path.push(['argument', 'UnaryExpression'])
return moreNodePathFromSourceRange(argument, sourceRange, path)
}
return path
}
if (_node.type === 'FunctionExpression' && isInRange) {
for (let i = 0; i < _node.params.length; i++) {
const param = _node.params[i]
if (param.identifier.start <= start && param.identifier.end >= end) {
path.push(['params', 'FunctionExpression'])
path.push([i, 'index'])
return moreNodePathFromSourceRange(param.identifier, sourceRange, path)
}
}
if (_node.body.start <= start && _node.body.end >= end) {
path.push(['body', 'FunctionExpression'])
const fnBody = _node.body.body
for (let i = 0; i < fnBody.length; i++) {
const statement = fnBody[i]
if (statement.start <= start && statement.end >= end) {
path.push(['body', 'FunctionExpression'])
path.push([i, 'index'])
return moreNodePathFromSourceRange(statement, sourceRange, path)
}
}
}
return path
}
if (_node.type === 'ReturnStatement' && isInRange) {
const { argument } = _node
if (argument.start <= start && argument.end >= end) {
path.push(['argument', 'ReturnStatement'])
return moreNodePathFromSourceRange(argument, sourceRange, path)
}
return path
}
if (_node.type === 'MemberExpression' && isInRange) {
const { object, property } = _node
if (object.start <= start && object.end >= end) {
path.push(['object', 'MemberExpression'])
return moreNodePathFromSourceRange(object, sourceRange, path)
}
if (property.start <= start && property.end >= end) {
path.push(['property', 'MemberExpression'])
return moreNodePathFromSourceRange(property, sourceRange, path)
}
return path
}
if (_node.type === 'PipeSubstitution' && isInRange) return path
if (_node.type === 'IfExpression' && isInRange) {
const { cond, then_val, else_ifs, final_else } = _node
if (cond.start <= start && cond.end >= end) {
path.push(['cond', 'IfExpression'])
return moreNodePathFromSourceRange(cond, sourceRange, path)
}
if (then_val.start <= start && then_val.end >= end) {
path.push(['then_val', 'IfExpression'])
path.push(['body', 'IfExpression'])
return getNodePathFromSourceRange(then_val, sourceRange, path)
}
for (let i = 0; i < else_ifs.length; i++) {
const else_if = else_ifs[i]
if (else_if.start <= start && else_if.end >= end) {
path.push(['else_ifs', 'IfExpression'])
path.push([i, 'index'])
const { cond, then_val } = else_if
if (cond.start <= start && cond.end >= end) {
path.push(['cond', 'IfExpression'])
return moreNodePathFromSourceRange(cond, sourceRange, path)
}
path.push(['then_val', 'IfExpression'])
path.push(['body', 'IfExpression'])
return getNodePathFromSourceRange(then_val, sourceRange, path)
}
}
if (final_else.start <= start && final_else.end >= end) {
path.push(['final_else', 'IfExpression'])
path.push(['body', 'IfExpression'])
return getNodePathFromSourceRange(final_else, sourceRange, path)
}
return path
}
if (_node.type === 'ImportStatement' && isInRange) {
if (_node.selector && _node.selector.type === 'List') {
path.push(['selector', 'ImportStatement'])
const { items } = _node.selector
for (let i = 0; i < items.length; i++) {
const item = items[i]
if (item.start <= start && item.end >= end) {
path.push(['items', 'ImportSelector'])
path.push([i, 'index'])
if (item.name.start <= start && item.name.end >= end) {
path.push(['name', 'ImportItem'])
return path
}
if (
item.alias &&
item.alias.start <= start &&
item.alias.end >= end
) {
path.push(['alias', 'ImportItem'])
return path
}
return path
}
}
return path
}
return path
}
console.error('not implemented: ' + node.type)
return path
}
export function getNodePathFromSourceRange(
node: Program,
sourceRange: SourceRange,
previousPath: PathToNode = [['body', '']]
): PathToNode {
const [start, end] = sourceRange || []
let path: PathToNode = [...previousPath]
const _node = { ...node }
// loop over each statement in body getting the index with a for loop
for (
let statementIndex = 0;
statementIndex < _node.body.length;
statementIndex++
) {
const statement = _node.body[statementIndex]
if (statement.start <= start && statement.end >= end) {
path.push([statementIndex, 'index'])
return moreNodePathFromSourceRange(statement, sourceRange, path)
}
}
return path
}

View File

@ -16,7 +16,7 @@ import {
EdgeCut, EdgeCut,
} from 'lang/wasm' } from 'lang/wasm'
import { Models } from '@kittycad/lib' import { Models } from '@kittycad/lib'
import { getNodePathFromSourceRange } from 'lang/queryAst' import { getNodePathFromSourceRange } from 'lang/queryAstNodePathUtils'
import { err } from 'lib/trap' import { err } from 'lib/trap'
export type { Artifact, ArtifactId, SegmentArtifact } from 'lang/wasm' export type { Artifact, ArtifactId, SegmentArtifact } from 'lang/wasm'

View File

@ -1389,6 +1389,7 @@ export class EngineCommandManager extends EventTarget {
enableSSAO: true, enableSSAO: true,
showScaleGrid: false, showScaleGrid: false,
cameraProjection: 'perspective', cameraProjection: 'perspective',
cameraOrbit: 'spherical',
} }
} }
@ -1437,6 +1438,7 @@ export class EngineCommandManager extends EventTarget {
enableSSAO: true, enableSSAO: true,
showScaleGrid: false, showScaleGrid: false,
cameraProjection: 'orthographic', cameraProjection: 'orthographic',
cameraOrbit: 'spherical',
}, },
// When passed, use a completely separate connecting code path that simply // When passed, use a completely separate connecting code path that simply
// opens a websocket and this is a function that is called when connected. // opens a websocket and this is a function that is called when connected.
@ -1999,7 +2001,7 @@ export class EngineCommandManager extends EventTarget {
.catch((e) => { .catch((e) => {
// TODO: Previously was never caught, we are not rejecting these pendingCommands but this needs to be handled at some point. // TODO: Previously was never caught, we are not rejecting these pendingCommands but this needs to be handled at some point.
/*noop*/ /*noop*/
return null return e
}) })
} }
/** /**

View File

@ -31,6 +31,9 @@ class FileSystemManager {
} }
async join(dir: string, path: string): Promise<string> { async join(dir: string, path: string): Promise<string> {
if (path.startsWith(dir)) {
path = path.slice(dir.length)
}
return Promise.resolve(window.electron.path.join(dir, path)) return Promise.resolve(window.electron.path.join(dir, path))
} }

View File

@ -14,7 +14,8 @@ import {
CallExpression, CallExpression,
topLevelRange, topLevelRange,
} from '../wasm' } from '../wasm'
import { getNodeFromPath, getNodePathFromSourceRange } from '../queryAst' import { getNodeFromPath } from '../queryAst'
import { getNodePathFromSourceRange } from 'lang/queryAstNodePathUtils'
import { enginelessExecutor } from '../../lib/testHelpers' import { enginelessExecutor } from '../../lib/testHelpers'
import { err } from 'lib/trap' import { err } from 'lib/trap'
import { Node } from 'wasm-lib/kcl/bindings/Node' import { Node } from 'wasm-lib/kcl/bindings/Node'

View File

@ -17,9 +17,9 @@ import {
import { import {
getNodeFromPath, getNodeFromPath,
getNodeFromPathCurry, getNodeFromPathCurry,
getNodePathFromSourceRange,
getObjExprProperty, getObjExprProperty,
} from 'lang/queryAst' } from 'lang/queryAst'
import { getNodePathFromSourceRange } from 'lang/queryAstNodePathUtils'
import { import {
isLiteralArrayOrStatic, isLiteralArrayOrStatic,
isNotLiteralArrayOrStatic, isNotLiteralArrayOrStatic,

View File

@ -22,11 +22,8 @@ import {
SourceRange, SourceRange,
LiteralValue, LiteralValue,
} from '../wasm' } from '../wasm'
import { import { getNodeFromPath, getNodeFromPathCurry } from '../queryAst'
getNodeFromPath, import { getNodePathFromSourceRange } from 'lang/queryAstNodePathUtils'
getNodeFromPathCurry,
getNodePathFromSourceRange,
} from '../queryAst'
import { import {
createArrayExpression, createArrayExpression,
createBinaryExpression, createBinaryExpression,

View File

@ -6,6 +6,8 @@ import {
ArrayExpression, ArrayExpression,
BinaryExpression, BinaryExpression,
ArtifactGraph, ArtifactGraph,
LiteralValue,
NumericSuffix,
} from './wasm' } from './wasm'
import { filterArtifacts } from 'lang/std/artifactGraph' import { filterArtifacts } from 'lang/std/artifactGraph'
import { isOverlap } from 'lib/utils' import { isOverlap } from 'lib/utils'
@ -69,3 +71,15 @@ export function isLiteral(e: any): e is Literal {
export function isBinaryExpression(e: any): e is BinaryExpression { export function isBinaryExpression(e: any): e is BinaryExpression {
return e && e.type === 'BinaryExpression' return e && e.type === 'BinaryExpression'
} }
export function isLiteralValueNumber(
e: LiteralValue
): e is { value: number; suffix: NumericSuffix } {
return (
typeof e === 'object' &&
'value' in e &&
typeof e.value === 'number' &&
'suffix' in e &&
typeof e.suffix === 'string'
)
}

View File

@ -1,5 +1,5 @@
import { err } from 'lib/trap' import { err } from 'lib/trap'
import { initPromise, parse, ParseResult } from './wasm' import { formatNumber, initPromise, parse, ParseResult } from './wasm'
import { enginelessExecutor } from 'lib/testHelpers' import { enginelessExecutor } from 'lib/testHelpers'
import { Node } from 'wasm-lib/kcl/bindings/Node' import { Node } from 'wasm-lib/kcl/bindings/Node'
import { Program } from '../wasm-lib/kcl/bindings/Program' import { Program } from '../wasm-lib/kcl/bindings/Program'
@ -20,3 +20,12 @@ it('can execute parsed AST', async () => {
expect(err(execState)).toEqual(false) expect(err(execState)).toEqual(false)
expect(execState.memory.get('x')?.value).toEqual(1) expect(execState.memory.get('x')?.value).toEqual(1)
}) })
it('formats numbers with units', () => {
expect(formatNumber(1, 'None')).toEqual('1')
expect(formatNumber(1, 'Count')).toEqual('1_')
expect(formatNumber(1, 'Mm')).toEqual('1mm')
expect(formatNumber(1, 'Inch')).toEqual('1in')
expect(formatNumber(0.5, 'Mm')).toEqual('0.5mm')
expect(formatNumber(-0.5, 'Mm')).toEqual('-0.5mm')
})

View File

@ -2,6 +2,7 @@ import {
init, init,
parse_wasm, parse_wasm,
recast_wasm, recast_wasm,
format_number,
execute, execute,
kcl_lint, kcl_lint,
modify_ast_for_sketch_wasm, modify_ast_for_sketch_wasm,
@ -17,6 +18,7 @@ import {
default_project_settings, default_project_settings,
base64_decode, base64_decode,
clear_scene_and_bust_cache, clear_scene_and_bust_cache,
change_kcl_settings,
reloadModule, reloadModule,
} from 'lib/wasm_lib_wrapper' } from 'lib/wasm_lib_wrapper'
@ -53,7 +55,9 @@ import { ArtifactId } from 'wasm-lib/kcl/bindings/Artifact'
import { ArtifactCommand } from 'wasm-lib/kcl/bindings/Artifact' import { ArtifactCommand } from 'wasm-lib/kcl/bindings/Artifact'
import { ArtifactGraph as RustArtifactGraph } from 'wasm-lib/kcl/bindings/Artifact' import { ArtifactGraph as RustArtifactGraph } from 'wasm-lib/kcl/bindings/Artifact'
import { Artifact } from './std/artifactGraph' import { Artifact } from './std/artifactGraph'
import { getNodePathFromSourceRange } from './queryAst' import { getNodePathFromSourceRange } from 'lang/queryAstNodePathUtils'
import { NumericSuffix } from 'wasm-lib/kcl/bindings/NumericSuffix'
import { MetaSettings } from 'wasm-lib/kcl/bindings/MetaSettings'
export type { Artifact } from 'wasm-lib/kcl/bindings/Artifact' export type { Artifact } from 'wasm-lib/kcl/bindings/Artifact'
export type { ArtifactCommand } from 'wasm-lib/kcl/bindings/Artifact' export type { ArtifactCommand } from 'wasm-lib/kcl/bindings/Artifact'
@ -90,6 +94,7 @@ export type { Literal } from '../wasm-lib/kcl/bindings/Literal'
export type { LiteralValue } from '../wasm-lib/kcl/bindings/LiteralValue' export type { LiteralValue } from '../wasm-lib/kcl/bindings/LiteralValue'
export type { ArrayExpression } from '../wasm-lib/kcl/bindings/ArrayExpression' export type { ArrayExpression } from '../wasm-lib/kcl/bindings/ArrayExpression'
export type { SourceRange } from 'wasm-lib/kcl/bindings/SourceRange' export type { SourceRange } from 'wasm-lib/kcl/bindings/SourceRange'
export type { NumericSuffix } from 'wasm-lib/kcl/bindings/NumericSuffix'
export type SyntaxType = export type SyntaxType =
| 'Program' | 'Program'
@ -566,9 +571,19 @@ export function sketchFromKclValue(
return result return result
} }
/**
* Execute a KCL program.
* @param node The AST of the program to execute.
* @param path The full path of the file being executed. Use `null` for
* expressions that don't have a file, like expressions in the command bar.
* @param programMemoryOverride If this is not `null`, this will be used as the
* initial program memory, and the execution will be engineless (AKA mock
* execution).
*/
export const executor = async ( export const executor = async (
node: Node<Program>, node: Node<Program>,
engineCommandManager: EngineCommandManager, engineCommandManager: EngineCommandManager,
path?: string,
programMemoryOverride: ProgramMemory | Error | null = null programMemoryOverride: ProgramMemory | Error | null = null
): Promise<ExecState> => { ): Promise<ExecState> => {
if (programMemoryOverride !== null && err(programMemoryOverride)) if (programMemoryOverride !== null && err(programMemoryOverride))
@ -590,6 +605,7 @@ export const executor = async (
} }
const execOutcome: RustExecOutcome = await execute( const execOutcome: RustExecOutcome = await execute(
JSON.stringify(node), JSON.stringify(node),
path,
JSON.stringify(programMemoryOverride?.toRaw() || null), JSON.stringify(programMemoryOverride?.toRaw() || null),
JSON.stringify({ settings: jsAppSettings }), JSON.stringify({ settings: jsAppSettings }),
engineCommandManager, engineCommandManager,
@ -627,6 +643,13 @@ export const recast = (ast: Program): string | Error => {
return recast_wasm(JSON.stringify(ast)) return recast_wasm(JSON.stringify(ast))
} }
/**
* Format a number with suffix as KCL.
*/
export function formatNumber(value: number, suffix: NumericSuffix): string {
return format_number(value, JSON.stringify(suffix))
}
export const makeDefaultPlanes = async ( export const makeDefaultPlanes = async (
engineCommandManager: EngineCommandManager engineCommandManager: EngineCommandManager
): Promise<DefaultPlanes> => { ): Promise<DefaultPlanes> => {
@ -823,3 +846,17 @@ export function base64Decode(base64: string): ArrayBuffer | Error {
return new Error('Caught error decoding base64 string: ' + e) return new Error('Caught error decoding base64 string: ' + e)
} }
} }
/// Change the meta settings for the kcl file.
/// Returns the new kcl string with the updated settings.
export function changeKclSettings(
kcl: string,
settings: MetaSettings
): string | Error {
try {
return change_kcl_settings(kcl, JSON.stringify(settings))
} catch (e) {
console.error('Caught error changing kcl settings: ' + e)
return new Error('Caught error changing kcl settings: ' + e)
}
}

40
src/lib/base64.test.ts Normal file
View File

@ -0,0 +1,40 @@
import { expect } from 'vitest'
import { base64ToString, stringToBase64 } from './base64'
describe('base64 encoding', () => {
test('to base64, simple code', async () => {
const code = `extrusionDistance = 12`
// Generated by online tool
const expectedBase64 = `ZXh0cnVzaW9uRGlzdGFuY2UgPSAxMg==`
const base64 = stringToBase64(code)
expect(base64).toBe(expectedBase64)
})
test(`to base64, code with UTF-8 characters`, async () => {
// example adapted from MDN docs: https://developer.mozilla.org/en-US/docs/Glossary/Base64#the_unicode_problem
const code = `a = 5; Ā = 12.in(); 𐀀 = Ā; 文 = 12.0; 🦄 = -5;`
// Generated by online tool
const expectedBase64 = `YSA9IDU7IMSAID0gMTIuaW4oKTsg8JCAgCA9IMSAOyDmlocgPSAxMi4wOyDwn6aEID0gLTU7`
const base64 = stringToBase64(code)
expect(base64).toBe(expectedBase64)
})
// The following are simply the reverse of the above tests
test('from base64, simple code', async () => {
const base64 = `ZXh0cnVzaW9uRGlzdGFuY2UgPSAxMg==`
const expectedCode = `extrusionDistance = 12`
const code = base64ToString(base64)
expect(code).toBe(expectedCode)
})
test(`from base64, code with UTF-8 characters`, async () => {
const base64 = `YSA9IDU7IMSAID0gMTIuaW4oKTsg8JCAgCA9IMSAOyDmlocgPSAxMi4wOyDwn6aEID0gLTU7`
const expectedCode = `a = 5; Ā = 12.in(); 𐀀 = Ā; 文 = 12.0; 🦄 = -5;`
const code = base64ToString(base64)
expect(code).toBe(expectedCode)
})
})

29
src/lib/base64.ts Normal file
View File

@ -0,0 +1,29 @@
/**
* Converts a string to a base64 string, preserving the UTF-8 encoding
*/
export function stringToBase64(str: string) {
return bytesToBase64(new TextEncoder().encode(str))
}
/**
* Converts a base64 string to a string, preserving the UTF-8 encoding
*/
export function base64ToString(base64: string) {
return new TextDecoder().decode(base64ToBytes(base64))
}
/**
* From the MDN Web Docs
* https://developer.mozilla.org/en-US/docs/Glossary/Base64#the_unicode_problem
*/
function base64ToBytes(base64: string) {
const binString = atob(base64)
return Uint8Array.from(binString, (m) => m.codePointAt(0)!)
}
function bytesToBase64(bytes: Uint8Array) {
const binString = Array.from(bytes, (byte) =>
String.fromCodePoint(byte)
).join('')
return btoa(binString)
}

View File

@ -1,17 +1,14 @@
import { StateMachineCommandSetConfig } from 'lib/commandTypes' import { Command } from 'lib/commandTypes'
import { authMachine } from 'machines/authMachine' import { authActor } from 'machines/appMachine'
import { ACTOR_IDS } from 'machines/machineConstants'
type AuthCommandSchema = {} export const authCommands: Command[] = [
{
export const authCommandBarConfig: StateMachineCommandSetConfig< groupId: ACTOR_IDS.AUTH,
typeof authMachine, name: 'log-out',
AuthCommandSchema displayName: 'Log out',
> = {
'Log in': {
hide: 'both',
},
'Log out': {
args: [],
icon: 'arrowLeft', icon: 'arrowLeft',
needsReview: false,
onSubmit: () => authActor.send({ type: 'Log out' }),
}, },
} ]

View File

@ -308,7 +308,6 @@ export const modelingMachineCommandConfig: StateMachineCommandSetConfig<
description: description:
'Create a 3D body by moving a sketch region along an arbitrary path.', 'Create a 3D body by moving a sketch region along an arbitrary path.',
icon: 'sweep', icon: 'sweep',
status: 'development',
needsReview: false, needsReview: false,
args: { args: {
target: { target: {
@ -317,8 +316,6 @@ export const modelingMachineCommandConfig: StateMachineCommandSetConfig<
required: true, required: true,
skip: true, skip: true,
multiple: false, multiple: false,
warningMessage:
'The sweep workflow is new and under tested. Please break it and report issues.',
}, },
trajectory: { trajectory: {
inputType: 'selection', inputType: 'selection',
@ -355,20 +352,18 @@ export const modelingMachineCommandConfig: StateMachineCommandSetConfig<
selectionTypes: ['cap', 'wall'], selectionTypes: ['cap', 'wall'],
multiple: true, multiple: true,
required: true, required: true,
validation: shellValidator,
}, },
thickness: { thickness: {
inputType: 'kcl', inputType: 'kcl',
defaultValue: KCL_DEFAULT_LENGTH, defaultValue: KCL_DEFAULT_LENGTH,
required: true, required: true,
// TODO: add dry-run validation on thickness param validation: shellValidator,
}, },
}, },
}, },
Revolve: { Revolve: {
description: 'Create a 3D body by rotating a sketch region about an axis.', description: 'Create a 3D body by rotating a sketch region about an axis.',
icon: 'revolve', icon: 'revolve',
status: 'development',
needsReview: true, needsReview: true,
args: { args: {
selection: { selection: {
@ -377,8 +372,6 @@ export const modelingMachineCommandConfig: StateMachineCommandSetConfig<
multiple: false, // TODO: multiple selection multiple: false, // TODO: multiple selection
required: true, required: true,
skip: true, skip: true,
warningMessage:
'The revolve workflow is new and under tested. Please break it and report issues.',
}, },
axisOrEdge: { axisOrEdge: {
inputType: 'options', inputType: 'options',

View File

@ -1,5 +1,8 @@
import { UnitLength_type } from '@kittycad/lib/dist/types/src/models'
import { CommandBarOverwriteWarning } from 'components/CommandBarOverwriteWarning' import { CommandBarOverwriteWarning } from 'components/CommandBarOverwriteWarning'
import { StateMachineCommandSetConfig } from 'lib/commandTypes' import { StateMachineCommandSetConfig } from 'lib/commandTypes'
import { isDesktop } from 'lib/isDesktop'
import { baseUnitLabels, baseUnitsUnion } from 'lib/settings/settingsTypes'
import { projectsMachine } from 'machines/projectsMachine' import { projectsMachine } from 'machines/projectsMachine'
export type ProjectsCommandSchema = { export type ProjectsCommandSchema = {
@ -17,6 +20,13 @@ export type ProjectsCommandSchema = {
oldName: string oldName: string
newName: string newName: string
} }
'Import file from URL': {
name: string
code?: string
units: UnitLength_type
method: 'newProject' | 'existingProject'
projectName?: string
}
} }
export const projectsCommandBarConfig: StateMachineCommandSetConfig< export const projectsCommandBarConfig: StateMachineCommandSetConfig<
@ -26,22 +36,23 @@ export const projectsCommandBarConfig: StateMachineCommandSetConfig<
'Open project': { 'Open project': {
icon: 'arrowRight', icon: 'arrowRight',
description: 'Open a project', description: 'Open a project',
status: isDesktop() ? 'active' : 'inactive',
args: { args: {
name: { name: {
inputType: 'options', inputType: 'options',
required: true, required: true,
options: [], options: (_, context) =>
optionsFromContext: (context) => context?.projects.map((p) => ({
context.projects.map((p) => ({ name: p.name,
name: p.name!, value: p.name,
value: p.name!, })) || [],
})),
}, },
}, },
}, },
'Create project': { 'Create project': {
icon: 'folderPlus', icon: 'folderPlus',
description: 'Create a project', description: 'Create a project',
status: isDesktop() ? 'active' : 'inactive',
args: { args: {
name: { name: {
inputType: 'string', inputType: 'string',
@ -53,6 +64,7 @@ export const projectsCommandBarConfig: StateMachineCommandSetConfig<
'Delete project': { 'Delete project': {
icon: 'close', icon: 'close',
description: 'Delete a project', description: 'Delete a project',
status: isDesktop() ? 'active' : 'inactive',
needsReview: true, needsReview: true,
reviewMessage: ({ argumentsToSubmit }) => reviewMessage: ({ argumentsToSubmit }) =>
CommandBarOverwriteWarning({ CommandBarOverwriteWarning({
@ -75,6 +87,7 @@ export const projectsCommandBarConfig: StateMachineCommandSetConfig<
icon: 'folder', icon: 'folder',
description: 'Rename a project', description: 'Rename a project',
needsReview: true, needsReview: true,
status: isDesktop() ? 'active' : 'inactive',
args: { args: {
oldName: { oldName: {
inputType: 'options', inputType: 'options',
@ -92,4 +105,80 @@ export const projectsCommandBarConfig: StateMachineCommandSetConfig<
}, },
}, },
}, },
'Import file from URL': {
icon: 'file',
description: 'Create a file',
needsReview: true,
status: 'active',
args: {
method: {
inputType: 'options',
required: true,
skip: true,
options: isDesktop()
? [
{ name: 'New project', value: 'newProject' },
{ name: 'Existing project', value: 'existingProject' },
]
: [{ name: 'Overwrite', value: 'existingProject' }],
valueSummary(value) {
return isDesktop()
? value === 'newProject'
? 'New project'
: 'Existing project'
: 'Overwrite'
},
},
// TODO: We can't get the currently-opened project to auto-populate here because
// it's not available on projectMachine, but lower in fileMachine. Unify these.
projectName: {
inputType: 'options',
required: (commandsContext) =>
isDesktop() &&
commandsContext.argumentsToSubmit.method === 'existingProject',
skip: true,
options: (_, context) =>
context?.projects.map((p) => ({
name: p.name!,
value: p.name!,
})) || [],
},
name: {
inputType: 'string',
required: isDesktop(),
skip: true,
},
code: {
inputType: 'text',
required: true,
skip: true,
valueSummary(value) {
const lineCount = value?.trim().split('\n').length
return `${lineCount} line${lineCount === 1 ? '' : 's'}`
},
},
units: {
inputType: 'options',
required: false,
skip: true,
options: baseUnitsUnion.map((unit) => ({
name: baseUnitLabels[unit],
value: unit,
})),
},
},
reviewMessage(commandBarContext) {
return isDesktop()
? `Will add the contents from URL to a new ${
commandBarContext.argumentsToSubmit.method === 'newProject'
? 'project with file main.kcl'
: `file within the project "${commandBarContext.argumentsToSubmit.projectName}"`
} named "${
commandBarContext.argumentsToSubmit.name
}", and set default units to "${
commandBarContext.argumentsToSubmit.units
}".`
: `Will overwrite the contents of the current file with the contents from the URL.`
},
},
} }

View File

@ -0,0 +1,19 @@
import { parseEngineErrorMessage } from './validators'
describe('parseEngineErrorMessage', () => {
it('takes an engine error string and parses its json message', () => {
const engineError =
'engine error: [{"error_code":"internal_engine","message":"Trajectory curve must be G1 continuous (with continuous tangents)"}]'
const message = parseEngineErrorMessage(engineError)
expect(message).toEqual(
'Trajectory curve must be G1 continuous (with continuous tangents)'
)
})
it('retuns undefined on strings with different formats', () => {
const s1 = 'engine error: []'
const s2 = 'blabla'
expect(parseEngineErrorMessage(s1)).toBeUndefined()
expect(parseEngineErrorMessage(s2)).toBeUndefined()
})
})

View File

@ -3,6 +3,8 @@ import { engineCommandManager } from 'lib/singletons'
import { uuidv4 } from 'lib/utils' import { uuidv4 } from 'lib/utils'
import { CommandBarContext } from 'machines/commandBarMachine' import { CommandBarContext } from 'machines/commandBarMachine'
import { Selections } from 'lib/selections' import { Selections } from 'lib/selections'
import { KclCommandValue } from 'lib/commandTypes'
import { ApiError_type } from '@kittycad/lib/dist/types/src/models'
export const disableDryRunWithRetry = async (numberOfRetries = 3) => { export const disableDryRunWithRetry = async (numberOfRetries = 3) => {
for (let tries = 0; tries < numberOfRetries; tries++) { for (let tries = 0; tries < numberOfRetries; tries++) {
@ -46,6 +48,20 @@ function isSelections(selections: unknown): selections is Selections {
) )
} }
export function parseEngineErrorMessage(engineError: string) {
const parts = engineError.split('engine error: ')
if (parts.length < 2) {
return undefined
}
const errors = JSON.parse(parts[1]) as ApiError_type[]
if (!errors[0]) {
return undefined
}
return errors[0].message
}
export const revolveAxisValidator = async ({ export const revolveAxisValidator = async ({
data, data,
context, context,
@ -83,7 +99,7 @@ export const revolveAxisValidator = async ({
value: 360, value: 360,
} }
const revolveAboutEdgeCommand = async () => { const command = async () => {
return await engineCommandManager.sendSceneCommand({ return await engineCommandManager.sendSceneCommand({
type: 'modeling_cmd_req', type: 'modeling_cmd_req',
cmd_id: uuidv4(), cmd_id: uuidv4(),
@ -92,17 +108,18 @@ export const revolveAxisValidator = async ({
angle: angleInDegrees, angle: angleInDegrees,
edge_id: edgeSelection, edge_id: edgeSelection,
target: sketchSelection, target: sketchSelection,
tolerance: 0.0001, // Gotcha: Playwright will fail with larger tolerances, need to use a smaller one.
tolerance: 1e-7,
}, },
}) })
} }
const attemptRevolve = await dryRunWrapper(revolveAboutEdgeCommand) const result = await dryRunWrapper(command)
if (attemptRevolve?.success) { if (result?.success) {
return true return true
} else {
// return error message for the toast
return 'Unable to revolve with selected edge'
} }
const reason = parseEngineErrorMessage(result) || 'unknown'
return `Unable to revolve with the current selection. Reason: ${reason}`
} }
export const loftValidator = async ({ export const loftValidator = async ({
@ -128,7 +145,7 @@ export const loftValidator = async ({
return 'Unable to loft, selection contains less than two solid2ds' return 'Unable to loft, selection contains less than two solid2ds'
} }
const loftCommand = async () => { const command = async () => {
// TODO: check what to do with these // TODO: check what to do with these
const DEFAULT_V_DEGREE = 2 const DEFAULT_V_DEGREE = 2
const DEFAULT_TOLERANCE = 2 const DEFAULT_TOLERANCE = 2
@ -145,26 +162,31 @@ export const loftValidator = async ({
}, },
}) })
} }
const attempt = await dryRunWrapper(loftCommand) const result = await dryRunWrapper(command)
if (attempt?.success) { if (result?.success) {
return true return true
} else {
// return error message for the toast
return 'Unable to loft with selected sketches'
} }
const reason = parseEngineErrorMessage(result) || 'unknown'
return `Unable to loft with the current selection. Reason: ${reason}`
} }
export const shellValidator = async ({ export const shellValidator = async ({
context,
data, data,
}: { }: {
data: { selection: Selections } context: CommandBarContext
data: { thickness: KclCommandValue }
}): Promise<boolean | string> => { }): Promise<boolean | string> => {
if (!isSelections(data.selection)) { const thicknessArg = data.thickness
const selectionArg = context.argumentsToSubmit['selection'] as Selections
if (!isSelections(selectionArg)) {
return 'Unable to shell, selections are missing' return 'Unable to shell, selections are missing'
} }
// No validation on the faces, filtering is done upstream and we have the dry run validation just below // No validation on the args, filtering is done upstream and we have the dry run validation just below
const face_ids = data.selection.graphSelections.flatMap((s) => const shell_thickness = Number(thicknessArg.valueCalculated)
const face_ids = selectionArg.graphSelections.flatMap((s) =>
s.artifact ? s.artifact.id : [] s.artifact ? s.artifact.id : []
) )
@ -180,15 +202,13 @@ export const shellValidator = async ({
return "Unable to shell, couldn't find the solid" return "Unable to shell, couldn't find the solid"
} }
const shellCommand = async () => { const command = async () => {
// TODO: figure out something better than an arbitrarily small value
const DEFAULT_THICKNESS: Models['LengthUnit_type'] = 1e-9
const DEFAULT_HOLLOW = false const DEFAULT_HOLLOW = false
const cmdArgs = { const cmdArgs = {
face_ids, face_ids,
object_id, object_id,
shell_thickness,
hollow: DEFAULT_HOLLOW, hollow: DEFAULT_HOLLOW,
shell_thickness: DEFAULT_THICKNESS,
} }
return await engineCommandManager.sendSceneCommand({ return await engineCommandManager.sendSceneCommand({
type: 'modeling_cmd_req', type: 'modeling_cmd_req',
@ -200,12 +220,13 @@ export const shellValidator = async ({
}) })
} }
const attemptShell = await dryRunWrapper(shellCommand) const result = await dryRunWrapper(command)
if (attemptShell?.success) { if (result?.success) {
return true return true
} }
return 'Unable to shell with the provided selection' const reason = parseEngineErrorMessage(result) || 'unknown'
return `Unable to shell with the current selection. Reason: ${reason}`
} }
export const sweepValidator = async ({ export const sweepValidator = async ({
@ -241,7 +262,7 @@ export const sweepValidator = async ({
} }
const target = targetArtifact.pathId const target = targetArtifact.pathId
const sweepCommand = async () => { const command = async () => {
// TODO: second look on defaults here // TODO: second look on defaults here
const DEFAULT_TOLERANCE: Models['LengthUnit_type'] = 1e-7 const DEFAULT_TOLERANCE: Models['LengthUnit_type'] = 1e-7
const DEFAULT_SECTIONAL = false const DEFAULT_SECTIONAL = false
@ -261,10 +282,11 @@ export const sweepValidator = async ({
}) })
} }
const attemptSweep = await dryRunWrapper(sweepCommand) const result = await dryRunWrapper(command)
if (attemptSweep?.success) { if (result?.success) {
return true return true
} }
return 'Unable to sweep with the provided selection' const reason = parseEngineErrorMessage(result) || 'unknown'
return `Unable to sweep with the current selection. Reason: ${reason}`
} }

View File

@ -171,6 +171,13 @@ export type CommandArgumentConfig<
commandBarContext: ContextFrom<typeof commandBarMachine>, commandBarContext: ContextFrom<typeof commandBarMachine>,
machineContext?: C machineContext?: C
) => string) ) => string)
validation?: ({
data,
context,
}: {
data: any
context: CommandBarContext
}) => Promise<boolean | string>
} }
| { | {
inputType: 'string' inputType: 'string'
@ -267,6 +274,13 @@ export type CommandArgument<
commandBarContext: ContextFrom<typeof commandBarMachine>, commandBarContext: ContextFrom<typeof commandBarMachine>,
machineContext?: ContextFrom<T> machineContext?: ContextFrom<T>
) => string) ) => string)
validation?: ({
data,
context,
}: {
data: any
context: CommandBarContext
}) => Promise<boolean | string>
} }
| { | {
inputType: 'string' inputType: 'string'

View File

@ -26,7 +26,7 @@ export const FILE_EXT = '.kcl'
/** Default file to open when a project is opened */ /** Default file to open when a project is opened */
export const PROJECT_ENTRYPOINT = `main${FILE_EXT}` as const export const PROJECT_ENTRYPOINT = `main${FILE_EXT}` as const
/** Thumbnail file name */ /** Thumbnail file name */
export const PROJECT_IMAGE_NAME = `main.jpg` as const export const PROJECT_IMAGE_NAME = `thumbnail.png` as const
/** The localStorage key for last-opened projects */ /** The localStorage key for last-opened projects */
export const FILE_PERSIST_KEY = `${PROJECT_FOLDER}-last-opened` as const export const FILE_PERSIST_KEY = `${PROJECT_FOLDER}-last-opened` as const
/** The default name given to new kcl files in a project */ /** The default name given to new kcl files in a project */
@ -68,7 +68,6 @@ export const KCL_DEFAULT_DEGREE = `360`
/** localStorage key for the playwright test-specific app settings file */ /** localStorage key for the playwright test-specific app settings file */
export const TEST_SETTINGS_FILE_KEY = 'playwright-test-settings' export const TEST_SETTINGS_FILE_KEY = 'playwright-test-settings'
export const DEFAULT_HOST = 'https://api.zoo.dev'
export const SETTINGS_FILE_NAME = 'settings.toml' export const SETTINGS_FILE_NAME = 'settings.toml'
export const TOKEN_FILE_NAME = 'token.txt' export const TOKEN_FILE_NAME = 'token.txt'
export const PROJECT_SETTINGS_FILE_NAME = 'project.toml' export const PROJECT_SETTINGS_FILE_NAME = 'project.toml'
@ -110,6 +109,9 @@ export const KCL_SAMPLES_MANIFEST_URLS = {
localFallback: '/kcl-samples-manifest-fallback.json', localFallback: '/kcl-samples-manifest-fallback.json',
} as const } as const
/** URL parameter to create a file */
export const CREATE_FILE_URL_PARAM = 'create-file'
/** Toast id for the app auto-updater toast */ /** Toast id for the app auto-updater toast */
export const AUTO_UPDATER_TOAST_ID = 'auto-updater-toast' export const AUTO_UPDATER_TOAST_ID = 'auto-updater-toast'
@ -139,3 +141,12 @@ export const VIEW_NAMES_SEMANTIC = {
} as const } as const
/** The modeling sidebar buttons' IDs get a suffix to prevent collisions */ /** The modeling sidebar buttons' IDs get a suffix to prevent collisions */
export const SIDEBAR_BUTTON_SUFFIX = '-pane-button' export const SIDEBAR_BUTTON_SUFFIX = '-pane-button'
/** Custom URL protocol our desktop registers */
export const ZOO_STUDIO_PROTOCOL = 'zoo-studio'
/**
* A query parameter that triggers a modal
* to "open in desktop app" when present in the URL
*/
export const ASK_TO_OPEN_QUERY_PARAM = 'ask-open-desktop'

View File

@ -193,6 +193,7 @@ export function buildCommandArgument<
createVariableByDefault: arg.createVariableByDefault, createVariableByDefault: arg.createVariableByDefault,
variableName: arg.variableName, variableName: arg.variableName,
defaultValue: arg.defaultValue, defaultValue: arg.defaultValue,
validation: arg.validation,
...baseCommandArgument, ...baseCommandArgument,
} satisfies CommandArgument<O, T> & { inputType: 'kcl' } } satisfies CommandArgument<O, T> & { inputType: 'kcl' }
} else { } else {

View File

@ -10,6 +10,7 @@ import {
import { import {
PROJECT_ENTRYPOINT, PROJECT_ENTRYPOINT,
PROJECT_FOLDER, PROJECT_FOLDER,
PROJECT_IMAGE_NAME,
PROJECT_SETTINGS_FILE_NAME, PROJECT_SETTINGS_FILE_NAME,
SETTINGS_FILE_NAME, SETTINGS_FILE_NAME,
TELEMETRY_FILE_NAME, TELEMETRY_FILE_NAME,
@ -625,3 +626,19 @@ export const getUser = async (
} }
return Promise.reject(new Error('unreachable')) return Promise.reject(new Error('unreachable'))
} }
export const writeProjectThumbnailFile = async (
dataUrl: string,
projectDirectoryPath: string
) => {
const filePath = window.electron.path.join(
projectDirectoryPath,
PROJECT_IMAGE_NAME
)
const data = atob(dataUrl.substring('data:image/png;base64,'.length))
const asArray = new Uint8Array(data.length)
for (let i = 0, len = data.length; i < len; ++i) {
asArray[i] = data.charCodeAt(i)
}
return window.electron.writeFile(filePath, asArray)
}

View File

@ -1,12 +1,13 @@
import { CommandBarOverwriteWarning } from 'components/CommandBarOverwriteWarning' import { CommandBarOverwriteWarning } from 'components/CommandBarOverwriteWarning'
import { Command, CommandArgumentOption } from './commandTypes' import { Command, CommandArgumentOption } from './commandTypes'
import { kclManager } from './singletons' import { codeManager, kclManager } from './singletons'
import { isDesktop } from './isDesktop' import { isDesktop } from './isDesktop'
import { FILE_EXT, PROJECT_SETTINGS_FILE_NAME } from './constants' import { FILE_EXT } from './constants'
import { UnitLength_type } from '@kittycad/lib/dist/types/src/models' import { UnitLength_type } from '@kittycad/lib/dist/types/src/models'
import { parseProjectSettings } from 'lang/wasm' import { reportRejection } from './trap'
import { err, reportRejection } from './trap' import { IndexLoaderData } from './types'
import { projectConfigurationToSettingsPayload } from './settings/settingsUtils' import { IS_NIGHTLY_OR_DEBUG } from 'routes/Settings'
import { copyFileShareLink } from './links'
interface OnSubmitProps { interface OnSubmitProps {
sampleName: string sampleName: string
@ -15,10 +16,21 @@ interface OnSubmitProps {
method: 'overwrite' | 'newFile' method: 'overwrite' | 'newFile'
} }
export function kclCommands( interface KclCommandConfig {
onSubmit: (p: OnSubmitProps) => Promise<void>, // TODO: find a different approach that doesn't require
providedOptions: CommandArgumentOption<string>[] // special props for a single command
): Command[] { specialPropsForSampleCommand: {
onSubmit: (p: OnSubmitProps) => Promise<void>
providedOptions: CommandArgumentOption<string>[]
}
projectData: IndexLoaderData
authToken: string
settings: {
defaultUnit: UnitLength_type
}
}
export function kclCommands(commandProps: KclCommandConfig): Command[] {
return [ return [
{ {
name: 'format-code', name: 'format-code',
@ -55,59 +67,28 @@ export function kclCommands(
const sampleCodeUrl = `https://raw.githubusercontent.com/KittyCAD/kcl-samples/main/${encodeURIComponent( const sampleCodeUrl = `https://raw.githubusercontent.com/KittyCAD/kcl-samples/main/${encodeURIComponent(
projectPathPart projectPathPart
)}/${encodeURIComponent(primaryKclFile)}` )}/${encodeURIComponent(primaryKclFile)}`
const sampleSettingsFileUrl = `https://raw.githubusercontent.com/KittyCAD/kcl-samples/main/${encodeURIComponent(
projectPathPart
)}/${PROJECT_SETTINGS_FILE_NAME}`
Promise.allSettled([fetch(sampleCodeUrl), fetch(sampleSettingsFileUrl)]) fetch(sampleCodeUrl)
.then((results) => { .then(async (codeResponse): Promise<OnSubmitProps> => {
const a = if (!codeResponse.ok) {
'value' in results[0] ? results[0].value : results[0].reason console.error(
const b = 'Failed to fetch sample code:',
'value' in results[1] ? results[1].value : results[1].reason codeResponse.statusText
return [a, b] )
}) return Promise.reject(new Error('Failed to fetch sample code'))
.then(
async ([
codeResponse,
settingsResponse,
]): Promise<OnSubmitProps> => {
if (!codeResponse.ok) {
console.error(
'Failed to fetch sample code:',
codeResponse.statusText
)
return Promise.reject(new Error('Failed to fetch sample code'))
}
const code = await codeResponse.text()
// It's possible that a sample doesn't have a project.toml
// associated with it.
let projectSettingsPayload: ReturnType<
typeof projectConfigurationToSettingsPayload
> = {}
if (settingsResponse.ok) {
const parsedProjectSettings = parseProjectSettings(
await settingsResponse.text()
)
if (!err(parsedProjectSettings)) {
projectSettingsPayload =
projectConfigurationToSettingsPayload(parsedProjectSettings)
}
}
return {
sampleName: data.sample.split('/')[0] + FILE_EXT,
code,
method: data.method,
sampleUnits:
projectSettingsPayload.modeling?.defaultUnit || 'mm',
}
} }
) const code = await codeResponse.text()
return {
sampleName: data.sample.split('/')[0] + FILE_EXT,
code,
method: data.method,
}
})
.then((props) => { .then((props) => {
if (props?.code) { if (props?.code) {
onSubmit(props).catch(reportError) commandProps.specialPropsForSampleCommand
.onSubmit(props)
.catch(reportError)
} }
}) })
.catch(reportError) .catch(reportError)
@ -149,9 +130,26 @@ export function kclCommands(
} }
return value return value
}, },
options: providedOptions, options: commandProps.specialPropsForSampleCommand.providedOptions,
}, },
}, },
}, },
{
name: 'share-file-link',
displayName: 'Share file',
hide: IS_NIGHTLY_OR_DEBUG ? undefined : 'desktop',
description: 'Create a link that contains a copy of the current file.',
groupId: 'code',
needsReview: false,
icon: 'link',
onSubmit: () => {
copyFileShareLink({
token: commandProps.authToken,
code: codeManager.code,
name: commandProps.projectData.project?.name || '',
units: commandProps.settings.defaultUnit,
}).catch(reportRejection)
},
},
] ]
} }

17
src/lib/links.test.ts Normal file
View File

@ -0,0 +1,17 @@
import { VITE_KC_SITE_APP_URL } from 'env'
import { createCreateFileUrl } from './links'
describe(`link creation tests`, () => {
test(`createCreateFileUrl happy path`, async () => {
const code = `extrusionDistance = 12`
const name = `test`
const units = `mm`
// Converted with external online tools
const expectedEncodedCode = `ZXh0cnVzaW9uRGlzdGFuY2UgPSAxMg%3D%3D`
const expectedLink = `${VITE_KC_SITE_APP_URL}/?create-file=true&name=test&units=mm&code=${expectedEncodedCode}&ask-open-desktop=true`
const result = createCreateFileUrl({ code, name, units })
expect(result.toString()).toBe(expectedLink)
})
})

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