Compare commits

...

100 Commits

Author SHA1 Message Date
1beb6b5186 Cut release v0.22.3 (#2729) 2024-06-21 13:17:14 +10:00
17978ab1d7 Reset code on critical onboarding steps (#2727)
* Make sure we always reset the code on important steps no matter what the user did to it

* Convert comments in codeManager to JSDoc comments so they appear in diagnostics

* Was using the wrong codeManager callback

* Make sure editorView is available before resetting code

* Add Playwright test that shows the code being reset

* Fix up text that looks like linksÏ

* fmt

* Skip test on MacOS, make test more reliable on Chrome

* Update cargo-clippy to run based on paths on PRs as well

* playw fix

* try keep reports

* add fix me

* try one last thing

* fmt

---------

Co-authored-by: Kurt Hutten <k.hutten@protonmail.ch>
2024-06-21 11:39:01 +10:00
a1bcad9dfb Implement Core Dump for modeling app state (#2653) 2024-06-20 19:36:28 -04:00
2e7bdf02cf Franknoirot/onboarding avatar text (#2726)
* Add failing playwright test

* Fix the problem to get the test passing

* Give the avatar button a tooltip too
2024-06-20 14:06:11 -04:00
6f76196b72 pin ts-rs to release (#2725)
Signed-off-by: Jess Frazelle <github@jessfraz.com>
2024-06-20 10:55:53 -07:00
e7af064518 Fix onboarding example code loading (#2723)
* Add Playwright test to verify that example code loads

* Just let the loaded code be null if it's null
2024-06-20 12:07:21 -04:00
674d49e2ae fix clear diagnostics when not wasm (#2715)
Signed-off-by: Jess Frazelle <github@jessfraz.com>
2024-06-19 19:45:55 -07:00
4cb48674c6 add a feature flag to disable printlns in kcl-lib for the lsp (#2712)
* updates

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>

* updates

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

* cleanup weird printlns

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

* updates

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

* check

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

* rename file

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

---------

Signed-off-by: Jess Frazelle <github@jessfraz.com>
2024-06-19 19:38:56 -07:00
82daec2aff more pyo3 methods (#2711)
more pyo3

Signed-off-by: Jess Frazelle <github@jessfraz.com>
2024-06-19 18:04:56 -07:00
f1ef9d5200 add pyo3 as a feature flag for python bindings (#2710)
* updates

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

* thing

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

* add feature flag for pyo3 for gregs stuff;

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

* add more

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

---------

Signed-off-by: Jess Frazelle <github@jessfraz.com>
2024-06-19 17:32:08 -07:00
dc226d3270 Disable SSAO temporarily (#2709) 2024-06-19 18:54:22 -04:00
7bf50d8fe0 get responses back from batch (#2687)
* updates

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>

* fixes

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

* remove my stupid println

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

* updates

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

* weird typescript

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

* better batch stuff;

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

* updates

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

* ckeanup

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

* fixes

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

* updates

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

* fixes

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

* typpo

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

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

* batch more

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

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

* thing

* updates

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

* up[dates

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

* updates

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

* fixes

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

* updates

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

* fix tests

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

* fixces

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

* cleanups

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

* images

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

* fixes

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

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

* empty

* cleanups

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

* console log all the things

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

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

* fixups

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

* updates

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

* console log cleanup

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

* fixes

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

* nicer types

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

* updates

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

* remove logs

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

* remove logs

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

---------

Signed-off-by: Jess Frazelle <github@jessfraz.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-06-19 13:57:50 -07:00
b26764bc9a Clear the AST if there was a parse error. (#2706)
Clear the AST if there was a parse error.

This leads to an unfortunate loop (good -> invalid -> original) that
wouldn't clear the diagnostics from the invalid step.
2024-06-19 16:15:22 -04:00
1b0c6298d7 Revert "Bump dashmap from 5.5.3 to 6.0.0 in /src/wasm-lib" (#2707)
Revert "Bump dashmap from 5.5.3 to 6.0.0 in /src/wasm-lib (#2704)"

This reverts commit bd42ea037b.
2024-06-19 12:19:17 -07:00
fe9a483726 Bump url from 2.5.1 to 2.5.2 in /src/wasm-lib (#2705)
Bumps [url](https://github.com/servo/rust-url) from 2.5.1 to 2.5.2.
- [Release notes](https://github.com/servo/rust-url/releases)
- [Commits](https://github.com/servo/rust-url/compare/v2.5.1...v2.5.2)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-18 22:51:07 -07:00
bd42ea037b Bump dashmap from 5.5.3 to 6.0.0 in /src/wasm-lib (#2704)
Bumps [dashmap](https://github.com/xacrimon/dashmap) from 5.5.3 to 6.0.0.
- [Release notes](https://github.com/xacrimon/dashmap/releases)
- [Commits](https://github.com/xacrimon/dashmap/compare/v.5.5.3...v6.0.0)

---
updated-dependencies:
- dependency-name: dashmap
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-18 20:46:12 -07:00
fdb1b21af3 Bump dawidd6/action-download-artifact from 5 to 6 (#2649)
Bumps [dawidd6/action-download-artifact](https://github.com/dawidd6/action-download-artifact) from 5 to 6.
- [Release notes](https://github.com/dawidd6/action-download-artifact/releases)
- [Commits](https://github.com/dawidd6/action-download-artifact/compare/v5...v6)

---
updated-dependencies:
- dependency-name: dawidd6/action-download-artifact
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-18 19:32:59 -07:00
630ef316b8 Bump serde_tokenstream from 0.2.0 to 0.2.1 in /src/wasm-lib (#2648)
Bumps [serde_tokenstream](https://github.com/oxidecomputer/serde_tokenstream) from 0.2.0 to 0.2.1.
- [Release notes](https://github.com/oxidecomputer/serde_tokenstream/releases)
- [Commits](https://github.com/oxidecomputer/serde_tokenstream/compare/v0.2.0...v0.2.1)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-18 19:32:50 -07:00
e322926be9 Bump url from 2.5.0 to 2.5.1 in /src/wasm-lib (#2644)
Bumps [url](https://github.com/servo/rust-url) from 2.5.0 to 2.5.1.
- [Release notes](https://github.com/servo/rust-url/releases)
- [Commits](https://github.com/servo/rust-url/compare/v2.5.0...v2.5.1)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-18 19:31:21 -07:00
a9e61da8b5 Recast bug fix (#2703)
* fix gregs recast bug

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

* fixes

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

---------

Signed-off-by: Jess Frazelle <github@jessfraz.com>
2024-06-18 18:48:30 -07:00
e2a835a437 rename radius to length for chamfer; (#2702)
Signed-off-by: Jess Frazelle <github@jessfraz.com>
2024-06-18 18:33:57 -07:00
c61273085f KCL execution server (#2686)
Adds a new library, the kcl-test-server. It lets you easily start a HTTP server with one endpoint, which accepts JSON. The JSON body contains a KCL program and a test name. The server has a pool of active engine sessions, and when it gets a KCL program, it executes it on one of those engine sessions.

This addresses part of #2580 but currently the sketch-on-face tests don't pass with this new test server yet.

This is a library, not a binary, because I want to use it in both the wasm-lib unit tests and in the zoo CLI.
2024-06-18 14:38:25 -05:00
a79e365c0f Slight tauri e2e cleanup (#2659)
* WIP: Break the tauri e2e tests apart
Will fix #2658

* Clean up

* Longer before timeout

* Also exclude tauri tests from vitest

* Utils fn back in app.spec.ts

* Remove utils

* Change before back to it

* Remove explicit mocha dep

* Revert other attemps at fixing the browser issues. mocha dep was the issue

* Clean up

* Signin/out sep with auth flows

* Lint

---------

Co-authored-by: Frank Noirot <frank@zoo.dev>
2024-06-18 14:01:39 -04:00
2386ba24e5 Better styling for pane and KCL editor focus (#2691)
* Pane styling first steps

* More style tweaks

* Make pane background nearly opaque when focus is within them
2024-06-18 12:42:47 -04:00
e42a891df8 Add nix flake and direnv config (#2694) 2024-06-18 11:32:08 -04:00
98200565bf Add a dismiss button to the command bar (#2647)
* Remove tab hotkey from selection input

* Add dismiss button to the command bar

* update Cargo.lock

* tweak close button styles

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

* Revert "update Cargo.lock"

This reverts commit 862a6897ba.

* Restore Cargo.lock I hate VSCode sometimes

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

---------

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

* more progress

* mostly done

* fix snapshot tests

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

* fix

* fix ubuntu

* more tweaks fixes

* add test

* more FE fixes

---------

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

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

* add images;

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

* std lib

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

* bu,mp

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

* fix tests

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

* update lock

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

* bump

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

* fixes

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

* fixes

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

---------

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

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

* add shell

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

---------

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

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-17 14:36:56 -05:00
bdeab4f87d Bump clap from 4.5.4 to 4.5.7 in /src/wasm-lib (#2643)
Bumps [clap](https://github.com/clap-rs/clap) from 4.5.4 to 4.5.7.
- [Release notes](https://github.com/clap-rs/clap/releases)
- [Changelog](https://github.com/clap-rs/clap/blob/master/CHANGELOG.md)
- [Commits](https://github.com/clap-rs/clap/compare/clap_complete-v4.5.4...v4.5.7)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-17 14:16:10 -05:00
05ccf5e2f4 Chamfer is just a fancy fillet so easy to add (#2681)
* add chamfer

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

* generate docs

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

* generate docs

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

---------

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

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

* Bump kittycad.ts lib

* Update cargo.lock again

* Bump lib again, and fix fillet typing

* Update kittycad.rs to v0.3.5

* Revert schemars to v0.8.17

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

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

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

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

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

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

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

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

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

* ty clippy :)

* add in a lint test

* add in some docstrings

* whoops

* sigh

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

* uno reverse card

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

* wtf stop it robot fuck

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

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

This reverts commit 5b18f3c035.

* hurm

* try harder to type slower

* try harder? this all passes locally.

* try this now

* simplify, add debugging for trace

* fix enter use

* re-order again

* reorder a bit more

* enter

* ok fine no other enters?

* nerd

* wip

* move control of clearing to typescript

* move result out

* err check

* remove log

* remove clear

* remove add to diag

* THERE CAN BE ONLY ONE

* _err

* dedupe

* Revert "dedupe"

This reverts commit f66de88200.

* attempt to dedupe

* clear diagnostics on mock execute, too

* handle dupe diagnostics

* fmt

* dedupe tsc

* == vs ===

* fix dedupe

* return this to the wasm for now

* clear the map every go around

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

---------

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

* remove lame theme color outline from tooltips

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

---------

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

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Jess Frazelle <jessfraz@users.noreply.github.com>
2024-06-11 07:49:30 -05:00
081e34a600 Bump regex from 1.10.4 to 1.10.5 in /src/wasm-lib (#2637)
Bumps [regex](https://github.com/rust-lang/regex) from 1.10.4 to 1.10.5.
- [Release notes](https://github.com/rust-lang/regex/releases)
- [Changelog](https://github.com/rust-lang/regex/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rust-lang/regex/compare/1.10.4...1.10.5)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-10 17:29:24 -07:00
541400f4be Cut release v0.22.1 (#2634) 2024-06-07 14:49:29 -04:00
39d249030d remove more page.clicks (#2630)
* remove more page.clicks

* fmt
2024-06-07 12:31:22 +10:00
f8a69fac73 Remove page.clicks from test utils (#2629)
* remove page.clicks from test utils

* timeout
2024-06-07 00:48:42 +00:00
24f4bf160f Add a right-click (or "context") menu to file tree and gizmo (#2628)
* Basic context menu components

* Working context menu!

* Show keyboard shortcuts in file tree context menu

* Add context menu to Gizmo

* Little polish on components

* Add a test for the gizmo, firm up other gizmo tests

* Updated Cargo lock

* Updated Cargo.lock
2024-06-06 19:56:46 -04:00
8011594e24 Fix most trackpad zoom jank (#2613)
* Remove zoom throttling

And use the mouse zoom for sketch mode

* test tweaks

* test tweak

---------

Co-authored-by: Kurt Hutten Irev-Dev <k.hutten@protonmail.ch>
2024-06-07 09:48:54 +10:00
0e09affb8f Remove debug logging from Engine Connection (#2623)
* Remove debug logging from Engine Connection

Left console.log('connectionstatechange: ' + event.target?.connectionState) intentionally

* Bring that beat back

@lf94 request that we keep this one and also make sure it's in coredump.
2024-06-07 07:16:55 +10:00
197a47346a Refactor: Break functions into smaller functions (#2622)
* Factor ExecutionCtx into its own fn

* Add hyper for tests

* Further factor out functions
2024-06-07 07:01:41 +10:00
9d083710e0 Bump actions/cache from 3 to 4 (#2616)
Bumps [actions/cache](https://github.com/actions/cache) from 3 to 4.
- [Release notes](https://github.com/actions/cache/releases)
- [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md)
- [Commits](https://github.com/actions/cache/compare/v3...v4)

---
updated-dependencies:
- dependency-name: actions/cache
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-06 13:05:20 -07:00
afa7c1dc4e Bump toml from 0.8.13 to 0.8.14 in /src/wasm-lib (#2615)
Bumps [toml](https://github.com/toml-rs/toml) from 0.8.13 to 0.8.14.
- [Commits](https://github.com/toml-rs/toml/compare/toml-v0.8.13...toml-v0.8.14)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-06 13:05:01 -07:00
c74b695a71 Remove an orphaned grackle file (#2611) 2024-06-06 14:48:58 -05:00
d0c244e05e Do not aggressively disconnect when video stream goes down (#2621) 2024-06-06 11:40:39 -04:00
a315b77f02 More selection verification (#2619) 2024-06-06 11:55:22 +00:00
15c854ff18 verify sketches can be selected outside of sketches (#2618) 2024-06-06 08:07:42 +00:00
acd3a5717d improve selections and remove redundant edit_mode (#2617) 2024-06-06 16:03:10 +10:00
8a2555550f Adding a sample using a custom axis in revolve.rs (#2596)
* Adding a sample using a custom axis in revolve.rs

* Adding updated docs and snapshot of generated part

* Running fmt
2024-06-05 19:48:59 +00:00
62e75c852a Bump dawidd6/action-download-artifact from 4 to 5 (#2601)
Bumps [dawidd6/action-download-artifact](https://github.com/dawidd6/action-download-artifact) from 4 to 5.
- [Release notes](https://github.com/dawidd6/action-download-artifact/releases)
- [Commits](https://github.com/dawidd6/action-download-artifact/compare/v4...v5)

---
updated-dependencies:
- dependency-name: dawidd6/action-download-artifact
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-05 09:44:13 -07:00
max
dd3601ea7b Gizmo Normal Snapping (#2539)
* gizmo 2.0

nice and clickable

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

* initial mouse position fix

when the scene first loads, mouse position is 0,0, which renders the gizmo selected.

* animation loop / disposal optimization

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

* reset camera tweak

* add cam target to debug panel

* test stub

* reset camera position handle removed from gizmo

it is now a button in the debug panel

* gizmo refactoring

* small fix

* reset camera view

bug fix

* nicer updateCameraToAxis

now gizmo rotates around the target instead of world 0,0,0

* micro refactoring

* playwright update

* playwright remove timeout + fmt

* hide gizmo while loading stream

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

This reverts commit f0a506d6b9.

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

This reverts commit 2781261331.

* try make gizmo test more realiable

* tweak

* refactoring

* increase timeout time

* 1 sec wait after mouse click

* 3 sec timeout

* better clickPosition

* test with 10 sec timeout

* 0.5 sec timeout

* add passive update for gizmo to avoid some edge cases

* default_camera_get_settings after click

* try and remove timeouts

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Kurt Hutten Irev-Dev <k.hutten@protonmail.ch>
2024-06-05 12:43:12 +00:00
a5e7782d9a Re-enable rust cache for src-tauri on Windows (#2586) 2024-06-05 06:06:25 -04:00
79b0b70688 Bump ts-rs from badbac0 to be0349d in /src/wasm-lib (#2602) 2024-06-05 03:40:42 -05:00
1d134c1be0 Timeout ahead of flaky sign out (#2593) 2024-06-05 04:36:26 -04:00
1c58572234 cache playwright follow up (#2605)
cache plawwright follow up
2024-06-05 05:53:21 +00:00
ecee51e82b cache playwright browsers (#2604) 2024-06-05 05:10:49 +00:00
978ac42f1c get pixel colour util for e2e tests (#2599)
* get pixel colour for e2e tests

* remove cleverness
2024-06-05 00:36:12 +00:00
893996430e Bump parse-display from 0.9.0 to 0.9.1 in /src/wasm-lib (#2561)
Bumps [parse-display](https://github.com/frozenlib/parse-display) from 0.9.0 to 0.9.1.
- [Changelog](https://github.com/frozenlib/parse-display/blob/master/CHANGELOG.md)
- [Commits](https://github.com/frozenlib/parse-display/compare/v0.9.0...v0.9.1)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Jess Frazelle <jessfraz@users.noreply.github.com>
2024-06-04 17:27:47 -07:00
41e65fc4e9 Bump proc-macro2 from 1.0.84 to 1.0.85 in /src/wasm-lib (#2575)
Bumps [proc-macro2](https://github.com/dtolnay/proc-macro2) from 1.0.84 to 1.0.85.
- [Release notes](https://github.com/dtolnay/proc-macro2/releases)
- [Commits](https://github.com/dtolnay/proc-macro2/compare/1.0.84...1.0.85)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-04 17:27:32 -07:00
99aa74ceba Bump tokio-tungstenite from 0.21.0 to 0.23.0 in /src/wasm-lib (#2576)
Bumps [tokio-tungstenite](https://github.com/snapview/tokio-tungstenite) from 0.21.0 to 0.23.0.
- [Changelog](https://github.com/snapview/tokio-tungstenite/blob/master/CHANGELOG.md)
- [Commits](https://github.com/snapview/tokio-tungstenite/commits)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-04 17:27:22 -07:00
0bcf33ed00 Bump tokio from 1.37.0 to 1.38.0 in /src/wasm-lib (#2562)
Bumps [tokio](https://github.com/tokio-rs/tokio) from 1.37.0 to 1.38.0.
- [Release notes](https://github.com/tokio-rs/tokio/releases)
- [Commits](https://github.com/tokio-rs/tokio/compare/tokio-1.37.0...tokio-1.38.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-04 16:47:26 -07:00
d0a9b5ecab Faster debug builds (#2571)
Suggested reading:

https://doc.rust-lang.org/cargo/reference/profiles.html#profiles
https://doc.rust-lang.org/cargo/reference/profiles.html#debug
2024-06-04 22:10:28 +00:00
a569f818cf Bump image and 2020 crates (#2556) 2024-06-04 22:09:01 +00:00
f73556ba7b Remove grackle (#2566) 2024-06-04 16:28:32 -05:00
29cdc66b34 Prevent Firefox's global paste behavior if paste target is not also focused (#2581)
* Prevent Firefox's global paste behavior if paste target is not also focused

* Write a test, fix code thanks to test

* Add one more comment to test
2024-06-04 14:36:34 -04:00
c9800a58d0 Update code mods for extrude so that new top-level constants are created (#2549)
* Make sketch and extrude produce separate top-level constants

* Fix most tests

* Add a breaking test for sketch on face AST mod

* Use `extrude` instead of `part`

* Implement @Irev-Dev's branch changes from https://github.com/KittyCAD/modeling-app/pull/2472

* Get extrude on face working

* Update incorrect sketch on face test

* Update unit tests

* Fix up E2E test changes

* fmt

* Fix a couple of goofed up test updates

* More specific names for paths to node sent to modelingMachine

* Bump down playwright workers for now

* Slightly more explicit type coercion

* Update snapshot tests

* Missed one other new flow test that wasn't updated to use "sketch001"

* Typo

* Damn missed one more sorry

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

* Re-run CI

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

* I think the multiple sketches test reverted from under me

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-06-04 13:57:01 -04:00
e46aca4992 Add tab to Settings dialog to view keyboard shortcuts (#2567)
* Add keyboard custom icon

* Refactor Settings to be more modular

* Add basic keybindings view to settings

* Add more shortcuts

* Add link to see keyboard shortcuts tab

* Little more bottom padding

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

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

* Add keybindings to settings search

* Add a playwright test for opening the the keyboard shortcuts

* fmt

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-06-04 13:56:20 -04:00
9564890b29 Bump dawidd6/action-download-artifact from 3 to 4 (#2588)
Bumps [dawidd6/action-download-artifact](https://github.com/dawidd6/action-download-artifact) from 3 to 4.
- [Release notes](https://github.com/dawidd6/action-download-artifact/releases)
- [Commits](https://github.com/dawidd6/action-download-artifact/compare/v3...v4)

---
updated-dependencies:
- dependency-name: dawidd6/action-download-artifact
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-04 09:49:17 -07:00
f8a1f40f20 Show when user can't connect because of a bad token (#2105)
* Reapply "Add ping pong health, remove a timeout interval, fix up netwo… (#1771)

This reverts commit 1913519f68.

* Fix build errors

* Add new error states to network status notification

* Remove unused variable

* Refactor to use Context API for network status

* Don't do any stream events if network is not ok

* Catch LSP errors on bad auth

* Show when authentication is bad (cookie header only)

* Fix formatting

* Fix up types

* Revert awaiting on lsp failure

* Fix tsc

* wip

* wip

* fmt

* Fix typing

* Incorporate ping health; yarn make:dev; faster video stream loss notice

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

* run ci pls

* run ci pls

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

* run ci pls again

* Remove unused variables

* Add new instructions on running Playwright anywhere

* Please the Playwright. Praise the Playwright.

* Correct a vitest

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

* ci again

* Fix tests unrelated to this PR

* Fix flakiness in for segments tests

* Bump to 2 workers

* fmt

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

* fmt

* fmt

* Fixups

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

* ci

* Set workers to 1

* Wait for network status listeners before connecting

* Fix initial connection requirements and trying 2 workers again

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-06-04 08:32:24 -04:00
c551d88db4 add remove constraints to overlays (#2584)
* add remove constrainst to overlay three dot menu

* add tests
2024-06-04 06:29:20 +00:00
8eee3e1c58 Cut release v0.22.0 (#2582) 2024-06-03 21:53:39 -04:00
b02529cae0 perpendicular distance & remove constraint - constraint fixes (#2579)
* perpendicular distance constraint

* remove constraints fix
2024-06-03 12:40:59 +00:00
cf03021366 length constraint fix (#2578)
length constraint
2024-06-03 08:30:30 +00:00
f52d2d55f1 fix horz vert distance contraint (#2572)
fix hor vert distance contraint
2024-06-03 15:37:23 +10:00
59b1319e50 Update rectangle code gen to use profileStart to close shape (#2565)
* Update rectangle code gen to use profileStart to close shape

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

* Re-run CI

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-05-31 14:02:46 -04:00
b07bbda20b Remove FileTree from ProjectSiderbarMenu (#2544)
* Remove FileTree from ProjectSiderbarMenu

* Remove tests sidebar menu component tests that are no longer relevant
2024-05-31 06:42:20 -04:00
3c01924184 fix ABS XY constraint (#2560)
* fix source rangen for abs x y constraints

* fix abs bug

* add e2e test
2024-05-31 04:00:32 +00:00
bd16902f02 fix single selection angle constraint (#2555)
* fix single selection angle constraint

* fix angle for multi selections

* make test more robust for makos
2024-05-31 11:36:08 +10:00
8c3af1a72a Small refactor and renames (#2548)
Stuff that came up while working on multiple profiles per sketchgroup
2024-05-30 17:48:59 -05:00
33f5d7740d Enable Windows Tauri e2e tests in CI (#2554)
* Renable windows tauri e2e ci, no cache, manual debug build

* Cleanup and add workaround from https://github.com/actions/runner-images/issues/9538

* Added comment
2024-05-30 11:26:56 -07:00
b388f60648 hovering over axis should not remove overlays (#2553) 2024-05-30 11:25:20 +00:00
8f4380be74 Get existing tauri e2e tests to work on Windows (#2394)
* WIP: Get existing tauri e2e tests to work on Windows
Will fix #2393

* Enable windows stage (will fail)

* WIP msedge version sync

* Move setup edge before build

* Manual debug build (no action)

* Specify v119 for npm package

* Fixes on auth test

* Working test on win10

* Clean up

* Disable yarn cache to help debug the mismatch issue

* Revert "Disable yarn cache to help debug the mismatch issue"

This reverts commit e6abc7db42.

* Explicit webviewOptions and remove tauri driver fork

* Double \\ workaround for windows

* Clean up

* Clean up and readme

* Quick fix

* Lint

* Clippy fix

* Back to tauri-action and disable windows CI tests for early merge

* Back to 10sec delay

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

* Timer reset

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

* Trigger CI

* Back to 1 pw worker

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-05-30 06:23:56 -04:00
9ae8042a57 Update selections after constraint is applied [horizontal and vertical] (#2551)
* source range for vert horz constraints

* remove commented out code
2024-05-30 09:43:35 +00:00
4b676d47da Update selections after constraint is applied [equal length, parallel, snap to x or y] (#2543)
* migrate one constraint

* typo

* update snap to y, snap to x, horz align, vert align, equal length

* add some e2e tests

* add e2e test for snap to axis contsraits

* remove works for now
2024-05-30 13:28:29 +10:00
e6641e68f3 Add a promise-based toast when exporting (#2541)
* Add loading and success toasts to export engine command

* Move doExport out to a test utility, test visibility of loading spinner

* Add playwright test for export success toast

* Update Cargo.lock

* Remove loading assertion, it flashes too quickly for Playwright to pick up
2024-05-29 18:04:27 -04:00
450afb1605 increase playwright workers (#2518) 2024-05-28 05:32:02 +00:00
04433fecad Bump zip from 1.3.0 to 2.0.0 in /src/wasm-lib (#2536)
Bumps [zip](https://github.com/zip-rs/zip2) from 1.3.0 to 2.0.0.
- [Release notes](https://github.com/zip-rs/zip2/releases)
- [Changelog](https://github.com/zip-rs/zip2/blob/master/CHANGELOG.md)
- [Commits](https://github.com/zip-rs/zip2/compare/v1.3.0...v2.0.0)

---
updated-dependencies:
- dependency-name: zip
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-05-28 00:31:28 +00:00
6567e2ff92 Bump serde from 1.0.202 to 1.0.203 in /src/wasm-lib (#2537)
Bumps [serde](https://github.com/serde-rs/serde) from 1.0.202 to 1.0.203.
- [Release notes](https://github.com/serde-rs/serde/releases)
- [Commits](https://github.com/serde-rs/serde/compare/v1.0.202...v1.0.203)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-05-27 21:31:18 +00:00
91c32a7fe2 Bump proc-macro2 from 1.0.83 to 1.0.84 in /src/wasm-lib (#2538)
Bumps [proc-macro2](https://github.com/dtolnay/proc-macro2) from 1.0.83 to 1.0.84.
- [Release notes](https://github.com/dtolnay/proc-macro2/releases)
- [Commits](https://github.com/dtolnay/proc-macro2/compare/1.0.83...1.0.84)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-05-27 19:38:53 +00:00
f735cdc22e fix and simulate engine disconnect when in sketch mode (#2524)
* fix and simulate engine disconnect when in sketch mode

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

* Update e2e/playwright/test-utils.ts

---------

Signed-off-by: Jess Frazelle <github@jessfraz.com>
2024-05-24 23:11:49 +00:00
197 changed files with 19817 additions and 12220 deletions

View File

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

1
.envrc Normal file
View File

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

40
.github/workflows/cargo-check.yml vendored Normal file
View File

@ -0,0 +1,40 @@
on:
push:
branches:
- main
paths:
- '**/Cargo.toml'
- '**/Cargo.lock'
- '**/rust-toolchain.toml'
- '**.rs'
- .github/workflows/cargo-check.yml
pull_request:
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
cancel-in-progress: true
name: cargo check
jobs:
cargocheck:
name: cargo check
runs-on: ubuntu-latest
strategy:
matrix:
dir: ['src/wasm-lib']
steps:
- uses: actions/checkout@v4
- name: Install latest rust
uses: actions-rs/toolchain@v1
with:
toolchain: stable
override: true
- name: Rust Cache
uses: Swatinem/rust-cache@v2.6.1
- name: Run check
run: |
cd "${{ matrix.dir }}"
# We specifically want to test the disable-println feature
# Since it is not enabled by default, we need to specify it
# This is used in kcl-lsp
cargo check --all --features disable-println --features pyo3

View File

@ -9,6 +9,12 @@ on:
- '**.rs' - '**.rs'
- .github/workflows/cargo-clippy.yml - .github/workflows/cargo-clippy.yml
pull_request: pull_request:
paths:
- '**/Cargo.toml'
- '**/Cargo.lock'
- '**/rust-toolchain.toml'
- '**.rs'
- .github/workflows/cargo-clippy.yml
concurrency: concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
cancel-in-progress: true cancel-in-progress: true
@ -54,3 +60,8 @@ jobs:
run: | run: |
cd "${{ matrix.dir }}" cd "${{ matrix.dir }}"
cargo clippy --all --tests --benches -- -D warnings cargo clippy --all --tests --benches -- -D warnings
# If this fails, run "cargo check" to update Cargo.lock,
# then add Cargo.lock to the PR.
- name: Check Cargo.lock doesn't need updating
run: |
cargo check --locked || echo "Pls run cargo check and commit the changed Cargo.lock"

View File

@ -147,6 +147,14 @@ jobs:
cp artifact/src-tauri/tauri.conf.json src-tauri/tauri.conf.json cp artifact/src-tauri/tauri.conf.json src-tauri/tauri.conf.json
cp artifact/src-tauri/tauri.release.conf.json src-tauri/tauri.release.conf.json cp artifact/src-tauri/tauri.release.conf.json src-tauri/tauri.release.conf.json
- name: Update WebView2 on Windows
if: matrix.os == 'windows-latest'
# Workaround needed to build the tauri windows app with matching edge version.
# From https://github.com/actions/runner-images/issues/9538
run: |
Invoke-WebRequest -Uri 'https://go.microsoft.com/fwlink/p/?LinkId=2124703' -OutFile 'setup.exe'
Start-Process -FilePath setup.exe -Verb RunAs -Wait
- name: Install ubuntu system dependencies - name: Install ubuntu system dependencies
if: matrix.os == 'ubuntu-latest' if: matrix.os == 'ubuntu-latest'
run: | run: |
@ -172,9 +180,7 @@ jobs:
- name: Setup Rust - name: Setup Rust
uses: dtolnay/rust-toolchain@stable uses: dtolnay/rust-toolchain@stable
# TODO: re-enable for Windows builds, see https://github.com/tauri-apps/tauri/issues/9045
- name: Setup Rust cache - name: Setup Rust cache
if: matrix.os != 'windows-latest'
uses: swatinem/rust-cache@v2 uses: swatinem/rust-cache@v2
with: with:
workspaces: './src-tauri -> target' workspaces: './src-tauri -> target'
@ -364,6 +370,17 @@ jobs:
E2E_APPLICATION: "./src-tauri/target/${{ env.BUILD_RELEASE == 'true' && 'release' || 'debug' }}/zoo-modeling-app" E2E_APPLICATION: "./src-tauri/target/${{ env.BUILD_RELEASE == 'true' && 'release' || 'debug' }}/zoo-modeling-app"
KITTYCAD_API_TOKEN: ${{ env.BUILD_RELEASE == 'true' && secrets.KITTYCAD_API_TOKEN || secrets.KITTYCAD_API_TOKEN_DEV }} KITTYCAD_API_TOKEN: ${{ env.BUILD_RELEASE == 'true' && secrets.KITTYCAD_API_TOKEN || secrets.KITTYCAD_API_TOKEN_DEV }}
- name: Run e2e tests (windows only)
if: ${{ matrix.os == 'windows-latest' && github.event_name != 'release' && github.event_name != 'schedule' }}
run: |
cargo install tauri-driver --force
yarn wdio run wdio.conf.ts
env:
E2E_APPLICATION: ".\\src-tauri\\target\\${{ env.BUILD_RELEASE == 'true' && 'release' || 'debug' }}\\Zoo Modeling App.exe"
KITTYCAD_API_TOKEN: ${{ env.BUILD_RELEASE == 'true' && secrets.KITTYCAD_API_TOKEN || secrets.KITTYCAD_API_TOKEN_DEV }}
VITE_KC_API_BASE_URL: ${{ env.BUILD_RELEASE == 'true' && 'https://api.zoo.dev' || 'https://api.dev.zoo.dev' }}
E2E_TAURI_ENABLED: true
TS_NODE_COMPILER_OPTIONS: '{"module": "commonjs"}'
publish-apps-release: publish-apps-release:
runs-on: ubuntu-latest runs-on: ubuntu-latest

View File

@ -46,12 +46,18 @@ jobs:
- uses: KittyCAD/action-install-cli@main - uses: KittyCAD/action-install-cli@main
- name: Install dependencies - name: Install dependencies
run: yarn run: yarn
- name: Cache Playwright Browsers
uses: actions/cache@v4
with:
path: |
~/.cache/ms-playwright/
key: ${{ runner.os }}-playwright-${{ hashFiles('yarn.lock') }}
- name: Install Playwright Browsers - name: Install Playwright Browsers
run: yarn playwright install --with-deps run: yarn playwright install --with-deps
- name: Download Wasm Cache - name: Download Wasm Cache
id: download-wasm id: download-wasm
if: needs.check-rust-changes.outputs.rust-changed == 'false' if: needs.check-rust-changes.outputs.rust-changed == 'false'
uses: dawidd6/action-download-artifact@v3 uses: dawidd6/action-download-artifact@v6
continue-on-error: true continue-on-error: true
with: with:
github_token: ${{secrets.GITHUB_TOKEN}} github_token: ${{secrets.GITHUB_TOKEN}}
@ -127,7 +133,7 @@ jobs:
- uses: actions/upload-artifact@v3 - uses: actions/upload-artifact@v3
if: always() if: always()
with: with:
name: playwright-report name: playwright-report-ubuntu
path: playwright-report/ path: playwright-report/
retention-days: 30 retention-days: 30
@ -143,12 +149,20 @@ jobs:
cache: 'yarn' cache: 'yarn'
- name: Install dependencies - name: Install dependencies
run: yarn run: yarn
- name: Cache Playwright Browsers
uses: actions/cache@v4
with:
path: |
~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-playwright-
- name: Install Playwright Browsers - name: Install Playwright Browsers
run: yarn playwright install --with-deps run: yarn playwright install --with-deps
- name: Download Wasm Cache - name: Download Wasm Cache
id: download-wasm id: download-wasm
if: needs.check-rust-changes.outputs.rust-changed == 'false' if: needs.check-rust-changes.outputs.rust-changed == 'false'
uses: dawidd6/action-download-artifact@v3 uses: dawidd6/action-download-artifact@v6
continue-on-error: true continue-on-error: true
with: with:
github_token: ${{secrets.GITHUB_TOKEN}} github_token: ${{secrets.GITHUB_TOKEN}}
@ -190,6 +204,6 @@ jobs:
- uses: actions/upload-artifact@v3 - uses: actions/upload-artifact@v3
if: always() if: always()
with: with:
name: playwright-report name: playwright-report-macos
path: playwright-report/ path: playwright-report/
retention-days: 30 retention-days: 30

1
.gitignore vendored
View File

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

14
Makefile Normal file
View File

@ -0,0 +1,14 @@
.PHONY: dev
WASM_LIB_FILES := $(wildcard src/wasm-lib/**/*.rs)
dev: node_modules public/wasm_lib_bg.wasm
yarn start
public/wasm_lib_bg.wasm: $(WASM_LIB_FILES)
yarn build:wasm-dev
node_modules: package.json
package.json:
yarn install

View File

@ -197,28 +197,32 @@ For more information on fuzzing you can check out
### Playwright ### Playwright
First time running plawright locally, you'll need to add the secrets file For a portable way to run Playwright you'll need Docker.
After that, open a terminal and run:
```bash ```bash
touch ./e2e/playwright/playwright-secrets.env docker run --network host --rm --init -it playwright/chrome:playwright-1.43.1
printf 'token="your-token"\nsnapshottoken="your-snapshot-token"' > ./e2e/playwright/playwright-secrets.env
``` ```
and in another terminal, run:
```bash
PW_TEST_CONNECT_WS_ENDPOINT=ws://127.0.0.1:4444/ yarn playwright test --project="Google Chrome" <test suite>
```
An example of a `<test suite>` is: `e2e/playwright/flow-tests.spec.ts`
YOU WILL NEED A PLAYWRIGHT-SECRETS.ENV FILE:
```bash
# ./e2e/playwright/playwright-secrets.env
token=<your-token>
snapshottoken=<your-snapshot-token>
```
then replace "your-token" with a dev token from dev.zoo.dev/account/api-tokens then replace "your-token" with a dev token from dev.zoo.dev/account/api-tokens
then:
run playwright
```
yarn playwright test
```
run a specific test suite
```
yarn playwright test src/e2e-tests/example.spec.ts
```
run a specific test change the test from `test('...` to `test.only('...` run a specific test change the test from `test('...` to `test.only('...`
(note if you commit this, the tests will instantly fail without running any of the tests) (note if you commit this, the tests will instantly fail without running any of the tests)
@ -309,6 +313,25 @@ PS: for the debug panel, the following JSON is useful for snapping the camera
</details> </details>
### Tauri e2e tests
#### Windows (local only until the CI edge version mismatch is fixed)
```
yarn install
yarn build:wasm-dev
cp src/wasm-lib/pkg/wasm_lib_bg.wasm public
yarn vite build --mode development
yarn tauri build --debug -b
$env:KITTYCAD_API_TOKEN="<YOUR_KITTYCAD_API_TOKEN>"
$env:VITE_KC_API_BASE_URL="https://api.dev.zoo.dev"
$env:E2E_TAURI_ENABLED="true"
$env:TS_NODE_COMPILER_OPTIONS='{"module": "commonjs"}'
$env:E2E_APPLICATION=".\src-tauri\target\debug\Zoo Modeling App.exe"
Stop-Process -Name msedgedriver
yarn wdio run wdio.conf.ts
```
## KCL ## KCL
For how to contribute to KCL, [see our KCL README](https://github.com/KittyCAD/modeling-app/tree/main/src/wasm-lib/kcl). For how to contribute to KCL, [see our KCL README](https://github.com/KittyCAD/modeling-app/tree/main/src/wasm-lib/kcl).

323
docs/kcl/chamfer.md Normal file

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

311
docs/kcl/shell.md Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,10 +1,10 @@
import { test, expect, Download } from '@playwright/test' import { test, expect } from '@playwright/test'
import { secrets } from './secrets' import { secrets } from './secrets'
import { getUtils } from './test-utils' import { Paths, doExport, getUtils } from './test-utils'
import { Models } from '@kittycad/lib' import { Models } from '@kittycad/lib'
import fsp from 'fs/promises' import fsp from 'fs/promises'
import { spawn } from 'child_process' import { spawn } from 'child_process'
import { APP_NAME, KCL_DEFAULT_LENGTH } from 'lib/constants' import { KCL_DEFAULT_LENGTH } from 'lib/constants'
import JSZip from 'jszip' import JSZip from 'jszip'
import path from 'path' import path from 'path'
import { TEST_SETTINGS, TEST_SETTINGS_KEY } from './storageStates' import { TEST_SETTINGS, TEST_SETTINGS_KEY } from './storageStates'
@ -99,78 +99,6 @@ const part001 = startSketchOn('-XZ')
await page.waitForTimeout(1000) await page.waitForTimeout(1000)
await u.clearAndCloseDebugPanel() await u.clearAndCloseDebugPanel()
interface Paths {
modelPath: string
imagePath: string
outputType: string
}
const doExport = async (
output: Models['OutputFormat_type']
): Promise<Paths> => {
await page.getByRole('button', { name: APP_NAME }).click()
await expect(
page.getByRole('button', { name: 'Export Part' })
).toBeVisible()
await page.getByRole('button', { name: 'Export Part' }).click()
await expect(page.getByTestId('command-bar')).toBeVisible()
// Go through export via command bar
await page.getByRole('option', { name: output.type, exact: false }).click()
await page.locator('#arg-form').waitFor({ state: 'detached' })
if ('storage' in output) {
await page.getByTestId('arg-name-storage').waitFor({ timeout: 1000 })
await page.getByRole('button', { name: 'storage', exact: false }).click()
await page
.getByRole('option', { name: output.storage, exact: false })
.click()
await page.locator('#arg-form').waitFor({ state: 'detached' })
}
await expect(page.getByText('Confirm Export')).toBeVisible()
const getPromiseAndResolve = () => {
let resolve: any = () => {}
const promise = new Promise<Download>((r) => {
resolve = r
})
return [promise, resolve]
}
const [downloadPromise1, downloadResolve1] = getPromiseAndResolve()
let downloadCnt = 0
page.on('download', async (download) => {
if (downloadCnt === 0) {
downloadResolve1(download)
}
downloadCnt++
})
await page.getByRole('button', { name: 'Submit command' }).click()
// Handle download
const download = await downloadPromise1
const downloadLocationer = (extra = '', isImage = false) =>
`./e2e/playwright/export-snapshots/${output.type}-${
'storage' in output ? output.storage : ''
}${extra}.${isImage ? 'png' : output.type}`
const downloadLocation = downloadLocationer()
await download.saveAs(downloadLocation)
if (output.type === 'step') {
// stable timestamps for step files
const fileContents = await fsp.readFile(downloadLocation, 'utf-8')
const newFileContents = fileContents.replace(
/[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]+[0-9]+[0-9]\+[0-9]{2}:[0-9]{2}/g,
'1970-01-01T00:00:00.0+00:00'
)
await fsp.writeFile(downloadLocation, newFileContents)
}
return {
modelPath: downloadLocation,
imagePath: downloadLocationer('', true),
outputType: output.type,
}
}
const axisDirectionPair: Models['AxisDirectionPair_type'] = { const axisDirectionPair: Models['AxisDirectionPair_type'] = {
axis: 'z', axis: 'z',
direction: 'positive', direction: 'positive',
@ -186,84 +114,114 @@ const part001 = startSketchOn('-XZ')
// just note that only `type` and `storage` are used for selecting the drop downs is the app // just note that only `type` and `storage` are used for selecting the drop downs is the app
// the rest are only there to make typescript happy // the rest are only there to make typescript happy
exportLocations.push( exportLocations.push(
await doExport({ await doExport(
type: 'step', {
coords: sysType, type: 'step',
}) coords: sysType,
},
page
)
) )
exportLocations.push( exportLocations.push(
await doExport({ await doExport(
type: 'ply', {
coords: sysType, type: 'ply',
selection: { type: 'default_scene' }, coords: sysType,
storage: 'ascii', selection: { type: 'default_scene' },
units: 'in', storage: 'ascii',
}) units: 'in',
},
page
)
) )
exportLocations.push( exportLocations.push(
await doExport({ await doExport(
type: 'ply', {
storage: 'binary_little_endian', type: 'ply',
coords: sysType, storage: 'binary_little_endian',
selection: { type: 'default_scene' }, coords: sysType,
units: 'in', selection: { type: 'default_scene' },
}) units: 'in',
},
page
)
) )
exportLocations.push( exportLocations.push(
await doExport({ await doExport(
type: 'ply', {
storage: 'binary_big_endian', type: 'ply',
coords: sysType, storage: 'binary_big_endian',
selection: { type: 'default_scene' }, coords: sysType,
units: 'in', selection: { type: 'default_scene' },
}) units: 'in',
},
page
)
) )
exportLocations.push( exportLocations.push(
await doExport({ await doExport(
type: 'stl', {
storage: 'ascii', type: 'stl',
coords: sysType, storage: 'ascii',
units: 'in', coords: sysType,
selection: { type: 'default_scene' }, units: 'in',
}) selection: { type: 'default_scene' },
},
page
)
) )
exportLocations.push( exportLocations.push(
await doExport({ await doExport(
type: 'stl', {
storage: 'binary', type: 'stl',
coords: sysType, storage: 'binary',
units: 'in', coords: sysType,
selection: { type: 'default_scene' }, units: 'in',
}) selection: { type: 'default_scene' },
},
page
)
) )
exportLocations.push( exportLocations.push(
await doExport({ await doExport(
// obj seems to be a little flaky, times out tests sometimes {
type: 'obj', // obj seems to be a little flaky, times out tests sometimes
coords: sysType, type: 'obj',
units: 'in', coords: sysType,
}) units: 'in',
},
page
)
) )
exportLocations.push( exportLocations.push(
await doExport({ await doExport(
type: 'gltf', {
storage: 'embedded', type: 'gltf',
presentation: 'pretty', storage: 'embedded',
}) presentation: 'pretty',
},
page
)
) )
exportLocations.push( exportLocations.push(
await doExport({ await doExport(
type: 'gltf', {
storage: 'binary', type: 'gltf',
presentation: 'pretty', storage: 'binary',
}) presentation: 'pretty',
},
page
)
) )
exportLocations.push( exportLocations.push(
await doExport({ await doExport(
type: 'gltf', {
storage: 'standard', type: 'gltf',
presentation: 'pretty', storage: 'standard',
}) presentation: 'pretty',
},
page
)
) )
// close page to disconnect websocket since we can only have one open atm // close page to disconnect websocket since we can only have one open atm
@ -447,17 +405,16 @@ test('Draft segments should look right', async ({ page, context }) => {
// select a plane // select a plane
await page.mouse.click(700, 200) await page.mouse.click(700, 200)
await expect(page.locator('.cm-content')).toHaveText( let code = `const sketch001 = startSketchOn('XZ')`
`const part001 = startSketchOn('XZ')` await expect(page.locator('.cm-content')).toHaveText(code)
)
await page.waitForTimeout(300) // TODO detect animation ending, or disable animation await page.waitForTimeout(700) // TODO detect animation ending, or disable animation
const startXPx = 600 const startXPx = 600
await page.mouse.click(startXPx + PUR * 10, 500 - PUR * 10) await page.mouse.click(startXPx + PUR * 10, 500 - PUR * 10)
await expect(page.locator('.cm-content')) code += `
.toHaveText(`const part001 = startSketchOn('XZ') |> startProfileAt([7.19, -9.7], %)`
|> startProfileAt([9.06, -12.22], %)`) await expect(page.locator('.cm-content')).toHaveText(code)
await page.waitForTimeout(100) await page.waitForTimeout(100)
await u.closeDebugPanel() await u.closeDebugPanel()
@ -469,10 +426,9 @@ test('Draft segments should look right', async ({ page, context }) => {
await page.mouse.click(startXPx + PUR * 20, 500 - PUR * 10) await page.mouse.click(startXPx + PUR * 20, 500 - PUR * 10)
await page.waitForTimeout(100) await page.waitForTimeout(100)
await expect(page.locator('.cm-content')) code += `
.toHaveText(`const part001 = startSketchOn('XZ') |> line([7.25, 0], %)`
|> startProfileAt([9.06, -12.22], %) await expect(page.locator('.cm-content')).toHaveText(code)
|> line([9.14, 0], %)`)
await page.getByRole('button', { name: 'Tangential Arc' }).click() await page.getByRole('button', { name: 'Tangential Arc' }).click()
@ -507,7 +463,7 @@ test('Draft rectangles should look right', async ({ page, context }) => {
await page.mouse.click(700, 200) await page.mouse.click(700, 200)
await expect(page.locator('.cm-content')).toHaveText( await expect(page.locator('.cm-content')).toHaveText(
`const part001 = startSketchOn('XZ')` `const sketch001 = startSketchOn('XZ')`
) )
await page.waitForTimeout(500) // TODO detect animation ending, or disable animation await page.waitForTimeout(500) // TODO detect animation ending, or disable animation
@ -555,17 +511,16 @@ test.describe('Client side scene scale should match engine scale', () => {
// select a plane // select a plane
await page.mouse.click(700, 200) await page.mouse.click(700, 200)
await expect(page.locator('.cm-content')).toHaveText( let code = `const sketch001 = startSketchOn('XZ')`
`const part001 = startSketchOn('XZ')` await expect(page.locator('.cm-content')).toHaveText(code)
)
await page.waitForTimeout(300) // TODO detect animation ending, or disable animation await page.waitForTimeout(600) // TODO detect animation ending, or disable animation
const startXPx = 600 const startXPx = 600
await page.mouse.click(startXPx + PUR * 10, 500 - PUR * 10) await page.mouse.click(startXPx + PUR * 10, 500 - PUR * 10)
await expect(page.locator('.cm-content')) code += `
.toHaveText(`const part001 = startSketchOn('XZ') |> startProfileAt([7.19, -9.7], %)`
|> startProfileAt([9.06, -12.22], %)`) await expect(u.codeLocator).toHaveText(code)
await page.waitForTimeout(100) await page.waitForTimeout(100)
await u.closeDebugPanel() await u.closeDebugPanel()
@ -573,21 +528,18 @@ test.describe('Client side scene scale should match engine scale', () => {
await page.mouse.click(startXPx + PUR * 20, 500 - PUR * 10) await page.mouse.click(startXPx + PUR * 20, 500 - PUR * 10)
await page.waitForTimeout(100) await page.waitForTimeout(100)
await expect(page.locator('.cm-content')) code += `
.toHaveText(`const part001 = startSketchOn('XZ') |> line([7.25, 0], %)`
|> startProfileAt([9.06, -12.22], %) await expect(u.codeLocator).toHaveText(code)
|> line([9.14, 0], %)`)
await page.getByRole('button', { name: 'Tangential Arc' }).click() await page.getByRole('button', { name: 'Tangential Arc' }).click()
await page.waitForTimeout(100) await page.waitForTimeout(100)
await page.mouse.click(startXPx + PUR * 30, 500 - PUR * 20) await page.mouse.click(startXPx + PUR * 30, 500 - PUR * 20)
await expect(page.locator('.cm-content')) code += `
.toHaveText(`const part001 = startSketchOn('XZ') |> tangentialArcTo([21.7, -2.44], %)`
|> startProfileAt([9.06, -12.22], %) await expect(u.codeLocator).toHaveText(code)
|> line([9.14, 0], %)
|> tangentialArcTo([27.34, -3.08], %)`)
// click tangential arc tool again to unequip it // click tangential arc tool again to unequip it
await page.getByRole('button', { name: 'Tangential Arc' }).click() await page.getByRole('button', { name: 'Tangential Arc' }).click()
@ -658,17 +610,16 @@ test.describe('Client side scene scale should match engine scale', () => {
// select a plane // select a plane
await page.mouse.click(700, 200) await page.mouse.click(700, 200)
await expect(page.locator('.cm-content')).toHaveText( let code = `const sketch001 = startSketchOn('XZ')`
`const part001 = startSketchOn('XZ')` await expect(u.codeLocator).toHaveText(code)
)
await page.waitForTimeout(300) // TODO detect animation ending, or disable animation await page.waitForTimeout(600) // TODO detect animation ending, or disable animation
const startXPx = 600 const startXPx = 600
await page.mouse.click(startXPx + PUR * 10, 500 - PUR * 10) await page.mouse.click(startXPx + PUR * 10, 500 - PUR * 10)
await expect(page.locator('.cm-content')) code += `
.toHaveText(`const part001 = startSketchOn('XZ') |> startProfileAt([182.59, -246.32], %)`
|> startProfileAt([230.03, -310.32], %)`) await expect(u.codeLocator).toHaveText(code)
await page.waitForTimeout(100) await page.waitForTimeout(100)
await u.closeDebugPanel() await u.closeDebugPanel()
@ -676,21 +627,18 @@ test.describe('Client side scene scale should match engine scale', () => {
await page.mouse.click(startXPx + PUR * 20, 500 - PUR * 10) await page.mouse.click(startXPx + PUR * 20, 500 - PUR * 10)
await page.waitForTimeout(100) await page.waitForTimeout(100)
await expect(page.locator('.cm-content')) code += `
.toHaveText(`const part001 = startSketchOn('XZ') |> line([184.3, 0], %)`
|> startProfileAt([230.03, -310.32], %) await expect(u.codeLocator).toHaveText(code)
|> line([232.2, 0], %)`)
await page.getByRole('button', { name: 'Tangential Arc' }).click() await page.getByRole('button', { name: 'Tangential Arc' }).click()
await page.waitForTimeout(100) await page.waitForTimeout(100)
await page.mouse.click(startXPx + PUR * 30, 500 - PUR * 20) await page.mouse.click(startXPx + PUR * 30, 500 - PUR * 20)
await expect(page.locator('.cm-content')) code += `
.toHaveText(`const part001 = startSketchOn('XZ') |> tangentialArcTo([551.2, -62.01], %)`
|> startProfileAt([230.03, -310.32], %) await expect(u.codeLocator).toHaveText(code)
|> line([232.2, 0], %)
|> tangentialArcTo([694.43, -78.12], %)`)
await page.getByRole('button', { name: 'Tangential Arc' }).click() await page.getByRole('button', { name: 'Tangential Arc' }).click()
await page.waitForTimeout(100) await page.waitForTimeout(100)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 45 KiB

View File

@ -1,5 +1,6 @@
import { SaveSettingsPayload } from 'lib/settings/settingsTypes' import { SaveSettingsPayload } from 'lib/settings/settingsTypes'
import { Themes } from 'lib/theme' import { Themes } from 'lib/theme'
import { onboardingPaths } from 'routes/Onboarding/paths'
export const TEST_SETTINGS_KEY = '/settings.toml' export const TEST_SETTINGS_KEY = '/settings.toml'
export const TEST_SETTINGS = { export const TEST_SETTINGS = {
@ -22,9 +23,22 @@ export const TEST_SETTINGS = {
}, },
} satisfies Partial<SaveSettingsPayload> } satisfies Partial<SaveSettingsPayload>
export const TEST_SETTINGS_ONBOARDING_USER_MENU = {
...TEST_SETTINGS,
app: { ...TEST_SETTINGS.app, onboardingStatus: onboardingPaths.USER_MENU },
} satisfies Partial<SaveSettingsPayload>
export const TEST_SETTINGS_ONBOARDING_EXPORT = { export const TEST_SETTINGS_ONBOARDING_EXPORT = {
...TEST_SETTINGS, ...TEST_SETTINGS,
app: { ...TEST_SETTINGS.app, onboardingStatus: '/export' }, app: { ...TEST_SETTINGS.app, onboardingStatus: onboardingPaths.EXPORT },
} satisfies Partial<SaveSettingsPayload>
export const TEST_SETTINGS_ONBOARDING_PARAMETRIC_MODELING = {
...TEST_SETTINGS,
app: {
...TEST_SETTINGS.app,
onboardingStatus: onboardingPaths.PARAMETRIC_MODELING,
},
} satisfies Partial<SaveSettingsPayload> } satisfies Partial<SaveSettingsPayload>
export const TEST_SETTINGS_ONBOARDING_START = { export const TEST_SETTINGS_ONBOARDING_START = {
@ -50,3 +64,25 @@ export const TEST_SETTINGS_CORRUPTED = {
textWrapping: true, textWrapping: true,
}, },
} satisfies Partial<SaveSettingsPayload> } satisfies Partial<SaveSettingsPayload>
export const TEST_CODE_GIZMO = `const part001 = startSketchOn('XZ')
|> startProfileAt([20, 0], %)
|> line([7.13, 4 + 0], %)
|> angledLine({ angle: 3 + 0, length: 3.14 + 0 }, %)
|> lineTo([20.14 + 0, -0.14 + 0], %)
|> xLineTo(29 + 0, %)
|> yLine(-3.14 + 0, %, 'a')
|> xLine(1.63, %)
|> angledLineOfXLength({ angle: 3 + 0, length: 3.14 }, %)
|> angledLineOfYLength({ angle: 30, length: 3 + 0 }, %)
|> angledLineToX({ angle: 22.14 + 0, to: 12 }, %)
|> angledLineToY({ angle: 30, to: 11.14 }, %)
|> angledLineThatIntersects({
angle: 3.14,
intersectTag: 'a',
offset: 0
}, %)
|> tangentialArcTo([13.14 + 0, 13.14], %)
|> close(%)
|> extrude(5 + 7, %)
`

View File

@ -1,22 +1,27 @@
import { test, expect, Page } from '@playwright/test' import { test, expect, Page, Download } from '@playwright/test'
import { EngineCommand } from '../../src/lang/std/engineConnection' import { EngineCommand } from '../../src/lang/std/engineConnection'
import os from 'os'
import fsp from 'fs/promises' import fsp from 'fs/promises'
import pixelMatch from 'pixelmatch' import pixelMatch from 'pixelmatch'
import { PNG } from 'pngjs' import { PNG } from 'pngjs'
import { Protocol } from 'playwright-core/types/protocol' import { Protocol } from 'playwright-core/types/protocol'
import type { Models } from '@kittycad/lib'
import { APP_NAME } from 'lib/constants'
async function waitForPageLoad(page: Page) { async function waitForPageLoad(page: Page) {
// wait for 'Loading stream...' spinner // wait for 'Loading stream...' spinner
await page.getByTestId('loading-stream').waitFor() await page.getByTestId('loading-stream').waitFor()
// wait for all spinners to be gone // wait for all spinners to be gone
await page.getByTestId('loading').waitFor({ state: 'detached' }) await page
.getByTestId('loading')
.waitFor({ state: 'detached', timeout: 20_000 })
await page.getByTestId('start-sketch').waitFor() await page.getByTestId('start-sketch').waitFor()
} }
async function removeCurrentCode(page: Page) { async function removeCurrentCode(page: Page) {
const hotkey = process.platform === 'darwin' ? 'Meta' : 'Control' const hotkey = process.platform === 'darwin' ? 'Meta' : 'Control'
await page.click('.cm-content') await page.locator('.cm-content').click()
await page.keyboard.down(hotkey) await page.keyboard.down(hotkey)
await page.keyboard.press('a') await page.keyboard.press('a')
await page.keyboard.up(hotkey) await page.keyboard.up(hotkey)
@ -25,12 +30,12 @@ async function removeCurrentCode(page: Page) {
} }
async function sendCustomCmd(page: Page, cmd: EngineCommand) { async function sendCustomCmd(page: Page, cmd: EngineCommand) {
await page.fill('[data-testid="custom-cmd-input"]', JSON.stringify(cmd)) await page.getByTestId('custom-cmd-input').fill(JSON.stringify(cmd))
await page.click('[data-testid="custom-cmd-send-button"]') await page.getByTestId('custom-cmd-send-button').click()
} }
async function clearCommandLogs(page: Page) { async function clearCommandLogs(page: Page) {
await page.click('[data-testid="clear-commands"]') await page.getByTestId('clear-commands').click()
} }
async function expectCmdLog(page: Page, locatorStr: string) { async function expectCmdLog(page: Page, locatorStr: string) {
@ -94,11 +99,79 @@ async function waitForCmdReceive(page: Page, commandType: string) {
.waitFor() .waitFor()
} }
export const wiggleMove = async (
page: any,
x: number,
y: number,
steps: number,
dist: number,
ang: number,
amplitude: number,
freq: number
) => {
const tau = Math.PI * 2
const deg = tau / 360
const step = dist / steps
for (let i = 0, j = 0; i < dist; i += step, j += 1) {
const [x1, y1] = [0, Math.sin((tau / steps) * j * freq) * amplitude]
const [x2, y2] = [
Math.cos(-ang * deg) * i - Math.sin(-ang * deg) * y1,
Math.sin(-ang * deg) * i + Math.cos(-ang * deg) * y1,
]
const [xr, yr] = [x2, y2]
await page.mouse.move(x + xr, y + yr, { steps: 2 })
}
}
export const getMovementUtils = (opts: any) => {
// The way we truncate is kinda odd apparently, so we need this function
// "[k]itty[c]ad round"
const kcRound = (n: number) => Math.trunc(n * 100) / 100
// To translate between screen and engine ("[U]nit") coordinates
// NOTE: these pretty much can't be perfect because of screen scaling.
// Handle on a case-by-case.
const toU = (x: number, y: number) => [
kcRound(x * 0.0678),
kcRound(-y * 0.0678), // Y is inverted in our coordinate system
]
// Turn the array into a string with specific formatting
const fromUToString = (xy: number[]) => `[${xy[0]}, ${xy[1]}]`
// Combine because used often
const toSU = (xy: number[]) => fromUToString(toU(xy[0], xy[1]))
// Make it easier to click around from center ("click [from] zero zero")
const click00 = (x: number, y: number) =>
opts.page.mouse.click(opts.center.x + x, opts.center.y + y)
// Relative clicker, must keep state
let last = { x: 0, y: 0 }
const click00r = (x?: number, y?: number) => {
// reset relative coordinates when anything is undefined
if (x === undefined || y === undefined) {
last.x = 0
last.y = 0
return
}
const ret = click00(last.x + x, last.y + y)
last.x += x
last.y += y
// Returns the new absolute coordinate if you need it.
return ret.then(() => [last.x, last.y])
}
return { toSU, click00r }
}
export async function getUtils(page: Page) { export async function getUtils(page: Page) {
// Chrome devtools protocol session only works in Chromium
const browserType = page.context().browser()?.browserType().name()
const cdpSession = const cdpSession =
process.platform === 'darwin' browserType !== 'chromium' ? null : await page.context().newCDPSession(page)
? null
: await page.context().newCDPSession(page)
return { return {
waitForAuthSkipAppStart: () => waitForPageLoad(page), waitForAuthSkipAppStart: () => waitForPageLoad(page),
@ -130,11 +203,30 @@ export async function getUtils(page: Page) {
}, },
waitForCmdReceive: (commandType: string) => waitForCmdReceive: (commandType: string) =>
waitForCmdReceive(page, commandType), waitForCmdReceive(page, commandType),
getSegmentBodyCoords: async (locator: string, px = 30) => {
const overlay = page.locator(locator)
const bbox = await overlay
.boundingBox()
.then((box) => ({ ...box, x: box?.x || 0, y: box?.y || 0 }))
const angle = Number(await overlay.getAttribute('data-overlay-angle'))
const angleXOffset = Math.cos(((angle - 180) * Math.PI) / 180) * px
const angleYOffset = Math.sin(((angle - 180) * Math.PI) / 180) * px
return {
x: Math.round(bbox.x + angleXOffset),
y: Math.round(bbox.y - angleYOffset),
}
},
getAngle: async (locator: string) => {
const overlay = page.locator(locator)
return Number(await overlay.getAttribute('data-overlay-angle'))
},
getBoundingBox: async (locator: string) => getBoundingBox: async (locator: string) =>
page page
.locator(locator) .locator(locator)
.boundingBox() .boundingBox()
.then((box) => ({ x: box?.x || 0, y: box?.y || 0 })), .then((box) => ({ ...box, x: box?.x || 0, y: box?.y || 0 })),
codeLocator: page.locator('.cm-content'),
canvasLocator: page.getByTestId('client-side-scene'),
doAndWaitForCmd: async ( doAndWaitForCmd: async (
fn: () => Promise<void>, fn: () => Promise<void>,
commandType: string, commandType: string,
@ -150,6 +242,30 @@ export async function getUtils(page: Page) {
await closeDebugPanel(page) await closeDebugPanel(page)
} }
}, },
/**
* Given an expected RGB value, diff if the channel with the largest difference
*/
getGreatestPixDiff: async (
coords: { x: number; y: number },
expected: [number, number, number]
): Promise<number> => {
const buffer = await page.screenshot({
fullPage: true,
})
const screenshot = await PNG.sync.read(buffer)
// most likely related to pixel density but the screenshots for webkit are 2x the size
// there might be a more robust way of doing this.
const pixMultiplier = browserType === 'webkit' ? 2 : 1
const index =
(screenshot.width * coords.y * pixMultiplier +
coords.x * pixMultiplier) *
4 // rbga is 4 channels
return Math.max(
Math.abs(screenshot.data[index] - expected[0]),
Math.abs(screenshot.data[index + 1] - expected[1]),
Math.abs(screenshot.data[index + 2] - expected[2])
)
},
doAndWaitForImageDiff: (fn: () => Promise<any>, diffCount = 200) => doAndWaitForImageDiff: (fn: () => Promise<any>, diffCount = 200) =>
new Promise(async (resolve) => { new Promise(async (resolve) => {
await page.screenshot({ await page.screenshot({
@ -277,3 +393,82 @@ export const makeTemplate: (
), ),
} }
} }
export interface Paths {
modelPath: string
imagePath: string
outputType: string
}
export const doExport = async (
output: Models['OutputFormat_type'],
page: Page
): Promise<Paths> => {
await page.getByRole('button', { name: APP_NAME }).click()
await expect(page.getByRole('button', { name: 'Export Part' })).toBeVisible()
await page.getByRole('button', { name: 'Export Part' }).click()
await expect(page.getByTestId('command-bar')).toBeVisible()
// Go through export via command bar
await page.getByRole('option', { name: output.type, exact: false }).click()
await page.locator('#arg-form').waitFor({ state: 'detached' })
if ('storage' in output) {
await page.getByTestId('arg-name-storage').waitFor({ timeout: 1000 })
await page.getByRole('button', { name: 'storage', exact: false }).click()
await page
.getByRole('option', { name: output.storage, exact: false })
.click()
await page.locator('#arg-form').waitFor({ state: 'detached' })
}
await expect(page.getByText('Confirm Export')).toBeVisible()
const getPromiseAndResolve = () => {
let resolve: any = () => {}
const promise = new Promise<Download>((r) => {
resolve = r
})
return [promise, resolve]
}
const [downloadPromise1, downloadResolve1] = getPromiseAndResolve()
let downloadCnt = 0
page.on('download', async (download) => {
if (downloadCnt === 0) {
downloadResolve1(download)
}
downloadCnt++
})
await page.getByRole('button', { name: 'Submit command' }).click()
// Handle download
const download = await downloadPromise1
const downloadLocationer = (extra = '', isImage = false) =>
`./e2e/playwright/export-snapshots/${output.type}-${
'storage' in output ? output.storage : ''
}${extra}.${isImage ? 'png' : output.type}`
const downloadLocation = downloadLocationer()
await download.saveAs(downloadLocation)
if (output.type === 'step') {
// stable timestamps for step files
const fileContents = await fsp.readFile(downloadLocation, 'utf-8')
const newFileContents = fileContents.replace(
/[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]+[0-9]+[0-9]\+[0-9]{2}:[0-9]{2}/g,
'1970-01-01T00:00:00.0+00:00'
)
await fsp.writeFile(downloadLocation, newFileContents)
}
return {
modelPath: downloadLocation,
imagePath: downloadLocationer('', true),
outputType: output.type,
}
}
/**
* Gets the appropriate modifier key for the platform.
*/
export const metaModifier = os.platform() === 'darwin' ? 'Meta' : 'Control'

View File

@ -1,31 +1,23 @@
import { browser, $, expect } from '@wdio/globals' import { browser, $, expect } from '@wdio/globals'
import fs from 'fs/promises' import fs from 'fs/promises'
import path from 'path'
import os from 'os'
import { click, setDatasetValue } from '../utils'
const documentsDir = `${process.env.HOME}/Documents` const isWin32 = os.platform() === 'win32'
const userSettingsDir = `${process.env.HOME}/.config/dev.zoo.modeling-app` const documentsDir = path.join(os.homedir(), 'Documents')
const defaultProjectDir = `${documentsDir}/zoo-modeling-app-projects` const userSettingsDir = path.join(
const newProjectDir = `${documentsDir}/a-different-directory` os.homedir(),
const userCodeDir = '/tmp/kittycad_user_code' '.config',
'dev.zoo.modeling-app'
)
const defaultProjectDir = path.join(documentsDir, 'zoo-modeling-app-projects')
const newProjectDir = path.join(documentsDir, 'a-different-directory')
const tmp = process.env.TEMP || '/tmp'
const userCodeDir = path.join(tmp, 'kittycad_user_code')
async function click(element: WebdriverIO.Element): Promise<void> { describe('ZMA sign in flow', () => {
// Workaround for .click(), see https://github.com/tauri-apps/tauri/issues/6541 before(async () => {
await element.waitForClickable()
await browser.execute('arguments[0].click();', element)
}
/* Shoutout to @Sheap on Github for a great workaround utility:
* https://github.com/tauri-apps/tauri/issues/6541#issue-1638944060
*/
async function setDatasetValue(
field: WebdriverIO.Element,
property: string,
value: string
) {
await browser.execute(`arguments[0].dataset.${property} = "${value}"`, field)
}
describe('ZMA (Tauri, Linux)', () => {
it('opens the auth page and signs in', async () => {
// Clean up filesystem from previous tests // Clean up filesystem from previous tests
await new Promise((resolve) => setTimeout(resolve, 100)) await new Promise((resolve) => setTimeout(resolve, 100))
await fs.rm(defaultProjectDir, { force: true, recursive: true }) await fs.rm(defaultProjectDir, { force: true, recursive: true })
@ -34,7 +26,9 @@ describe('ZMA (Tauri, Linux)', () => {
await fs.rm(userSettingsDir, { force: true, recursive: true }) await fs.rm(userSettingsDir, { force: true, recursive: true })
await fs.mkdir(defaultProjectDir, { recursive: true }) await fs.mkdir(defaultProjectDir, { recursive: true })
await fs.mkdir(newProjectDir, { recursive: true }) await fs.mkdir(newProjectDir, { recursive: true })
})
it('opens the auth page and signs in', async () => {
const signInButton = await $('[data-testid="sign-in-button"]') const signInButton = await $('[data-testid="sign-in-button"]')
expect(await signInButton.getText()).toEqual('Sign in') expect(await signInButton.getText()).toEqual('Sign in')
@ -42,9 +36,7 @@ describe('ZMA (Tauri, Linux)', () => {
await new Promise((resolve) => setTimeout(resolve, 2000)) await new Promise((resolve) => setTimeout(resolve, 2000))
// Get from main.rs // Get from main.rs
const userCode = await ( const userCode = await (await fs.readFile(userCodeDir)).toString()
await fs.readFile('/tmp/kittycad_user_code')
).toString()
console.log(`Found user code ${userCode}`) console.log(`Found user code ${userCode}`)
// Device flow: verify // Device flow: verify
@ -76,6 +68,10 @@ describe('ZMA (Tauri, Linux)', () => {
const newFileButton = await $('[data-testid="home-new-file"]') const newFileButton = await $('[data-testid="home-new-file"]')
expect(await newFileButton.getText()).toEqual('New project') expect(await newFileButton.getText()).toEqual('New project')
}) })
})
describe('ZMA authorized user flows', () => {
// Note: each flow below is intended to start *and* end from the home page
it('opens the settings page, checks filesystem settings, and closes the settings page', async () => { it('opens the settings page, checks filesystem settings, and closes the settings page', async () => {
const menuButton = await $('[data-testid="user-sidebar-toggle"]') const menuButton = await $('[data-testid="user-sidebar-toggle"]')
@ -92,7 +88,12 @@ describe('ZMA (Tauri, Linux)', () => {
* to be able to skip the folder selection dialog if data-testValue * to be able to skip the folder selection dialog if data-testValue
* has a value, allowing us to test the input otherwise works. * has a value, allowing us to test the input otherwise works.
*/ */
await setDatasetValue(projectDirInput, 'testValue', newProjectDir) // TODO: understand why we need to force double \ on Windows
await setDatasetValue(
projectDirInput,
'testValue',
isWin32 ? newProjectDir.replaceAll('\\', '\\\\') : newProjectDir
)
const projectDirButton = await $('[data-testid="project-directory-button"]') const projectDirButton = await $('[data-testid="project-directory-button"]')
await click(projectDirButton) await click(projectDirButton)
await new Promise((resolve) => setTimeout(resolve, 500)) await new Promise((resolve) => setTimeout(resolve, 500))
@ -102,6 +103,15 @@ describe('ZMA (Tauri, Linux)', () => {
const nameInput = await $('[data-testid="projects-defaultProjectName"]') const nameInput = await $('[data-testid="projects-defaultProjectName"]')
expect(await nameInput.getValue()).toEqual('project-$nnn') expect(await nameInput.getValue()).toEqual('project-$nnn')
// Setting it back (for back to back local tests)
await new Promise((resolve) => setTimeout(resolve, 5000))
await setDatasetValue(
projectDirInput,
'testValue',
isWin32 ? defaultProjectDir.replaceAll('\\', '\\\\') : newProjectDir
)
await click(projectDirButton)
const closeButton = await $('[data-testid="settings-close-button"]') const closeButton = await $('[data-testid="settings-close-button"]')
await click(closeButton) await click(closeButton)
}) })
@ -120,12 +130,21 @@ describe('ZMA (Tauri, Linux)', () => {
it('opens the new file and expects a loading stream', async () => { it('opens the new file and expects a loading stream', async () => {
const projectLink = await $('[data-testid="project-link"]') const projectLink = await $('[data-testid="project-link"]')
await click(projectLink) await click(projectLink)
const errorText = await $('[data-testid="unexpected-error"]') if (isWin32) {
expect(await errorText.getText()).toContain('unexpected error') // TODO: actually do something to check that the stream is up
await browser.execute('window.location.href = "tauri://localhost/home"') await new Promise((resolve) => setTimeout(resolve, 5000))
} else {
const errorText = await $('[data-testid="unexpected-error"]')
expect(await errorText.getText()).toContain('unexpected error')
}
const base = isWin32 ? 'http://tauri.localhost' : 'tauri://localhost'
await browser.execute(`window.location.href = "${base}/home"`)
}) })
})
describe('ZMA sign out flow', () => {
it('signs out', async () => { it('signs out', async () => {
await new Promise((resolve) => setTimeout(resolve, 1000))
const menuButton = await $('[data-testid="user-sidebar-toggle"]') const menuButton = await $('[data-testid="user-sidebar-toggle"]')
await click(menuButton) await click(menuButton)
const signoutButton = await $('[data-testid="user-sidebar-sign-out"]') const signoutButton = await $('[data-testid="user-sidebar-sign-out"]')

18
e2e/tauri/utils.ts Normal file
View File

@ -0,0 +1,18 @@
import { browser } from '@wdio/globals'
export async function click(element: WebdriverIO.Element): Promise<void> {
// Workaround for .click(), see https://github.com/tauri-apps/tauri/issues/6541
await element.waitForClickable()
await browser.execute('arguments[0].click();', element)
}
/* Shoutout to @Sheap on Github for a great workaround utility:
* https://github.com/tauri-apps/tauri/issues/6541#issue-1638944060
*/
export async function setDatasetValue(
field: WebdriverIO.Element,
property: string,
value: string
) {
await browser.execute(`arguments[0].dataset.${property} = "${value}"`, field)
}

62
flake.lock generated Normal file
View File

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

70
flake.nix Normal file
View File

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

View File

@ -1,6 +1,6 @@
{ {
"name": "untitled-app", "name": "untitled-app",
"version": "0.21.9", "version": "0.22.3",
"private": true, "private": true,
"dependencies": { "dependencies": {
"@codemirror/autocomplete": "^6.16.0", "@codemirror/autocomplete": "^6.16.0",
@ -10,7 +10,7 @@
"@fortawesome/react-fontawesome": "^0.2.0", "@fortawesome/react-fontawesome": "^0.2.0",
"@headlessui/react": "^1.7.19", "@headlessui/react": "^1.7.19",
"@headlessui/tailwindcss": "^0.2.0", "@headlessui/tailwindcss": "^0.2.0",
"@kittycad/lib": "^0.0.63", "@kittycad/lib": "^0.0.67",
"@lezer/javascript": "^1.4.9", "@lezer/javascript": "^1.4.9",
"@open-rpc/client-js": "^1.8.1", "@open-rpc/client-js": "^1.8.1",
"@react-hook/resize-observer": "^2.0.1", "@react-hook/resize-observer": "^2.0.1",
@ -95,7 +95,8 @@
"lint": "eslint --fix src", "lint": "eslint --fix src",
"bump-jsons": "echo \"$(jq --arg v \"$VERSION\" '.version=$v' package.json --indent 2)\" > package.json && echo \"$(jq --arg v \"$VERSION\" '.version=$v' src-tauri/tauri.conf.json --indent 2)\" > src-tauri/tauri.conf.json", "bump-jsons": "echo \"$(jq --arg v \"$VERSION\" '.version=$v' package.json --indent 2)\" > package.json && echo \"$(jq --arg v \"$VERSION\" '.version=$v' src-tauri/tauri.conf.json --indent 2)\" > src-tauri/tauri.conf.json",
"postinstall": "yarn xstate:typegen", "postinstall": "yarn xstate:typegen",
"xstate:typegen": "yarn xstate typegen \"src/**/*.ts?(x)\"" "xstate:typegen": "yarn xstate typegen \"src/**/*.ts?(x)\"",
"make:dev": "make dev"
}, },
"prettier": { "prettier": {
"trailingComma": "es5", "trailingComma": "es5",

View File

@ -12,12 +12,12 @@ import { defineConfig, devices } from '@playwright/test'
export default defineConfig({ export default defineConfig({
testDir: './e2e/playwright', testDir: './e2e/playwright',
/* Run tests in files in parallel */ /* Run tests in files in parallel */
fullyParallel: true, fullyParallel: false,
/* Fail the build on CI if you accidentally left test.only in the source code. */ /* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI, forbidOnly: !!process.env.CI,
/* Retry on CI only */ /* Retry on CI only */
retries: process.env.CI ? 3 : 0, retries: process.env.CI ? 3 : 0,
/* Opt out of parallel tests on CI. */ /* Different amount of parallelism on CI and local. */
workers: process.env.CI ? 1 : 1, workers: process.env.CI ? 1 : 1,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */ /* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: 'html', reporter: 'html',
@ -34,7 +34,14 @@ export default defineConfig({
projects: [ projects: [
{ {
name: 'Google Chrome', name: 'Google Chrome',
use: { ...devices['Desktop Chrome'], channel: 'chrome' }, // or 'chrome-beta' use: {
...devices['Desktop Chrome'],
channel: 'chrome',
contextOptions: {
/* Chromium is the only one with these permission types */
permissions: ['clipboard-write', 'clipboard-read'],
},
}, // or 'chrome-beta'
}, },
{ {
name: 'webkit', name: 'webkit',
@ -72,7 +79,7 @@ export default defineConfig({
/* Run your local dev server before starting the tests */ /* Run your local dev server before starting the tests */
webServer: { webServer: {
command: 'yarn serve', command: 'yarn start',
// url: 'http://127.0.0.1:3000', // url: 'http://127.0.0.1:3000',
reuseExistingServer: !process.env.CI, reuseExistingServer: !process.env.CI,
}, },

1504
src-tauri/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -267,7 +267,15 @@ async fn login(app: tauri::AppHandle, host: &str) -> Result<String, InvokeError>
let e2e_tauri_enabled = env::var("E2E_TAURI_ENABLED").is_ok(); let e2e_tauri_enabled = env::var("E2E_TAURI_ENABLED").is_ok();
if e2e_tauri_enabled { if e2e_tauri_enabled {
log::warn!("E2E_TAURI_ENABLED is set, won't open {} externally", auth_uri.secret()); log::warn!("E2E_TAURI_ENABLED is set, won't open {} externally", auth_uri.secret());
tokio::fs::write("/tmp/kittycad_user_code", details.user_code().secret()) let mut temp = String::from("/tmp");
// Overwrite with Windows variable
match env::var("TEMP") {
Ok(val) => temp = val,
Err(_e) => println!("Fallback to default /tmp"),
}
let path = Path::new(&temp).join("kittycad_user_code");
println!("Writing to {}", path.to_string_lossy());
tokio::fs::write(path, details.user_code().secret())
.await .await
.map_err(|e| InvokeError::from_anyhow(e.into()))?; .map_err(|e| InvokeError::from_anyhow(e.into()))?;
} else { } else {

View File

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

View File

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

View File

@ -12,6 +12,8 @@ import SignIn from './routes/SignIn'
import { Auth } from './Auth' import { Auth } from './Auth'
import { isTauri } from './lib/isTauri' import { isTauri } from './lib/isTauri'
import Home from './routes/Home' import Home from './routes/Home'
import { NetworkContext } from './hooks/useNetworkContext'
import { useNetworkStatus } from './hooks/useNetworkStatus'
import makeUrlPathRelative from './lib/makeUrlPathRelative' import makeUrlPathRelative from './lib/makeUrlPathRelative'
import DownloadAppBanner from 'components/DownloadAppBanner' import DownloadAppBanner from 'components/DownloadAppBanner'
import { WasmErrBanner } from 'components/WasmErrBanner' import { WasmErrBanner } from 'components/WasmErrBanner'
@ -155,5 +157,11 @@ const router = createBrowserRouter([
* @returns RouterProvider * @returns RouterProvider
*/ */
export const Router = () => { export const Router = () => {
return <RouterProvider router={router} /> const networkStatus = useNetworkStatus()
return (
<NetworkContext.Provider value={networkStatus}>
<RouterProvider router={router} />
</NetworkContext.Provider>
)
} }

View File

@ -3,13 +3,11 @@ import { isCursorInSketchCommandRange } from 'lang/util'
import { engineCommandManager, kclManager } from 'lib/singletons' import { engineCommandManager, kclManager } from 'lib/singletons'
import { useModelingContext } from 'hooks/useModelingContext' import { useModelingContext } from 'hooks/useModelingContext'
import { useCommandsContext } from 'hooks/useCommandsContext' import { useCommandsContext } from 'hooks/useCommandsContext'
import { useNetworkContext } from 'hooks/useNetworkContext'
import { NetworkHealthState } from 'hooks/useNetworkStatus'
import { ActionButton } from 'components/ActionButton' import { ActionButton } from 'components/ActionButton'
import { isSingleCursorInPipe } from 'lang/queryAst' import { isSingleCursorInPipe } from 'lang/queryAst'
import { useKclContext } from 'lang/KclProvider' import { useKclContext } from 'lang/KclProvider'
import {
NetworkHealthState,
useNetworkStatus,
} from 'components/NetworkHealthIndicator'
import { useStore } from 'useStore' import { useStore } from 'useStore'
import { ActionButtonDropdown } from 'components/ActionButtonDropdown' import { ActionButtonDropdown } from 'components/ActionButtonDropdown'
import { useHotkeys } from 'react-hotkeys-hook' import { useHotkeys } from 'react-hotkeys-hook'
@ -38,14 +36,16 @@ export function Toolbar({
}, [engineCommandManager.artifactMap, context.selectionRanges]) }, [engineCommandManager.artifactMap, context.selectionRanges])
const toolbarButtonsRef = useRef<HTMLUListElement>(null) const toolbarButtonsRef = useRef<HTMLUListElement>(null)
const { overallState } = useNetworkContext()
const { overallState } = useNetworkStatus()
const { isExecuting } = useKclContext() const { isExecuting } = useKclContext()
const { isStreamReady } = useStore((s) => ({ const { isStreamReady } = useStore((s) => ({
isStreamReady: s.isStreamReady, isStreamReady: s.isStreamReady,
})) }))
const disableAllButtons = const disableAllButtons =
overallState !== NetworkHealthState.Ok || isExecuting || !isStreamReady (overallState !== NetworkHealthState.Ok &&
overallState !== NetworkHealthState.Weak) ||
isExecuting ||
!isStreamReady
useHotkeys( useHotkeys(
'l', 'l',

View File

@ -48,12 +48,14 @@ export type ReactCameraProperties =
type: 'perspective' type: 'perspective'
fov?: number fov?: number
position: [number, number, number] position: [number, number, number]
target: [number, number, number]
quaternion: [number, number, number, number] quaternion: [number, number, number, number]
} }
| { | {
type: 'orthographic' type: 'orthographic'
zoom?: number zoom?: number
position: [number, number, number] position: [number, number, number]
target: [number, number, number]
quaternion: [number, number, number, number] quaternion: [number, number, number, number]
} }
@ -172,41 +174,6 @@ export class CameraControls {
} }
} }
throttledUpdateEngineFov = throttle(
(vals: {
position: Vector3
quaternion: Quaternion
zoom: number
fov: number
target: Vector3
}) => {
const cmd: EngineCommand = {
type: 'modeling_cmd_req',
cmd_id: uuidv4(),
cmd: {
type: 'default_camera_perspective_settings',
...convertThreeCamValuesToEngineCam({
...vals,
isPerspective: true,
}),
fov_y: vals.fov,
...calculateNearFarFromFOV(vals.fov),
},
}
this.engineCommandManager.sendSceneCommand(cmd)
this.lastPerspectiveCmd = cmd
this.lastPerspectiveCmdTime = Date.now()
if (this.lastPerspectiveCmdTimeoutId !== null) {
clearTimeout(this.lastPerspectiveCmdTimeoutId)
}
this.lastPerspectiveCmdTimeoutId = setTimeout(
this.sendLastPerspectiveReliableChannel,
lastCmdDelay
) as any as number
},
1000 / 30
)
constructor( constructor(
isOrtho = false, isOrtho = false,
domElement: HTMLCanvasElement, domElement: HTMLCanvasElement,
@ -442,7 +409,7 @@ export class CameraControls {
this.handleEnd() this.handleEnd()
return return
} }
this.throttledEngCmd({ this.engineCommandManager.sendSceneCommand({
type: 'modeling_cmd_req', type: 'modeling_cmd_req',
cmd: { cmd: {
type: 'default_camera_zoom', type: 'default_camera_zoom',
@ -454,11 +421,11 @@ export class CameraControls {
return return
} }
const isTrackpad = Math.abs(event.deltaY) <= 1 || event.deltaY % 1 === 0 // Else "clientToEngine" (Sketch Mode) or forceUpdate
const zoomSpeed = isTrackpad ? 0.02 : 0.1 // Reduced zoom speed for trackpad // From onMouseMove zoom handling which seems to be really smooth
this.pendingZoom = this.pendingZoom ? this.pendingZoom : 1 this.pendingZoom = this.pendingZoom ? this.pendingZoom : 1
this.pendingZoom *= 1 + (event.deltaY > 0 ? zoomSpeed : -zoomSpeed) this.pendingZoom *= 1 + event.deltaY * 0.01
this.handleEnd() this.handleEnd()
} }
@ -532,26 +499,28 @@ export class CameraControls {
direction.normalize() direction.normalize()
this.camera.position.copy(this.target).addScaledVector(direction, distance) this.camera.position.copy(this.target).addScaledVector(direction, distance)
} }
usePerspectiveCamera = () => { usePerspectiveCamera = async () => {
this._usePerspectiveCamera() this._usePerspectiveCamera()
this.engineCommandManager.sendSceneCommand({ if (this.syncDirection === 'clientToEngine') {
type: 'modeling_cmd_req', await this.engineCommandManager.sendSceneCommand({
cmd_id: uuidv4(), type: 'modeling_cmd_req',
cmd: { cmd_id: uuidv4(),
type: 'default_camera_set_perspective', cmd: {
parameters: { type: 'default_camera_set_perspective',
fov_y: parameters: {
this.camera instanceof PerspectiveCamera ? this.camera.fov : 45, fov_y:
...calculateNearFarFromFOV(this.lastPerspectiveFov), this.camera instanceof PerspectiveCamera ? this.camera.fov : 45,
...calculateNearFarFromFOV(this.lastPerspectiveFov),
},
}, },
}, })
}) }
this.onCameraChange() this.onCameraChange()
this.update() this.update()
return this.camera return this.camera
} }
dollyZoom = (newFov: number) => { dollyZoom = async (newFov: number, splitEngineCalls = false) => {
if (!(this.camera instanceof PerspectiveCamera)) { if (!(this.camera instanceof PerspectiveCamera)) {
console.warn('Dolly zoom is only applicable to perspective cameras.') console.warn('Dolly zoom is only applicable to perspective cameras.')
return return
@ -602,13 +571,52 @@ export class CameraControls {
this.camera.near = z_near this.camera.near = z_near
this.camera.far = z_far this.camera.far = z_far
this.throttledUpdateEngineFov({ if (splitEngineCalls) {
fov: newFov, await this.engineCommandManager.sendSceneCommand({
position: newPosition, type: 'modeling_cmd_req',
quaternion: this.camera.quaternion, cmd_id: uuidv4(),
zoom: this.camera.zoom, cmd: {
target: this.target, type: 'default_camera_look_at',
}) ...convertThreeCamValuesToEngineCam({
isPerspective: true,
position: newPosition,
quaternion: this.camera.quaternion,
zoom: this.camera.zoom,
target: this.target,
}),
},
})
await this.engineCommandManager.sendSceneCommand({
type: 'modeling_cmd_req',
cmd_id: uuidv4(),
cmd: {
type: 'default_camera_set_perspective',
parameters: {
fov_y: newFov,
z_near: 0.01,
z_far: 1000,
},
},
})
} else {
await this.engineCommandManager.sendSceneCommand({
type: 'modeling_cmd_req',
cmd_id: uuidv4(),
cmd: {
type: 'default_camera_perspective_settings',
...convertThreeCamValuesToEngineCam({
isPerspective: true,
position: newPosition,
quaternion: this.camera.quaternion,
zoom: this.camera.zoom,
target: this.target,
}),
fov_y: newFov,
z_near: 0.01,
z_far: 1000,
},
})
}
} }
update = (forceUpdate = false) => { update = (forceUpdate = false) => {
@ -773,6 +781,75 @@ export class CameraControls {
}) })
} }
async updateCameraToAxis(
axis: 'x' | 'y' | 'z' | '-x' | '-y' | '-z'
): Promise<void> {
const distance = this.camera.position.distanceTo(this.target)
const vantage = this.target.clone()
let up = { x: 0, y: 0, z: 1 }
if (axis === 'x') {
vantage.x += distance
} else if (axis === 'y') {
vantage.y += distance
} else if (axis === 'z') {
vantage.z += distance
up = { x: -1, y: 0, z: 0 }
} else if (axis === '-x') {
vantage.x -= distance
} else if (axis === '-y') {
vantage.y -= distance
} else if (axis === '-z') {
vantage.z -= distance
up = { x: -1, y: 0, z: 0 }
}
await this.engineCommandManager.sendSceneCommand({
type: 'modeling_cmd_req',
cmd_id: uuidv4(),
cmd: {
type: 'default_camera_look_at',
center: this.target,
vantage: vantage,
up: up,
},
})
await this.engineCommandManager.sendSceneCommand({
type: 'modeling_cmd_req',
cmd_id: uuidv4(),
cmd: {
type: 'default_camera_get_settings',
},
})
}
async resetCameraPosition(): Promise<void> {
await this.engineCommandManager.sendSceneCommand({
type: 'modeling_cmd_req',
cmd_id: uuidv4(),
cmd: {
type: 'default_camera_look_at',
center: this.target,
vantage: {
x: this.target.x,
y: this.target.y - 128,
z: this.target.z + 64,
},
up: { x: 0, y: 0, z: 1 },
},
})
await this.engineCommandManager.sendSceneCommand({
type: 'modeling_cmd_req',
cmd_id: uuidv4(),
cmd: {
type: 'zoom_to_fit',
object_ids: [], // leave empty to zoom to all objects
padding: 0.2, // padding around the objects
},
})
}
async tweenCameraToQuaternion( async tweenCameraToQuaternion(
targetQuaternion: Quaternion, targetQuaternion: Quaternion,
targetPosition = new Vector3(), targetPosition = new Vector3(),
@ -944,6 +1021,29 @@ export class CameraControls {
.onComplete(onComplete) .onComplete(onComplete)
.start() .start()
}) })
snapToPerspectiveBeforeHandingBackControlToEngine = async (
targetCamUp = new Vector3(0, 0, 1)
) => {
if (this.syncDirection === 'engineToClient') {
console.warn(
'animate To Perspective not design to work with engineToClient syncDirection.'
)
}
this.isFovAnimationInProgress = true
const targetFov = this.fovBeforeOrtho // Target FOV for perspective
this.lastPerspectiveFov = 4
let currentFov = 4
const initialCameraUp = this.camera.up.clone()
this.usePerspectiveCamera()
const tempVec = new Vector3()
currentFov = this.lastPerspectiveFov + (targetFov - this.lastPerspectiveFov)
const currentUp = tempVec.lerpVectors(initialCameraUp, targetCamUp, 1)
this.camera.up.copy(currentUp)
await this.dollyZoom(currentFov, true)
this.isFovAnimationInProgress = false
}
get reactCameraProperties(): ReactCameraProperties { get reactCameraProperties(): ReactCameraProperties {
return { return {
@ -957,6 +1057,11 @@ export class CameraControls {
roundOff(this.camera.position.y, 2), roundOff(this.camera.position.y, 2),
roundOff(this.camera.position.z, 2), roundOff(this.camera.position.z, 2),
], ],
target: [
roundOff(this.target.x, 2),
roundOff(this.target.y, 2),
roundOff(this.target.z, 2),
],
quaternion: [ quaternion: [
roundOff(this.camera.quaternion.x, 2), roundOff(this.camera.quaternion.x, 2),
roundOff(this.camera.quaternion.y, 2), roundOff(this.camera.quaternion.y, 2),
@ -1011,7 +1116,7 @@ function calculateNearFarFromFOV(fov: number) {
// const nearFarRatio = (fov - 3) / (45 - 3) // const nearFarRatio = (fov - 3) / (45 - 3)
// const z_near = 0.1 + nearFarRatio * (5 - 0.1) // const z_near = 0.1 + nearFarRatio * (5 - 0.1)
// const z_far = 1000 + nearFarRatio * (100000 - 1000) // const z_far = 1000 + nearFarRatio * (100000 - 1000)
return { z_near: 0.1, z_far: 1000 } return { z_near: 0.01, z_far: 1000 }
} }
function convertThreeCamValuesToEngineCam({ function convertThreeCamValuesToEngineCam({
@ -1030,11 +1135,6 @@ function convertThreeCamValuesToEngineCam({
// leaving for now since it's working but maybe revisit later // leaving for now since it's working but maybe revisit later
const euler = new Euler().setFromQuaternion(quaternion, 'XYZ') const euler = new Euler().setFromQuaternion(quaternion, 'XYZ')
const lookAtVector = new Vector3(0, 0, -1)
.applyEuler(euler)
.normalize()
.add(position)
const upVector = new Vector3(0, 1, 0).applyEuler(euler).normalize() const upVector = new Vector3(0, 1, 0).applyEuler(euler).normalize()
if (isPerspective) { if (isPerspective) {
return { return {
@ -1043,6 +1143,10 @@ function convertThreeCamValuesToEngineCam({
vantage: position, vantage: position,
} }
} }
const lookAtVector = new Vector3(0, 0, -1)
.applyEuler(euler)
.normalize()
.add(position)
const fudgeFactor2 = zoom * 0.9979224466814468 - 0.03473692325839295 const fudgeFactor2 = zoom * 0.9979224466814468 - 0.03473692325839295
const zoomFactor = (-ZOOM_MAGIC_NUMBER + fudgeFactor2) / zoom const zoomFactor = (-ZOOM_MAGIC_NUMBER + fudgeFactor2) / zoom
const direction = lookAtVector.clone().sub(position).normalize() const direction = lookAtVector.clone().sub(position).normalize()

View File

@ -136,6 +136,7 @@ export const ClientSideScene = ({
<div <div
ref={canvasRef} ref={canvasRef}
style={{ cursor: cursor }} style={{ cursor: cursor }}
data-testid="client-side-scene"
className={`absolute inset-0 h-full w-full transition-all duration-300 ${ className={`absolute inset-0 h-full w-full transition-all duration-300 ${
hideClient ? 'opacity-0' : 'opacity-100' hideClient ? 'opacity-0' : 'opacity-100'
} ${hideServer ? 'bg-chalkboard-10 dark:bg-chalkboard-100' : ''} ${ } ${hideServer ? 'bg-chalkboard-10 dark:bg-chalkboard-100' : ''} ${
@ -420,12 +421,16 @@ const SegmentMenu = ({
verticalPosition === 'top' ? 'bottom-full' : 'top-full' verticalPosition === 'top' ? 'bottom-full' : 'top-full'
} z-10 w-36 flex flex-col gap-1 divide-y divide-chalkboard-20 dark:divide-chalkboard-70 align-stretch px-0 py-1 bg-chalkboard-10 dark:bg-chalkboard-100 rounded-sm shadow-lg border border-solid border-chalkboard-20/50 dark:border-chalkboard-80/50`} } z-10 w-36 flex flex-col gap-1 divide-y divide-chalkboard-20 dark:divide-chalkboard-70 align-stretch px-0 py-1 bg-chalkboard-10 dark:bg-chalkboard-100 rounded-sm shadow-lg border border-solid border-chalkboard-20/50 dark:border-chalkboard-80/50`}
> >
{/* <button className="hover:bg-white/80 bg-white/50 rounded p-1 text-nowrap">
Remove segment constraints
</button> */}
<button <button
className="!border-transparent rounded-sm text-left p-1 text-nowrap" className="!border-transparent rounded-sm text-left p-1 text-nowrap"
// disabled={dependentSourceRanges.length > 0} onClick={() => {
send({ type: 'Constrain remove constraints', data: pathToNode })
}}
>
Remove constraints
</button>
<button
className="!border-transparent rounded-sm text-left p-1 text-nowrap"
title={ title={
dependentSourceRanges.length > 0 dependentSourceRanges.length > 0
? `At least ${dependentSourceRanges.length} segment rely on this segment's tag.` ? `At least ${dependentSourceRanges.length} segment rely on this segment's tag.`
@ -531,8 +536,7 @@ const ConstraintSymbol = ({
varNameMap[_type as LineInputsType]?.implicitConstraintDesc varNameMap[_type as LineInputsType]?.implicitConstraintDesc
const node = useMemo( const node = useMemo(
() => () => getNodeFromPath<Value>(kclManager.ast, pathToNode).node,
getNodeFromPath<Value>(parse(recast(kclManager.ast)), pathToNode).node,
[kclManager.ast, pathToNode] [kclManager.ast, pathToNode]
) )
const range: SourceRange = node ? [node.start, node.end] : [0, 0] const range: SourceRange = node ? [node.start, node.end] : [0, 0]
@ -696,6 +700,15 @@ export const CamDebugSettings = () => {
} }
}} }}
/> />
<div>
<button
onClick={() => {
sceneInfra.camControls.resetCameraPosition()
}}
>
Reset Camera Position
</button>
</div>
{camSettings.type === 'perspective' && ( {camSettings.type === 'perspective' && (
<input <input
type="range" type="range"
@ -813,6 +826,71 @@ export const CamDebugSettings = () => {
</li> </li>
</ul> </ul>
</div> </div>
<div>
target
<ul className="flex">
<li>
<span className="pl-2 pr-1">x:</span>
<input
type="number"
step={5}
data-testid="cam-x-target"
value={camSettings.target[0]}
className="text-black w-16"
onChange={(e) => {
sceneInfra.camControls.setCam({
...camSettings,
target: [
parseFloat(e.target.value),
camSettings.target[1],
camSettings.target[2],
],
})
}}
/>
</li>
<li>
<span className="pl-2 pr-1">y:</span>
<input
type="number"
step={5}
data-testid="cam-y-target"
value={camSettings.target[1]}
className="text-black w-16"
onChange={(e) => {
sceneInfra.camControls.setCam({
...camSettings,
target: [
camSettings.target[0],
parseFloat(e.target.value),
camSettings.target[2],
],
})
}}
/>
</li>
<li>
<span className="pl-2 pr-1">z:</span>
<input
type="number"
step={5}
data-testid="cam-z-target"
value={camSettings.target[2]}
className="text-black w-16"
onChange={(e) => {
sceneInfra.camControls.setCam({
...camSettings,
target: [
camSettings.target[0],
camSettings.target[1],
parseFloat(e.target.value),
],
})
}}
/>
</li>
</ul>
</div>
</div> </div>
) )
} }

View File

@ -32,9 +32,7 @@ import {
SKETCH_GROUP_SEGMENTS, SKETCH_GROUP_SEGMENTS,
SKETCH_LAYER, SKETCH_LAYER,
X_AXIS, X_AXIS,
XZ_PLANE,
Y_AXIS, Y_AXIS,
YZ_PLANE,
} from './sceneInfra' } from './sceneInfra'
import { isQuaternionVertical, quaternionFromUpNForward } from './helpers' import { isQuaternionVertical, quaternionFromUpNForward } from './helpers'
import { import {
@ -93,7 +91,10 @@ import { createGridHelper, orthoScale, perspScale } from './helpers'
import { Models } from '@kittycad/lib' import { Models } from '@kittycad/lib'
import { uuidv4 } from 'lib/utils' import { uuidv4 } from 'lib/utils'
import { SegmentOverlayPayload, SketchDetails } from 'machines/modelingMachine' import { SegmentOverlayPayload, SketchDetails } from 'machines/modelingMachine'
import { EngineCommandManager } from 'lang/std/engineConnection' import {
ArtifactMapCommand,
EngineCommandManager,
} from 'lang/std/engineConnection'
import { import {
getRectangleCallExpressions, getRectangleCallExpressions,
updateRectangleSketch, updateRectangleSketch,
@ -759,14 +760,6 @@ export class SceneEntities {
_ast = parse(recast(_ast)) _ast = parse(recast(_ast))
console.log('onClick', {
sketchInit: sketchInit,
_ast,
x,
y,
truncatedAst,
})
// Update the primary AST and unequip the rectangle tool // Update the primary AST and unequip the rectangle tool
await kclManager.executeAstMock(_ast) await kclManager.executeAstMock(_ast)
sceneInfra.modelingSend({ type: 'CancelSketch' }) sceneInfra.modelingSend({ type: 'CancelSketch' })
@ -1334,13 +1327,6 @@ export class SceneEntities {
to, to,
}) })
} }
async animateAfterSketch() {
// if (isReducedMotion()) {
// sceneInfra.camControls.usePerspectiveCamera()
// return
// }
await sceneInfra.camControls.animateToPerspective()
}
removeSketchGrid() { removeSketchGrid() {
if (this.axisGroup) this.scene.remove(this.axisGroup) if (this.axisGroup) this.scene.remove(this.axisGroup)
} }
@ -1404,87 +1390,135 @@ export class SceneEntities {
selected.material.color = defaultPlaneColor(type) selected.material.color = defaultPlaneColor(type)
}, },
onClick: async (args) => { onClick: async (args) => {
const checkExtrudeFaceClick = async (): Promise< const { streamDimensions } = useStore.getState()
['face' | 'plane' | 'other', string] const { entity_id, ...rest } = await sendSelectEventToEngine(
> => { args?.mouseEvent,
const { streamDimensions } = useStore.getState() document.getElementById('video-stream') as HTMLVideoElement,
const { entity_id } = await sendSelectEventToEngine( streamDimensions
args?.mouseEvent, )
document.getElementById('video-stream') as HTMLVideoElement, let _entity_id = entity_id
streamDimensions console.log('things', _entity_id, rest)
) if (!_entity_id) return
if (!entity_id) return ['other', ''] if (
if ( engineCommandManager.defaultPlanes?.xy === _entity_id ||
engineCommandManager.defaultPlanes?.xy === entity_id || engineCommandManager.defaultPlanes?.xz === _entity_id ||
engineCommandManager.defaultPlanes?.xz === entity_id || engineCommandManager.defaultPlanes?.yz === _entity_id ||
engineCommandManager.defaultPlanes?.yz === entity_id engineCommandManager.defaultPlanes?.negXy === _entity_id ||
) { engineCommandManager.defaultPlanes?.negXz === _entity_id ||
return ['plane', entity_id] engineCommandManager.defaultPlanes?.negYz === _entity_id
) {
const defaultPlaneStrMap: Record<string, DefaultPlaneStr> = {
[engineCommandManager.defaultPlanes.xy]: 'XY',
[engineCommandManager.defaultPlanes.xz]: 'XZ',
[engineCommandManager.defaultPlanes.yz]: 'YZ',
[engineCommandManager.defaultPlanes.negXy]: '-XY',
[engineCommandManager.defaultPlanes.negXz]: '-XZ',
[engineCommandManager.defaultPlanes.negYz]: '-YZ',
} }
const artifact = this.engineCommandManager.artifactMap[entity_id] // TODO can we get this information from rust land when it creates the default planes?
if (artifact?.commandType !== 'solid3d_get_extrusion_face_info') // maybe returned from make_default_planes (src/wasm-lib/src/wasm.rs)
return ['other', entity_id] let zAxis: [number, number, number] = [0, 0, 1]
let yAxis: [number, number, number] = [0, 1, 0]
const faceInfo = await getFaceDetails(entity_id) // get unit vector from camera position to target
if (!faceInfo?.origin || !faceInfo?.z_axis || !faceInfo?.y_axis) const camVector = sceneInfra.camControls.camera.position
return ['other', entity_id] .clone()
const { z_axis, y_axis, origin } = faceInfo .sub(sceneInfra.camControls.target)
const pathToNode = getNodePathFromSourceRange(
kclManager.ast, if (engineCommandManager.defaultPlanes?.xy === _entity_id) {
artifact.range console.log('XY')
) zAxis = [0, 0, 1]
yAxis = [0, 1, 0]
if (camVector.z < 0) {
zAxis = [0, 0, -1]
_entity_id = engineCommandManager.defaultPlanes?.negXy || ''
}
} else if (engineCommandManager.defaultPlanes?.yz === _entity_id) {
console.log('YZ')
zAxis = [1, 0, 0]
yAxis = [0, 0, 1]
if (camVector.x < 0) {
zAxis = [-1, 0, 0]
_entity_id = engineCommandManager.defaultPlanes?.negYz || ''
}
} else if (engineCommandManager.defaultPlanes?.xz === _entity_id) {
console.log('XZ')
zAxis = [0, 1, 0]
yAxis = [0, 0, 1]
_entity_id = engineCommandManager.defaultPlanes?.negXz || ''
if (camVector.y < 0) {
zAxis = [0, -1, 0]
_entity_id = engineCommandManager.defaultPlanes?.xz || ''
}
}
sceneInfra.modelingSend({ sceneInfra.modelingSend({
type: 'Select default plane', type: 'Select default plane',
data: { data: {
type: 'extrudeFace', type: 'defaultPlane',
zAxis: [z_axis.x, z_axis.y, z_axis.z], planeId: _entity_id,
yAxis: [y_axis.x, y_axis.y, y_axis.z], plane: defaultPlaneStrMap[_entity_id],
position: [origin.x, origin.y, origin.z].map( zAxis,
(num) => num / sceneInfra._baseUnitMultiplier yAxis,
) as [number, number, number],
extrudeSegmentPathToNode: pathToNode,
cap:
artifact?.additionalData?.type === 'cap'
? artifact.additionalData.info
: 'none',
faceId: entity_id,
}, },
}) })
return ['face', entity_id] return
} }
const artifact = this.engineCommandManager.artifactMap[_entity_id]
// If we clicked on an extrude wall, we climb up the parent Id
// to get the sketch profile's face ID. If we clicked on an endcap,
// we already have it.
const targetId =
'additionalData' in artifact &&
artifact.additionalData?.type === 'cap'
? _entity_id
: artifact.parentId
const faceResult = await checkExtrudeFaceClick() // tsc cannot infer that target can have extrusions
console.log('faceResult', faceResult) // from the commandType (why?) so we need to cast it
if (faceResult[0] === 'face') return const target = this.engineCommandManager.artifactMap?.[
targetId || ''
] as ArtifactMapCommand & { extrusions?: string[] }
// TODO: We get the first extrusion command ID,
// which is fine while backend systems only support one extrusion.
// but we need to more robustly handle resolving to the correct extrusion
// if there are multiple.
const extrusions =
this.engineCommandManager.artifactMap?.[target?.extrusions?.[0] || '']
if (artifact?.commandType !== 'solid3d_get_extrusion_face_info') return
const faceInfo = await getFaceDetails(_entity_id)
if (!faceInfo?.origin || !faceInfo?.z_axis || !faceInfo?.y_axis) return
const { z_axis, y_axis, origin } = faceInfo
const sketchPathToNode = getNodePathFromSourceRange(
kclManager.ast,
artifact.range
)
const extrudePathToNode = extrusions?.range
? getNodePathFromSourceRange(kclManager.ast, extrusions.range)
: []
if (!args || !args.intersects?.[0]) return
if (args.mouseEvent.which !== 1) return
const { intersects } = args
const type = intersects?.[0].object.name || ''
const posNorm = Number(intersects?.[0]?.normal?.z) > 0
let planeString: DefaultPlaneStr = posNorm ? 'XY' : '-XY'
let zAxis: [number, number, number] = posNorm ? [0, 0, 1] : [0, 0, -1]
let yAxis: [number, number, number] = [0, 1, 0]
if (type === YZ_PLANE) {
planeString = posNorm ? 'YZ' : '-YZ'
zAxis = posNorm ? [1, 0, 0] : [-1, 0, 0]
yAxis = [0, 0, 1]
} else if (type === XZ_PLANE) {
planeString = posNorm ? '-XZ' : 'XZ'
zAxis = posNorm ? [0, 1, 0] : [0, -1, 0]
yAxis = [0, 0, 1]
}
sceneInfra.modelingSend({ sceneInfra.modelingSend({
type: 'Select default plane', type: 'Select default plane',
data: { data: {
type: 'defaultPlane', type: 'extrudeFace',
plane: planeString, zAxis: [z_axis.x, z_axis.y, z_axis.z],
zAxis, yAxis: [y_axis.x, y_axis.y, y_axis.z],
yAxis, position: [origin.x, origin.y, origin.z].map(
planeId: faceResult[1], (num) => num / sceneInfra._baseUnitMultiplier
) as [number, number, number],
sketchPathToNode,
extrudePathToNode,
cap:
artifact?.additionalData?.type === 'cap'
? artifact.additionalData.info
: 'none',
faceId: _entity_id,
}, },
}) })
return
}, },
}) })
} }

View File

@ -39,6 +39,7 @@ export function ActionButtonDropdown({
onClick={item.onClick} onClick={item.onClick}
className="block px-3 py-1 hover:bg-primary/10 dark:hover:bg-chalkboard-80 border-0 m-0 text-sm w-full rounded-none text-left disabled:!bg-transparent dark:disabled:text-chalkboard-60" className="block px-3 py-1 hover:bg-primary/10 dark:hover:bg-chalkboard-80 border-0 m-0 text-sm w-full rounded-none text-left disabled:!bg-transparent dark:disabled:text-chalkboard-60"
disabled={item.disabled} disabled={item.disabled}
data-testid={item.label}
> >
<span className="capitalize">{item.label}</span> <span className="capitalize">{item.label}</span>
{item.shortcut && ( {item.shortcut && (

View File

@ -214,13 +214,17 @@ export const CreateNewVariable = ({
}) => { }) => {
return ( return (
<> <>
<label htmlFor="create-new-variable" className="block mt-3 font-mono"> <label
htmlFor="create-new-variable"
className="block mt-3 font-mono text-chalkboard-90"
>
Create new variable Create new variable
</label> </label>
<div className="mt-1 flex gap-2 items-center"> <div className="mt-1 flex gap-2 items-center">
{showCheckbox && ( {showCheckbox && (
<input <input
type="checkbox" type="checkbox"
data-testid="create-new-variable-checkbox"
checked={shouldCreateVariable} checked={shouldCreateVariable}
onChange={(e) => { onChange={(e) => {
setShouldCreateVariable(e.target.checked) setShouldCreateVariable(e.target.checked)

View File

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

View File

@ -7,10 +7,8 @@ import {
getSelectionType, getSelectionType,
getSelectionTypeDisplayText, getSelectionTypeDisplayText,
} from 'lib/selections' } from 'lib/selections'
import { kclManager } from 'lib/singletons'
import { modelingMachine } from 'machines/modelingMachine' import { modelingMachine } from 'machines/modelingMachine'
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { useHotkeys } from 'react-hotkeys-hook'
import { StateFrom } from 'xstate' import { StateFrom } from 'xstate'
const selectionSelector = (snapshot: StateFrom<typeof modelingMachine>) => const selectionSelector = (snapshot: StateFrom<typeof modelingMachine>) =>
@ -41,24 +39,10 @@ function CommandBarSelectionInput({
canSubmitSelectionArg(selectionsByType, arg) canSubmitSelectionArg(selectionsByType, arg)
) )
useHotkeys('tab', () => onSubmit(selection), {
enableOnFormTags: true,
enableOnContentEditable: true,
keyup: true,
})
useEffect(() => { useEffect(() => {
inputRef.current?.focus() inputRef.current?.focus()
}, [selection, inputRef]) }, [selection, inputRef])
// Exit engine's edit mode when this input step is active,
// and re-enter it when it's not.
// In future the engine's edit mode will go away and this will be handled differently.
useEffect(() => {
kclManager.exitEditMode()
return () => kclManager.defaultSelectionFilter()
}, [])
// Fast-forward through this arg if it's marked as skippable // Fast-forward through this arg if it's marked as skippable
// and we have a valid selection already // and we have a valid selection already
useEffect(() => { useEffect(() => {

View File

@ -0,0 +1,199 @@
import toast from 'react-hot-toast'
import { ActionIcon, ActionIconProps } from './ActionIcon'
import { RefObject, useEffect, useMemo, useRef, useState } from 'react'
import { useHotkeys } from 'react-hotkeys-hook'
import { Dialog } from '@headlessui/react'
interface ContextMenuProps
extends Omit<React.HTMLAttributes<HTMLUListElement>, 'children'> {
items?: React.ReactElement[]
menuTargetElement?: RefObject<HTMLElement>
}
const DefaultContextMenuItems = [
<ContextMenuItemRefresh />,
<ContextMenuItemCopy />,
// add more default context menu items here
]
export function ContextMenu({
items = DefaultContextMenuItems,
menuTargetElement,
className,
...props
}: ContextMenuProps) {
const dialogRef = useRef<HTMLDivElement>(null)
const [open, setOpen] = useState(false)
const [windowSize, setWindowSize] = useState({
width: globalThis?.window?.innerWidth,
height: globalThis?.window?.innerHeight,
})
const [position, setPosition] = useState({ x: 0, y: 0 })
useHotkeys('esc', () => setOpen(false), {
enabled: open,
})
const dialogPositionStyle = useMemo(() => {
if (!dialogRef.current)
return {
top: 0,
left: 0,
right: 'auto',
bottom: 'auto',
}
return {
top:
position.y + dialogRef.current.clientHeight > windowSize.height
? 'auto'
: position.y,
left:
position.x + dialogRef.current.clientWidth > windowSize.width
? 'auto'
: position.x,
right:
position.x + dialogRef.current.clientWidth > windowSize.width
? windowSize.width - position.x
: 'auto',
bottom:
position.y + dialogRef.current.clientHeight > windowSize.height
? windowSize.height - position.y
: 'auto',
}
}, [position, windowSize, dialogRef.current])
// Listen for window resize to update context menu position
useEffect(() => {
const handleResize = () => {
setWindowSize({
width: globalThis?.window?.innerWidth,
height: globalThis?.window?.innerHeight,
})
}
globalThis?.window?.addEventListener('resize', handleResize)
return () => {
globalThis?.window?.removeEventListener('resize', handleResize)
}
}, [])
// Add context menu listener to target once mounted
useEffect(() => {
const handleContextMenu = (e: MouseEvent) => {
console.log('context menu', e)
e.preventDefault()
setPosition({ x: e.x, y: e.y })
setOpen(true)
}
menuTargetElement?.current?.addEventListener(
'contextmenu',
handleContextMenu
)
return () => {
menuTargetElement?.current?.removeEventListener(
'contextmenu',
handleContextMenu
)
}
}, [menuTargetElement?.current])
return (
<Dialog open={open} onClose={() => setOpen(false)}>
<div
className="fixed inset-0 z-50 w-screen h-screen"
onContextMenu={(e) => e.preventDefault()}
>
<Dialog.Backdrop className="fixed z-10 inset-0" />
<Dialog.Panel
ref={dialogRef}
className={`w-48 fixed bg-chalkboard-10 dark:bg-chalkboard-90
border border-solid border-chalkboard-10 dark:border-chalkboard-90 rounded
shadow-lg backdrop:fixed backdrop:inset-0 backdrop:bg-primary ${className}`}
style={{
...dialogPositionStyle,
...props.style,
}}
>
<ul
{...props}
className="relative flex flex-col gap-0.5 items-stretch content-stretch"
onClick={() => setOpen(false)}
>
{...items}
</ul>
</Dialog.Panel>
</div>
</Dialog>
)
}
export function ContextMenuDivider() {
return <hr className="border-chalkboard-20 dark:border-chalkboard-80" />
}
interface ContextMenuItemProps {
children: React.ReactNode
icon?: ActionIconProps['icon']
onClick?: () => void
hotkey?: string
}
export function ContextMenuItem({
children,
icon,
onClick,
hotkey,
}: ContextMenuItemProps) {
return (
<button
className="flex items-center gap-2 py-1 px-2 cursor-pointer hover:bg-chalkboard-20 dark:hover:bg-chalkboard-80 border-none text-left"
onClick={onClick}
>
{icon && <ActionIcon icon={icon} bgClassName="!bg-transparent" />}
<div className="flex-1">{children}</div>
{hotkey && (
<kbd className="px-1.5 py-0.5 rounded bg-primary/10 text-primary dark:bg-chalkboard-80 dark:text-chalkboard-40">
{hotkey}
</kbd>
)}
</button>
)
}
export function ContextMenuItemRefresh() {
return (
<ContextMenuItem
icon="arrowRotateRight"
onClick={() => globalThis?.window?.location.reload()}
>
Refresh
</ContextMenuItem>
)
}
interface ContextMenuItemCopyProps {
toBeCopiedContent?: string
toBeCopiedLabel?: string
}
export function ContextMenuItemCopy({
toBeCopiedContent = globalThis.window?.getSelection()?.toString(),
toBeCopiedLabel = 'selection',
}: ContextMenuItemCopyProps) {
return (
<ContextMenuItem
icon="clipboardPlus"
onClick={() => {
if (toBeCopiedContent) {
globalThis?.navigator?.clipboard
.writeText(toBeCopiedContent)
.then(() => toast.success(`Copied ${toBeCopiedLabel} to clipboard`))
.catch(() =>
toast.error(`Failed to copy ${toBeCopiedLabel} to clipboard`)
)
}
}}
>
Copy
</ContextMenuItem>
)
}

View File

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

View File

@ -18,6 +18,8 @@ import { useLspContext } from './LspProvider'
import useHotkeyWrapper from 'lib/hotkeyWrapper' import useHotkeyWrapper from 'lib/hotkeyWrapper'
import { useModelingContext } from 'hooks/useModelingContext' import { useModelingContext } from 'hooks/useModelingContext'
import { DeleteConfirmationDialog } from './ProjectCard/DeleteProjectDialog' import { DeleteConfirmationDialog } from './ProjectCard/DeleteProjectDialog'
import { ContextMenu, ContextMenuItem } from './ContextMenu'
import usePlatform from 'hooks/usePlatform'
function getIndentationCSS(level: number) { function getIndentationCSS(level: number) {
return `calc(1rem * ${level + 1})` return `calc(1rem * ${level + 1})`
@ -125,6 +127,7 @@ const FileTreeItem = ({
const navigate = useNavigate() const navigate = useNavigate()
const [isConfirmingDelete, setIsConfirmingDelete] = useState(false) const [isConfirmingDelete, setIsConfirmingDelete] = useState(false)
const isCurrentFile = fileOrDir.path === currentFile?.path const isCurrentFile = fileOrDir.path === currentFile?.path
const itemRef = useRef(null)
const isRenaming = fileContext.itemsBeingRenamed.includes(fileOrDir.path) const isRenaming = fileContext.itemsBeingRenamed.includes(fileOrDir.path)
const removeCurrentItemFromRenaming = useCallback( const removeCurrentItemFromRenaming = useCallback(
@ -185,7 +188,7 @@ const FileTreeItem = ({
} }
return ( return (
<> <div className="contents" ref={itemRef}>
{fileOrDir.children === undefined ? ( {fileOrDir.children === undefined ? (
<li <li
className={ className={
@ -321,7 +324,41 @@ const FileTreeItem = ({
setIsOpen={setIsConfirmingDelete} setIsOpen={setIsConfirmingDelete}
/> />
)} )}
</> <FileTreeContextMenu
itemRef={itemRef}
onRename={addCurrentItemToRenaming}
onDelete={() => setIsConfirmingDelete(true)}
/>
</div>
)
}
interface FileTreeContextMenuProps {
itemRef: React.RefObject<HTMLElement>
onRename: () => void
onDelete: () => void
}
function FileTreeContextMenu({
itemRef,
onRename,
onDelete,
}: FileTreeContextMenuProps) {
const platform = usePlatform()
const metaKey = platform === 'macos' ? '⌘' : 'Ctrl'
return (
<ContextMenu
menuTargetElement={itemRef}
items={[
<ContextMenuItem onClick={onRename} hotkey="Enter">
Rename
</ContextMenuItem>,
<ContextMenuItem onClick={onDelete} hotkey={metaKey + ' + Del'}>
Delete
</ContextMenuItem>,
]}
/>
) )
} }

View File

@ -1,5 +1,6 @@
import { SceneInfra } from 'clientSideScene/sceneInfra'
import { sceneInfra } from 'lib/singletons' import { sceneInfra } from 'lib/singletons'
import { useEffect, useRef } from 'react' import { MutableRefObject, useEffect, useRef } from 'react'
import { import {
WebGLRenderer, WebGLRenderer,
Scene, Scene,
@ -12,21 +13,52 @@ import {
Clock, Clock,
Quaternion, Quaternion,
ColorRepresentation, ColorRepresentation,
Vector2,
Raycaster,
Camera,
Intersection,
Object3D,
} from 'three' } from 'three'
import {
ContextMenu,
ContextMenuDivider,
ContextMenuItem,
ContextMenuItemRefresh,
} from './ContextMenu'
const CANVAS_SIZE = 80 const CANVAS_SIZE = 80
const FRUSTUM_SIZE = 0.5 const FRUSTUM_SIZE = 0.5
const AXIS_LENGTH = 0.35 const AXIS_LENGTH = 0.35
const AXIS_WIDTH = 0.02 const AXIS_WIDTH = 0.02
const AXIS_COLORS = { enum AxisColors {
x: '#fa6668', X = '#fa6668',
y: '#11eb6b', Y = '#11eb6b',
z: '#6689ef', Z = '#6689ef',
gray: '#c6c7c2', Gray = '#c6c7c2',
}
enum AxisNames {
X = 'x',
Y = 'y',
Z = 'z',
NEG_X = '-x',
NEG_Y = '-y',
NEG_Z = '-z',
}
const axisNamesSemantic: Record<AxisNames, string> = {
[AxisNames.X]: 'Right',
[AxisNames.Y]: 'Back',
[AxisNames.Z]: 'Top',
[AxisNames.NEG_X]: 'Left',
[AxisNames.NEG_Y]: 'Front',
[AxisNames.NEG_Z]: 'Bottom',
} }
export default function Gizmo() { export default function Gizmo() {
const wrapperRef = useRef<HTMLDivElement | null>(null)
const canvasRef = useRef<HTMLCanvasElement | null>(null) const canvasRef = useRef<HTMLCanvasElement | null>(null)
const raycasterIntersect = useRef<Intersection<Object3D> | null>(null)
const cameraPassiveUpdateTimer = useRef(0)
const raycasterPassiveUpdateTimer = useRef(0)
useEffect(() => { useEffect(() => {
if (!canvasRef.current) return if (!canvasRef.current) return
@ -41,35 +73,89 @@ export default function Gizmo() {
const { gizmoAxes, gizmoAxisHeads } = createGizmo() const { gizmoAxes, gizmoAxisHeads } = createGizmo()
scene.add(...gizmoAxes, ...gizmoAxisHeads) scene.add(...gizmoAxes, ...gizmoAxisHeads)
const raycaster = new Raycaster()
const { mouse, disposeMouseEvents } = initializeMouseEvents(
canvas,
raycasterIntersect,
sceneInfra
)
const raycasterObjects = [...gizmoAxisHeads]
const clock = new Clock() const clock = new Clock()
const clientCamera = sceneInfra.camControls.camera const clientCamera = sceneInfra.camControls.camera
let currentQuaternion = new Quaternion().copy(clientCamera.quaternion) let currentQuaternion = new Quaternion().copy(clientCamera.quaternion)
const animate = () => { const animate = () => {
requestAnimationFrame(animate) const delta = clock.getDelta()
updateCameraOrientation( updateCameraOrientation(
camera, camera,
currentQuaternion, currentQuaternion,
sceneInfra.camControls.camera.quaternion, sceneInfra.camControls.camera.quaternion,
clock.getDelta() delta,
cameraPassiveUpdateTimer
)
updateRayCaster(
raycasterObjects,
raycaster,
mouse,
camera,
raycasterIntersect,
delta,
raycasterPassiveUpdateTimer
) )
renderer.render(scene, camera) renderer.render(scene, camera)
requestAnimationFrame(animate)
} }
animate() animate()
return () => { return () => {
renderer.dispose() renderer.dispose()
disposeMouseEvents()
} }
}, []) }, [])
return ( return (
<div className="grid place-content-center rounded-full overflow-hidden border border-solid border-primary/50 pointer-events-none"> <>
<canvas ref={canvasRef} /> <div
</div> ref={wrapperRef}
aria-label="View orientation gizmo"
className="grid place-content-center rounded-full overflow-hidden border border-solid border-primary/50 pointer-events-auto"
>
<canvas ref={canvasRef} />
<ContextMenu
menuTargetElement={wrapperRef}
items={[
...Object.entries(axisNamesSemantic).map(
([axisName, axisSemantic]) => (
<ContextMenuItem
key={axisName}
onClick={() => {
sceneInfra.camControls.updateCameraToAxis(
axisName as AxisNames
)
}}
>
{axisSemantic} view
</ContextMenuItem>
)
),
<ContextMenuItem
onClick={() => {
sceneInfra.camControls.resetCameraPosition()
}}
>
Reset view
</ContextMenuItem>,
<ContextMenuDivider />,
<ContextMenuItemRefresh />,
]}
/>
</div>
</>
) )
} }
const createCamera = () => { const createCamera = (): OrthographicCamera => {
return new OrthographicCamera( return new OrthographicCamera(
-FRUSTUM_SIZE, -FRUSTUM_SIZE,
FRUSTUM_SIZE, FRUSTUM_SIZE,
@ -82,21 +168,21 @@ const createCamera = () => {
const createGizmo = () => { const createGizmo = () => {
const gizmoAxes = [ const gizmoAxes = [
createAxis(AXIS_LENGTH, AXIS_WIDTH, AXIS_COLORS.x, 0, 'z'), createAxis(AXIS_LENGTH, AXIS_WIDTH, AxisColors.X, 0, 'z'),
createAxis(AXIS_LENGTH, AXIS_WIDTH, AXIS_COLORS.y, Math.PI / 2, 'z'), createAxis(AXIS_LENGTH, AXIS_WIDTH, AxisColors.Y, Math.PI / 2, 'z'),
createAxis(AXIS_LENGTH, AXIS_WIDTH, AXIS_COLORS.z, -Math.PI / 2, 'y'), createAxis(AXIS_LENGTH, AXIS_WIDTH, AxisColors.Z, -Math.PI / 2, 'y'),
createAxis(AXIS_LENGTH, AXIS_WIDTH, AXIS_COLORS.gray, Math.PI, 'z'), createAxis(AXIS_LENGTH, AXIS_WIDTH, AxisColors.Gray, Math.PI, 'z'),
createAxis(AXIS_LENGTH, AXIS_WIDTH, AXIS_COLORS.gray, -Math.PI / 2, 'z'), createAxis(AXIS_LENGTH, AXIS_WIDTH, AxisColors.Gray, -Math.PI / 2, 'z'),
createAxis(AXIS_LENGTH, AXIS_WIDTH, AXIS_COLORS.gray, Math.PI / 2, 'y'), createAxis(AXIS_LENGTH, AXIS_WIDTH, AxisColors.Gray, Math.PI / 2, 'y'),
] ]
const gizmoAxisHeads = [ const gizmoAxisHeads = [
createAxisHead(AXIS_LENGTH, AXIS_COLORS.x, 0, 'z'), createAxisHead(AxisNames.X, AxisColors.X, [AXIS_LENGTH, 0, 0]),
createAxisHead(AXIS_LENGTH, AXIS_COLORS.y, Math.PI / 2, 'z'), createAxisHead(AxisNames.Y, AxisColors.Y, [0, AXIS_LENGTH, 0]),
createAxisHead(AXIS_LENGTH, AXIS_COLORS.z, -Math.PI / 2, 'y'), createAxisHead(AxisNames.Z, AxisColors.Z, [0, 0, AXIS_LENGTH]),
createAxisHead(AXIS_LENGTH, AXIS_COLORS.gray, Math.PI, 'z'), createAxisHead(AxisNames.NEG_X, AxisColors.Gray, [-AXIS_LENGTH, 0, 0]),
createAxisHead(AXIS_LENGTH, AXIS_COLORS.gray, -Math.PI / 2, 'z'), createAxisHead(AxisNames.NEG_Y, AxisColors.Gray, [0, -AXIS_LENGTH, 0]),
createAxisHead(AXIS_LENGTH, AXIS_COLORS.gray, Math.PI / 2, 'y'), createAxisHead(AxisNames.NEG_Z, AxisColors.Gray, [0, 0, -AXIS_LENGTH]),
] ]
return { gizmoAxes, gizmoAxisHeads } return { gizmoAxes, gizmoAxisHeads }
@ -108,12 +194,9 @@ const createAxis = (
color: ColorRepresentation, color: ColorRepresentation,
rotation = 0, rotation = 0,
axis = 'x' axis = 'x'
) => { ): Mesh => {
const geometry = new BoxGeometry(length, width, width).translate( const geometry = new BoxGeometry(length, width, width)
length / 2, geometry.translate(length / 2, 0, 0)
0,
0
)
const material = new MeshBasicMaterial({ color: new Color(color) }) const material = new MeshBasicMaterial({ color: new Color(color) })
const mesh = new Mesh(geometry, material) const mesh = new Mesh(geometry, material)
mesh.rotation[axis as 'x' | 'y' | 'z'] = rotation mesh.rotation[axis as 'x' | 'y' | 'z'] = rotation
@ -121,15 +204,17 @@ const createAxis = (
} }
const createAxisHead = ( const createAxisHead = (
length: number, name: AxisNames,
color: ColorRepresentation, color: ColorRepresentation,
rotation = 0, position: number[]
axis = 'x' ): Mesh => {
) => { const geometry = new SphereGeometry(0.065, 16, 8)
const geometry = new SphereGeometry(0.065, 16, 8).translate(length, 0, 0)
const material = new MeshBasicMaterial({ color: new Color(color) }) const material = new MeshBasicMaterial({ color: new Color(color) })
const mesh = new Mesh(geometry, material) const mesh = new Mesh(geometry, material)
mesh.rotation[axis as 'x' | 'y' | 'z'] = rotation
mesh.position.set(position[0], position[1], position[2])
mesh.updateMatrixWorld()
mesh.name = name
return mesh return mesh
} }
@ -137,10 +222,97 @@ const updateCameraOrientation = (
camera: OrthographicCamera, camera: OrthographicCamera,
currentQuaternion: Quaternion, currentQuaternion: Quaternion,
targetQuaternion: Quaternion, targetQuaternion: Quaternion,
deltaTime: number deltaTime: number,
cameraPassiveUpdateTimer: MutableRefObject<number>
) => { ) => {
const slerpFactor = 1 - Math.exp(-30 * deltaTime) cameraPassiveUpdateTimer.current += deltaTime
currentQuaternion.slerp(targetQuaternion, slerpFactor).normalize() if (
camera.position.set(0, 0, 1).applyQuaternion(currentQuaternion) !quaternionsEqual(currentQuaternion, targetQuaternion) ||
camera.quaternion.copy(currentQuaternion) cameraPassiveUpdateTimer.current >= 5
) {
const slerpFactor = 1 - Math.exp(-30 * deltaTime)
currentQuaternion.slerp(targetQuaternion, slerpFactor).normalize()
camera.position.set(0, 0, 1).applyQuaternion(currentQuaternion)
camera.quaternion.copy(currentQuaternion)
cameraPassiveUpdateTimer.current = 0
}
}
const quaternionsEqual = (
q1: Quaternion,
q2: Quaternion,
tolerance: number = 0.001
): boolean => {
return (
Math.abs(q1.x - q2.x) < tolerance &&
Math.abs(q1.y - q2.y) < tolerance &&
Math.abs(q1.z - q2.z) < tolerance &&
Math.abs(q1.w - q2.w) < tolerance
)
}
const initializeMouseEvents = (
canvas: HTMLCanvasElement,
raycasterIntersect: MutableRefObject<Intersection<Object3D> | null>,
sceneInfra: SceneInfra
): { mouse: Vector2; disposeMouseEvents: () => void } => {
const mouse = new Vector2()
mouse.x = 1 // fix initial mouse position issue
const handleMouseMove = (event: MouseEvent) => {
const { left, top, width, height } = canvas.getBoundingClientRect()
mouse.x = ((event.clientX - left) / width) * 2 - 1
mouse.y = ((event.clientY - top) / height) * -2 + 1
}
const handleClick = () => {
if (raycasterIntersect.current) {
const axisName = raycasterIntersect.current.object.name as AxisNames
sceneInfra.camControls.updateCameraToAxis(axisName)
}
}
window.addEventListener('mousemove', handleMouseMove)
window.addEventListener('click', handleClick)
const disposeMouseEvents = () => {
window.removeEventListener('mousemove', handleMouseMove)
window.removeEventListener('click', handleClick)
}
return { mouse, disposeMouseEvents }
}
const updateRayCaster = (
objects: Object3D[],
raycaster: Raycaster,
mouse: Vector2,
camera: Camera,
raycasterIntersect: MutableRefObject<Intersection<Object3D> | null>,
deltaTime: number,
raycasterPassiveUpdateTimer: MutableRefObject<number>
) => {
raycasterPassiveUpdateTimer.current += deltaTime
// check if mouse is outside the canvas bounds and stop raycaster
if (raycasterPassiveUpdateTimer.current < 2) {
if (mouse.x < -1 || mouse.x > 1 || mouse.y < -1 || mouse.y > 1) {
raycasterIntersect.current = null
return
}
}
raycaster.setFromCamera(mouse, camera)
const intersects = raycaster.intersectObjects(objects)
objects.forEach((object) => object.scale.set(1, 1, 1))
if (intersects.length) {
intersects[0].object.scale.set(1.5, 1.5, 1.5)
raycasterIntersect.current = intersects[0] // filter first object
} else {
raycasterIntersect.current = null
}
if (raycasterPassiveUpdateTimer.current > 2) {
raycasterPassiveUpdateTimer.current = 0
}
} }

View File

@ -81,6 +81,12 @@ export function HelpMenu(props: React.PropsWithChildren) {
> >
Release notes Release notes
</HelpMenuItem> </HelpMenuItem>
<HelpMenuItem
as="button"
onClick={() => navigate('settings?tab=keybindings')}
>
Keyboard shortcuts
</HelpMenuItem>
<HelpMenuItem <HelpMenuItem
as="button" as="button"
onClick={() => { onClick={() => {

View File

@ -1,14 +1,65 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import {
EngineConnectionStateType,
DisconnectingType,
EngineCommandManagerEvents,
EngineConnectionEvents,
ConnectionError,
CONNECTION_ERROR_TEXT,
} from '../lang/std/engineConnection'
import { engineCommandManager } from '../lib/singletons'
const Loading = ({ children }: React.PropsWithChildren) => { const Loading = ({ children }: React.PropsWithChildren) => {
const [hasLongLoadTime, setHasLongLoadTime] = useState(false) const [error, setError] = useState<ConnectionError>(ConnectionError.Unset)
useEffect(() => { useEffect(() => {
const onConnectionStateChange = ({ detail: state }: CustomEvent) => {
if (
(state.type !== EngineConnectionStateType.Disconnected ||
state.type !== EngineConnectionStateType.Disconnecting) &&
state.value?.type !== DisconnectingType.Error
)
return
setError(state.value.value.error)
}
const onEngineAvailable = ({ detail: engineConnection }: CustomEvent) => {
engineConnection.addEventListener(
EngineConnectionEvents.ConnectionStateChanged,
onConnectionStateChange as EventListener
)
}
engineCommandManager.addEventListener(
EngineCommandManagerEvents.EngineAvailable,
onEngineAvailable as EventListener
)
return () => {
engineCommandManager.removeEventListener(
EngineCommandManagerEvents.EngineAvailable,
onEngineAvailable as EventListener
)
engineCommandManager.engineConnection?.removeEventListener(
EngineConnectionEvents.ConnectionStateChanged,
onConnectionStateChange as EventListener
)
}
}, [])
useEffect(() => {
// Don't set long loading time if there's a more severe error
if (error > ConnectionError.LongLoadingTime) return
const timer = setTimeout(() => { const timer = setTimeout(() => {
setHasLongLoadTime(true) setError(ConnectionError.LongLoadingTime)
}, 4000) }, 4000)
return () => clearTimeout(timer) return () => clearTimeout(timer)
}, [setHasLongLoadTime]) }, [error, setError])
return ( return (
<div <div
className="body-bg flex flex-col items-center justify-center h-screen" className="body-bg flex flex-col items-center justify-center h-screen"
@ -29,10 +80,10 @@ const Loading = ({ children }: React.PropsWithChildren) => {
<p <p
className={ className={
'text-sm mt-4 text-primary/60 transition-opacity duration-500' + 'text-sm mt-4 text-primary/60 transition-opacity duration-500' +
(hasLongLoadTime ? ' opacity-100' : ' opacity-0') (error !== ConnectionError.Unset ? ' opacity-100' : ' opacity-0')
} }
> >
Loading is taking longer than expected. {CONNECTION_ERROR_TEXT[error]}
</p> </p>
</div> </div>
) )

View File

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

View File

@ -13,7 +13,6 @@ import { LanguageSupport } from '@codemirror/language'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { paths } from 'lib/paths' import { paths } from 'lib/paths'
import { FileEntry } from 'lib/types' import { FileEntry } from 'lib/types'
import { NetworkHealthState, useNetworkStatus } from './NetworkHealthIndicator'
import Worker from 'editor/plugins/lsp/worker.ts?worker' import Worker from 'editor/plugins/lsp/worker.ts?worker'
import { import {
LspWorkerEventType, LspWorkerEventType,
@ -23,6 +22,8 @@ import {
} from 'editor/plugins/lsp/types' } from 'editor/plugins/lsp/types'
import { wasmUrl } from 'lang/wasm' import { wasmUrl } from 'lang/wasm'
import { PROJECT_ENTRYPOINT } from 'lib/constants' import { PROJECT_ENTRYPOINT } from 'lib/constants'
import { useNetworkContext } from 'hooks/useNetworkContext'
import { NetworkHealthState } from 'hooks/useNetworkStatus'
function getWorkspaceFolders(): LSP.WorkspaceFolder[] { function getWorkspaceFolders(): LSP.WorkspaceFolder[] {
return [] return []
@ -86,7 +87,7 @@ export const LspProvider = ({ children }: { children: React.ReactNode }) => {
} = useSettingsAuthContext() } = useSettingsAuthContext()
const token = auth?.context.token const token = auth?.context.token
const navigate = useNavigate() const navigate = useNavigate()
const { overallState } = useNetworkStatus() const { overallState } = useNetworkContext()
const isNetworkOkay = overallState === NetworkHealthState.Ok const isNetworkOkay = overallState === NetworkHealthState.Ok
// 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

@ -11,7 +11,10 @@ import {
import { SetSelections, modelingMachine } from 'machines/modelingMachine' import { SetSelections, modelingMachine } from 'machines/modelingMachine'
import { useSetupEngineManager } from 'hooks/useSetupEngineManager' import { useSetupEngineManager } from 'hooks/useSetupEngineManager'
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext' import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
import { isCursorInSketchCommandRange } from 'lang/util' import {
isCursorInSketchCommandRange,
updatePathToNodeFromMap,
} from 'lang/util'
import { import {
kclManager, kclManager,
sceneInfra, sceneInfra,
@ -44,7 +47,6 @@ import {
TANGENTIAL_ARC_TO_SEGMENT, TANGENTIAL_ARC_TO_SEGMENT,
getParentGroup, getParentGroup,
getSketchOrientationDetails, getSketchOrientationDetails,
getSketchQuaternion,
} from 'clientSideScene/sceneEntities' } from 'clientSideScene/sceneEntities'
import { import {
moveValueIntoNewVariablePath, moveValueIntoNewVariablePath,
@ -73,6 +75,7 @@ import { useSearchParams } from 'react-router-dom'
import { letEngineAnimateAndSyncCamAfter } from 'clientSideScene/CameraControls' import { letEngineAnimateAndSyncCamAfter } from 'clientSideScene/CameraControls'
import { getVarNameModal } from 'hooks/useToolbarGuards' import { getVarNameModal } from 'hooks/useToolbarGuards'
import useHotkeyWrapper from 'lib/hotkeyWrapper' import useHotkeyWrapper from 'lib/hotkeyWrapper'
import { uuidv4 } from 'lib/utils'
type MachineContext<T extends AnyStateMachine> = { type MachineContext<T extends AnyStateMachine> = {
state: StateFrom<T> state: StateFrom<T>
@ -118,7 +121,24 @@ export const ModelingMachineProvider = ({
htmlRef, htmlRef,
token token
) )
useHotkeyWrapper(['meta + shift + .'], () => coreDump(coreDumpManager, true)) useHotkeyWrapper(['meta + shift + .'], () => {
console.warn('CoreDump: Initializing core dump')
toast.promise(
coreDump(coreDumpManager, true),
{
loading: 'Starting core dump...',
success: 'Core dump completed successfully',
error: 'Error while exporting core dump',
},
{
success: {
// Note: this extended duration is especially important for Playwright e2e testing
// default duration is 2000 - https://react-hot-toast.com/docs/toast#default-durations
duration: 6000,
},
}
)
})
// Settings machine setup // Settings machine setup
// const retrievedSettings = useRef( // const retrievedSettings = useRef(
@ -138,7 +158,41 @@ export const ModelingMachineProvider = ({
{ {
actions: { actions: {
'sketch exit execute': () => { 'sketch exit execute': () => {
kclManager.executeCode(true) ;(async () => {
await sceneInfra.camControls.snapToPerspectiveBeforeHandingBackControlToEngine()
sceneInfra.camControls.syncDirection = 'engineToClient'
const settings: Models['CameraSettings_type'] = (
await engineCommandManager.sendSceneCommand({
type: 'modeling_cmd_req',
cmd_id: uuidv4(),
cmd: {
type: 'default_camera_get_settings',
},
})
)?.data?.data?.settings
if (settings.up.z !== 1) {
// workaround for gimbal lock situation
await engineCommandManager.sendSceneCommand({
type: 'modeling_cmd_req',
cmd_id: uuidv4(),
cmd: {
type: 'default_camera_look_at',
center: settings.center,
vantage: {
...settings.pos,
y:
settings.pos.y +
(settings.center.z - settings.pos.z > 0 ? 2 : -2),
},
up: { x: 0, y: 0, z: 1 },
},
})
}
kclManager.executeCode(true)
})()
}, },
'Set mouse state': assign({ 'Set mouse state': assign({
mouseState: (_, event) => event.data, mouseState: (_, event) => event.data,
@ -150,7 +204,7 @@ export const ModelingMachineProvider = ({
]) ])
const pathToNode = parent?.userData?.pathToNode const pathToNode = parent?.userData?.pathToNode
const pathToNodeString = JSON.stringify(pathToNode) const pathToNodeString = JSON.stringify(pathToNode)
if (!parent || !pathToNode) return {} if (!parent || !pathToNode) return segmentHoverMap
if (segmentHoverMap[pathToNodeString] !== undefined) if (segmentHoverMap[pathToNodeString] !== undefined)
clearTimeout(segmentHoverMap[JSON.stringify(pathToNode)]) clearTimeout(segmentHoverMap[JSON.stringify(pathToNode)])
return { return {
@ -218,9 +272,8 @@ export const ModelingMachineProvider = ({
} }
: {} : {}
), ),
'Set selection': assign(({ selectionRanges }, event) => { 'Set selection': assign(({ selectionRanges, sketchDetails }, event) => {
if (event.type !== 'Set selection') return {} // this was needed for ts after adding 'Set selection' action to on done modal events const setSelections = event.data as SetSelections // this was needed for ts after adding 'Set selection' action to on done modal events
const setSelections = event.data
if (!editorManager.editorView) return {} if (!editorManager.editorView) return {}
const dispatchSelection = (selection?: EditorSelection) => { const dispatchSelection = (selection?: EditorSelection) => {
if (!selection) return // TODO less of hack for the below please if (!selection) return // TODO less of hack for the below please
@ -307,11 +360,29 @@ export const ModelingMachineProvider = ({
selectionRanges: selections, selectionRanges: selections,
} }
} }
if (setSelections.selectionType === 'completeSelection') {
editorManager.selectRange(setSelections.selection)
if (!sketchDetails)
return {
selectionRanges: setSelections.selection,
}
return {
selectionRanges: setSelections.selection,
sketchDetails: {
...sketchDetails,
sketchPathToNode:
setSelections.updatedPathToNode ||
sketchDetails?.sketchPathToNode ||
[],
},
}
}
return {} return {}
}), }),
'Engine export': (_, event) => { 'Engine export': async (_, event) => {
if (event.type !== 'Export' || TEST) return if (event.type !== 'Export' || TEST) return
console.log('exporting', event.data)
const format = { const format = {
...event.data, ...event.data,
} as Partial<Models['OutputFormat_type']> } as Partial<Models['OutputFormat_type']>
@ -355,9 +426,16 @@ export const ModelingMachineProvider = ({
format.selection = { type: 'default_scene' } format.selection = { type: 'default_scene' }
} }
exportFromEngine({ toast.promise(
format: format as Models['OutputFormat_type'], exportFromEngine({
}).catch((e) => toast.error('Error while exporting', e)) // TODO I think we need to throw the error from engineCommandManager format: format as Models['OutputFormat_type'],
}),
{
loading: 'Exporting...',
success: 'Exported successfully',
error: 'Error while exporting',
}
)
}, },
}, },
guards: { guards: {
@ -426,7 +504,8 @@ export const ModelingMachineProvider = ({
const { modifiedAst, pathToNode: pathToNewSketchNode } = const { modifiedAst, pathToNode: pathToNewSketchNode } =
sketchOnExtrudedFace( sketchOnExtrudedFace(
kclManager.ast, kclManager.ast,
data.extrudeSegmentPathToNode, data.sketchPathToNode,
data.extrudePathToNode,
kclManager.programMemory, kclManager.programMemory,
data.cap data.cap
) )
@ -436,7 +515,7 @@ export const ModelingMachineProvider = ({
engineCommandManager, engineCommandManager,
data.faceId data.faceId
) )
sceneInfra.camControls.syncDirection = 'clientToEngine'
return { return {
sketchPathToNode: pathToNewSketchNode, sketchPathToNode: pathToNewSketchNode,
zAxis: data.zAxis, zAxis: data.zAxis,
@ -450,8 +529,10 @@ export const ModelingMachineProvider = ({
) )
await kclManager.updateAst(modifiedAst, false) await kclManager.updateAst(modifiedAst, false)
sceneInfra.camControls.syncDirection = 'clientToEngine' sceneInfra.camControls.syncDirection = 'clientToEngine'
const quat = await getSketchQuaternion(pathToNode, data.zAxis) await letEngineAnimateAndSyncCamAfter(
await sceneInfra.camControls.tweenCameraToQuaternion(quat) engineCommandManager,
data.planeId
)
return { return {
sketchPathToNode: pathToNode, sketchPathToNode: pathToNode,
zAxis: data.zAxis, zAxis: data.zAxis,
@ -481,13 +562,26 @@ export const ModelingMachineProvider = ({
}, },
'Get horizontal info': async ({ 'Get horizontal info': async ({
selectionRanges, selectionRanges,
sketchDetails,
}): Promise<SetSelections> => { }): Promise<SetSelections> => {
const { modifiedAst, pathToNodeMap } = const { modifiedAst, pathToNodeMap } =
await applyConstraintHorzVertDistance({ await applyConstraintHorzVertDistance({
constraint: 'setHorzDistance', constraint: 'setHorzDistance',
selectionRanges, selectionRanges,
}) })
await kclManager.updateAst(modifiedAst, true) const _modifiedAst = parse(recast(modifiedAst))
if (!sketchDetails) throw new Error('No sketch details')
const updatedPathToNode = updatePathToNodeFromMap(
sketchDetails.sketchPathToNode,
pathToNodeMap
)
await sceneEntitiesManager.updateAstAndRejigSketch(
updatedPathToNode,
_modifiedAst,
sketchDetails.zAxis,
sketchDetails.yAxis,
sketchDetails.origin
)
return { return {
selectionType: 'completeSelection', selectionType: 'completeSelection',
selection: pathMapToSelections( selection: pathMapToSelections(
@ -495,17 +589,31 @@ export const ModelingMachineProvider = ({
selectionRanges, selectionRanges,
pathToNodeMap pathToNodeMap
), ),
updatedPathToNode,
} }
}, },
'Get vertical info': async ({ 'Get vertical info': async ({
selectionRanges, selectionRanges,
sketchDetails,
}): Promise<SetSelections> => { }): Promise<SetSelections> => {
const { modifiedAst, pathToNodeMap } = const { modifiedAst, pathToNodeMap } =
await applyConstraintHorzVertDistance({ await applyConstraintHorzVertDistance({
constraint: 'setVertDistance', constraint: 'setVertDistance',
selectionRanges, selectionRanges,
}) })
await kclManager.updateAst(modifiedAst, true) const _modifiedAst = parse(recast(modifiedAst))
if (!sketchDetails) throw new Error('No sketch details')
const updatedPathToNode = updatePathToNodeFromMap(
sketchDetails.sketchPathToNode,
pathToNodeMap
)
await sceneEntitiesManager.updateAstAndRejigSketch(
updatedPathToNode,
_modifiedAst,
sketchDetails.zAxis,
sketchDetails.yAxis,
sketchDetails.origin
)
return { return {
selectionType: 'completeSelection', selectionType: 'completeSelection',
selection: pathMapToSelections( selection: pathMapToSelections(
@ -513,10 +621,12 @@ export const ModelingMachineProvider = ({
selectionRanges, selectionRanges,
pathToNodeMap pathToNodeMap
), ),
updatedPathToNode,
} }
}, },
'Get angle info': async ({ 'Get angle info': async ({
selectionRanges, selectionRanges,
sketchDetails,
}): Promise<SetSelections> => { }): Promise<SetSelections> => {
const { modifiedAst, pathToNodeMap } = await (angleBetweenInfo({ const { modifiedAst, pathToNodeMap } = await (angleBetweenInfo({
selectionRanges, selectionRanges,
@ -528,22 +638,48 @@ export const ModelingMachineProvider = ({
selectionRanges, selectionRanges,
angleOrLength: 'setAngle', angleOrLength: 'setAngle',
})) }))
await kclManager.updateAst(modifiedAst, true) const _modifiedAst = parse(recast(modifiedAst))
if (!sketchDetails) throw new Error('No sketch details')
const updatedPathToNode = updatePathToNodeFromMap(
sketchDetails.sketchPathToNode,
pathToNodeMap
)
await sceneEntitiesManager.updateAstAndRejigSketch(
updatedPathToNode,
_modifiedAst,
sketchDetails.zAxis,
sketchDetails.yAxis,
sketchDetails.origin
)
return { return {
selectionType: 'completeSelection', selectionType: 'completeSelection',
selection: pathMapToSelections( selection: pathMapToSelections(
kclManager.ast, _modifiedAst,
selectionRanges, selectionRanges,
pathToNodeMap pathToNodeMap
), ),
updatedPathToNode,
} }
}, },
'Get length info': async ({ 'Get length info': async ({
selectionRanges, selectionRanges,
sketchDetails,
}): Promise<SetSelections> => { }): Promise<SetSelections> => {
const { modifiedAst, pathToNodeMap } = const { modifiedAst, pathToNodeMap } =
await applyConstraintAngleLength({ selectionRanges }) await applyConstraintAngleLength({ selectionRanges })
await kclManager.updateAst(modifiedAst, true) const _modifiedAst = parse(recast(modifiedAst))
if (!sketchDetails) throw new Error('No sketch details')
const updatedPathToNode = updatePathToNodeFromMap(
sketchDetails.sketchPathToNode,
pathToNodeMap
)
await sceneEntitiesManager.updateAstAndRejigSketch(
updatedPathToNode,
_modifiedAst,
sketchDetails.zAxis,
sketchDetails.yAxis,
sketchDetails.origin
)
return { return {
selectionType: 'completeSelection', selectionType: 'completeSelection',
selection: pathMapToSelections( selection: pathMapToSelections(
@ -551,17 +687,31 @@ export const ModelingMachineProvider = ({
selectionRanges, selectionRanges,
pathToNodeMap pathToNodeMap
), ),
updatedPathToNode,
} }
}, },
'Get perpendicular distance info': async ({ 'Get perpendicular distance info': async ({
selectionRanges, selectionRanges,
sketchDetails,
}): Promise<SetSelections> => { }): Promise<SetSelections> => {
const { modifiedAst, pathToNodeMap } = await applyConstraintIntersect( const { modifiedAst, pathToNodeMap } = await applyConstraintIntersect(
{ {
selectionRanges, selectionRanges,
} }
) )
await kclManager.updateAst(modifiedAst, true) const _modifiedAst = parse(recast(modifiedAst))
if (!sketchDetails) throw new Error('No sketch details')
const updatedPathToNode = updatePathToNodeFromMap(
sketchDetails.sketchPathToNode,
pathToNodeMap
)
await sceneEntitiesManager.updateAstAndRejigSketch(
updatedPathToNode,
_modifiedAst,
sketchDetails.zAxis,
sketchDetails.yAxis,
sketchDetails.origin
)
return { return {
selectionType: 'completeSelection', selectionType: 'completeSelection',
selection: pathMapToSelections( selection: pathMapToSelections(
@ -569,17 +719,31 @@ export const ModelingMachineProvider = ({
selectionRanges, selectionRanges,
pathToNodeMap pathToNodeMap
), ),
updatedPathToNode,
} }
}, },
'Get ABS X info': async ({ 'Get ABS X info': async ({
selectionRanges, selectionRanges,
sketchDetails,
}): Promise<SetSelections> => { }): Promise<SetSelections> => {
const { modifiedAst, pathToNodeMap } = const { modifiedAst, pathToNodeMap } =
await applyConstraintAbsDistance({ await applyConstraintAbsDistance({
constraint: 'xAbs', constraint: 'xAbs',
selectionRanges, selectionRanges,
}) })
await kclManager.updateAst(modifiedAst, true) const _modifiedAst = parse(recast(modifiedAst))
if (!sketchDetails) throw new Error('No sketch details')
const updatedPathToNode = updatePathToNodeFromMap(
sketchDetails.sketchPathToNode,
pathToNodeMap
)
await sceneEntitiesManager.updateAstAndRejigSketch(
updatedPathToNode,
_modifiedAst,
sketchDetails.zAxis,
sketchDetails.yAxis,
sketchDetails.origin
)
return { return {
selectionType: 'completeSelection', selectionType: 'completeSelection',
selection: pathMapToSelections( selection: pathMapToSelections(
@ -587,17 +751,31 @@ export const ModelingMachineProvider = ({
selectionRanges, selectionRanges,
pathToNodeMap pathToNodeMap
), ),
updatedPathToNode,
} }
}, },
'Get ABS Y info': async ({ 'Get ABS Y info': async ({
selectionRanges, selectionRanges,
sketchDetails,
}): Promise<SetSelections> => { }): Promise<SetSelections> => {
const { modifiedAst, pathToNodeMap } = const { modifiedAst, pathToNodeMap } =
await applyConstraintAbsDistance({ await applyConstraintAbsDistance({
constraint: 'yAbs', constraint: 'yAbs',
selectionRanges, selectionRanges,
}) })
await kclManager.updateAst(modifiedAst, true) const _modifiedAst = parse(recast(modifiedAst))
if (!sketchDetails) throw new Error('No sketch details')
const updatedPathToNode = updatePathToNodeFromMap(
sketchDetails.sketchPathToNode,
pathToNodeMap
)
await sceneEntitiesManager.updateAstAndRejigSketch(
updatedPathToNode,
_modifiedAst,
sketchDetails.zAxis,
sketchDetails.yAxis,
sketchDetails.origin
)
return { return {
selectionType: 'completeSelection', selectionType: 'completeSelection',
selection: pathMapToSelections( selection: pathMapToSelections(
@ -605,6 +783,7 @@ export const ModelingMachineProvider = ({
selectionRanges, selectionRanges,
pathToNodeMap pathToNodeMap
), ),
updatedPathToNode,
} }
}, },
'Get convert to variable info': async ({ sketchDetails }, { data }) => { 'Get convert to variable info': async ({ sketchDetails }, { data }) => {
@ -658,6 +837,19 @@ export const ModelingMachineProvider = ({
editorManager.selectionRanges = modelingState.context.selectionRanges editorManager.selectionRanges = modelingState.context.selectionRanges
}, [modelingState.context.selectionRanges]) }, [modelingState.context.selectionRanges])
useEffect(() => {
const offlineCallback = () => {
// If we are in sketch mode we need to exit it.
// TODO: how do i check if we are in a sketch mode, I only want to call
// this then.
modelingSend({ type: 'Cancel' })
}
window.addEventListener('offline', offlineCallback)
return () => {
window.removeEventListener('offline', offlineCallback)
}
}, [modelingSend])
useStateMachineCommands({ useStateMachineCommands({
machineId: 'modeling', machineId: 'modeling',
state: modelingState, state: modelingState,

View File

@ -2,7 +2,7 @@
@apply relative z-0 rounded-r max-w-full h-full flex-1; @apply relative z-0 rounded-r max-w-full h-full flex-1;
display: grid; display: grid;
grid-template-rows: auto 1fr; grid-template-rows: auto 1fr;
@apply bg-chalkboard-10/50 backdrop-blur-sm border border-chalkboard-20; @apply bg-chalkboard-10/50 focus-within:bg-chalkboard-10/90 backdrop-blur-sm border border-chalkboard-20;
scroll-margin-block-start: 41px; scroll-margin-block-start: 41px;
} }
@ -12,7 +12,7 @@
} }
:global(.dark) .panel { :global(.dark) .panel {
@apply bg-chalkboard-100/50 backdrop-blur-[3px] border-chalkboard-80; @apply bg-chalkboard-100/50 focus-within:bg-chalkboard-100/90 backdrop-blur-[3px] border-chalkboard-80;
} }
.header { .header {

View File

@ -46,7 +46,11 @@ export const ModelingPane = ({
data-testid={detailsTestId} data-testid={detailsTestId}
id={id} id={id}
className={ className={
pointerEventsCssClass + styles.panel + ' group ' + (className || '') 'group-focus-within:border-primary dark:group-focus-within:border-chalkboard-50 ' +
pointerEventsCssClass +
styles.panel +
' group ' +
(className || '')
} }
> >
<ModelingPaneHeader title={title} Menu={Menu} /> <ModelingPaneHeader title={title} Menu={Menu} />

View File

@ -123,70 +123,73 @@ function ModelingSidebarSection({
}, [showDebugPanel.current, togglePane, openPanes]) }, [showDebugPanel.current, togglePane, openPanes])
return ( return (
<Tab.Group <div className="group contents">
vertical <Tab.Group
selectedIndex={ vertical
currentPane === 'none' ? 0 : paneIds.indexOf(currentPane) + 1 selectedIndex={
} currentPane === 'none' ? 0 : paneIds.indexOf(currentPane) + 1
onChange={(index) => {
const newPane = index === 0 ? 'none' : paneIds[index - 1]
togglePane(newPane)
}}
>
<Tab.List
className={
'pointer-events-auto ' +
(alignButtons === 'start'
? 'justify-start self-start'
: 'justify-end self-end') +
(currentPane === 'none'
? ' rounded-r focus-within:!border-primary/50'
: ' border-r-0') +
' p-2 col-start-1 col-span-1 h-fit w-fit flex flex-col items-start gap-2 bg-chalkboard-10 border border-solid border-chalkboard-20 dark:bg-chalkboard-90 dark:border-chalkboard-80 ' +
(openPanes.length === 1 && currentPane === 'none' ? 'pr-0.5' : '')
} }
onChange={(index) => {
const newPane = index === 0 ? 'none' : paneIds[index - 1]
togglePane(newPane)
}}
> >
<Tab key="none" className="sr-only"> <Tab.List
No panes open className={
</Tab> 'pointer-events-auto ' +
{filteredPanes.map((pane) => ( (alignButtons === 'start'
<ModelingPaneButton ? 'justify-start self-start'
key={pane.id} : 'justify-end self-end') +
paneConfig={pane} (currentPane === 'none'
currentPane={currentPane} ? ' rounded-r focus-within:!border-primary/50'
togglePane={() => togglePane(pane.id)} : ' border-r-0') +
/> ' p-2 col-start-1 col-span-1 h-fit w-fit flex flex-col items-start gap-2 ' +
))} 'bg-chalkboard-10 border border-solid border-chalkboard-20 dark:bg-chalkboard-90 dark:border-chalkboard-80 group-focus-within:border-primary dark:group-focus-within:border-chalkboard-50 ' +
</Tab.List> (openPanes.length === 1 && currentPane === 'none' ? 'pr-0.5' : '')
<Tab.Panels }
as="article" >
className={ <Tab key="none" className="sr-only">
'col-start-2 col-span-1 ' + No panes open
(openPanes.length === 1 </Tab>
? currentPane !== 'none' {filteredPanes.map((pane) => (
? `row-start-1 row-end-3` <ModelingPaneButton
: `hidden` key={pane.id}
: ``) paneConfig={pane}
} currentPane={currentPane}
> togglePane={() => togglePane(pane.id)}
<Tab.Panel key="none" /> />
{filteredPanes.map((pane) => ( ))}
<Tab.Panel key={pane.id} className="h-full"> </Tab.List>
<ModelingPane <Tab.Panels
id={`${pane.id}-pane`} as="article"
title={pane.title} className={
Menu={pane.Menu} 'col-start-2 col-span-1 ' +
> (openPanes.length === 1
{pane.Content instanceof Function ? ( ? currentPane !== 'none'
<pane.Content /> ? `row-start-1 row-end-3`
) : ( : `hidden`
pane.Content : ``)
)} }
</ModelingPane> >
</Tab.Panel> <Tab.Panel key="none" />
))} {filteredPanes.map((pane) => (
</Tab.Panels> <Tab.Panel key={pane.id} className="h-full">
</Tab.Group> <ModelingPane
id={`${pane.id}-pane`}
title={pane.title}
Menu={pane.Menu}
>
{pane.Content instanceof Function ? (
<pane.Content />
) : (
pane.Content
)}
</ModelingPane>
</Tab.Panel>
))}
</Tab.Panels>
</Tab.Group>
</div>
) )
} }

View File

@ -5,8 +5,8 @@ import { CommandBarProvider } from './CommandBar/CommandBarProvider'
import { import {
NETWORK_HEALTH_TEXT, NETWORK_HEALTH_TEXT,
NetworkHealthIndicator, NetworkHealthIndicator,
NetworkHealthState,
} from './NetworkHealthIndicator' } from './NetworkHealthIndicator'
import { NetworkHealthState } from 'hooks/useNetworkStatus'
function TestWrap({ children }: { children: React.ReactNode }) { function TestWrap({ children }: { children: React.ReactNode }) {
// wrap in router and xState context // wrap in router and xState context
@ -19,6 +19,7 @@ function TestWrap({ children }: { children: React.ReactNode }) {
) )
} }
// Our Playwright tests for this are much more comprehensive.
describe('NetworkHealthIndicator tests', () => { describe('NetworkHealthIndicator tests', () => {
test('Renders the network indicator', () => { test('Renders the network indicator', () => {
render( render(
@ -29,21 +30,7 @@ describe('NetworkHealthIndicator tests', () => {
fireEvent.click(screen.getByTestId('network-toggle')) fireEvent.click(screen.getByTestId('network-toggle'))
expect(screen.getByTestId('network')).toHaveTextContent( // Starts as disconnected
NETWORK_HEALTH_TEXT[NetworkHealthState.Ok]
)
})
test('Responds to network changes', () => {
render(
<TestWrap>
<NetworkHealthIndicator />
</TestWrap>
)
fireEvent.offline(window)
fireEvent.click(screen.getByTestId('network-toggle'))
expect(screen.getByTestId('network')).toHaveTextContent( expect(screen.getByTestId('network')).toHaveTextContent(
NETWORK_HEALTH_TEXT[NetworkHealthState.Disconnected] NETWORK_HEALTH_TEXT[NetworkHealthState.Disconnected]
) )

View File

@ -1,26 +1,13 @@
import { Popover } from '@headlessui/react' import { Popover } from '@headlessui/react'
import { useEffect, useState } from 'react'
import { ActionIcon, ActionIconProps } from './ActionIcon' import { ActionIcon, ActionIconProps } from './ActionIcon'
import {
ConnectingType,
ConnectingTypeGroup,
DisconnectingType,
EngineConnectionState,
EngineConnectionStateType,
ErrorType,
initialConnectingTypeGroupState,
} from '../lang/std/engineConnection'
import { engineCommandManager } from '../lib/singletons'
import Tooltip from './Tooltip' import Tooltip from './Tooltip'
import { ConnectingTypeGroup } from '../lang/std/engineConnection'
export enum NetworkHealthState { import { useNetworkContext } from '../hooks/useNetworkContext'
Ok, import { NetworkHealthState } from '../hooks/useNetworkStatus'
Issue,
Disconnected,
}
export const NETWORK_HEALTH_TEXT: Record<NetworkHealthState, string> = { export const NETWORK_HEALTH_TEXT: Record<NetworkHealthState, string> = {
[NetworkHealthState.Ok]: 'Connected', [NetworkHealthState.Ok]: 'Connected',
[NetworkHealthState.Weak]: 'Weak',
[NetworkHealthState.Issue]: 'Problem', [NetworkHealthState.Issue]: 'Problem',
[NetworkHealthState.Disconnected]: 'Offline', [NetworkHealthState.Disconnected]: 'Offline',
} }
@ -61,6 +48,10 @@ const overallConnectionStateColor: Record<NetworkHealthState, IconColorConfig> =
icon: 'text-succeed-80 dark:text-succeed-10', icon: 'text-succeed-80 dark:text-succeed-10',
bg: 'bg-succeed-10/30 dark:bg-succeed-80/50', bg: 'bg-succeed-10/30 dark:bg-succeed-80/50',
}, },
[NetworkHealthState.Weak]: {
icon: 'text-warn-80 dark:text-warn-10',
bg: 'bg-warn-10 dark:bg-warn-80/80',
},
[NetworkHealthState.Issue]: { [NetworkHealthState.Issue]: {
icon: 'text-destroy-80 dark:text-destroy-10', icon: 'text-destroy-80 dark:text-destroy-10',
bg: 'bg-destroy-10 dark:bg-destroy-80/80', bg: 'bg-destroy-10 dark:bg-destroy-80/80',
@ -76,125 +67,11 @@ const overallConnectionStateIcon: Record<
ActionIconProps['icon'] ActionIconProps['icon']
> = { > = {
[NetworkHealthState.Ok]: 'network', [NetworkHealthState.Ok]: 'network',
[NetworkHealthState.Weak]: 'network',
[NetworkHealthState.Issue]: 'networkCrossedOut', [NetworkHealthState.Issue]: 'networkCrossedOut',
[NetworkHealthState.Disconnected]: 'networkCrossedOut', [NetworkHealthState.Disconnected]: 'networkCrossedOut',
} }
export function useNetworkStatus() {
const [steps, setSteps] = useState(initialConnectingTypeGroupState)
const [internetConnected, setInternetConnected] = useState<boolean>(true)
const [overallState, setOverallState] = useState<NetworkHealthState>(
NetworkHealthState.Ok
)
const [hasCopied, setHasCopied] = useState<boolean>(false)
const [error, setError] = useState<ErrorType | undefined>(undefined)
const issues: Record<ConnectingTypeGroup, boolean> = {
[ConnectingTypeGroup.WebSocket]: steps[ConnectingTypeGroup.WebSocket].some(
(a: [ConnectingType, boolean | undefined]) => a[1] === false
),
[ConnectingTypeGroup.ICE]: steps[ConnectingTypeGroup.ICE].some(
(a: [ConnectingType, boolean | undefined]) => a[1] === false
),
[ConnectingTypeGroup.WebRTC]: steps[ConnectingTypeGroup.WebRTC].some(
(a: [ConnectingType, boolean | undefined]) => a[1] === false
),
}
const hasIssues: boolean =
issues[ConnectingTypeGroup.WebSocket] ||
issues[ConnectingTypeGroup.ICE] ||
issues[ConnectingTypeGroup.WebRTC]
useEffect(() => {
setOverallState(
!internetConnected
? NetworkHealthState.Disconnected
: hasIssues
? NetworkHealthState.Issue
: NetworkHealthState.Ok
)
}, [hasIssues, internetConnected])
useEffect(() => {
const onlineCallback = () => {
setSteps(initialConnectingTypeGroupState)
setInternetConnected(true)
}
const offlineCallback = () => {
setInternetConnected(false)
}
window.addEventListener('online', onlineCallback)
window.addEventListener('offline', offlineCallback)
return () => {
window.removeEventListener('online', onlineCallback)
window.removeEventListener('offline', offlineCallback)
}
}, [])
useEffect(() => {
engineCommandManager.onConnectionStateChange(
(engineConnectionState: EngineConnectionState) => {
let hasSetAStep = false
if (
engineConnectionState.type === EngineConnectionStateType.Connecting
) {
const groups = Object.values(steps)
for (let group of groups) {
for (let step of group) {
if (step[0] !== engineConnectionState.value.type) continue
step[1] = true
hasSetAStep = true
}
}
}
if (
engineConnectionState.type === EngineConnectionStateType.Disconnecting
) {
const groups = Object.values(steps)
for (let group of groups) {
for (let step of group) {
if (
engineConnectionState.value.type === DisconnectingType.Error
) {
if (
engineConnectionState.value.value.lastConnectingValue
?.type === step[0]
) {
step[1] = false
hasSetAStep = true
}
}
}
if (engineConnectionState.value.type === DisconnectingType.Error) {
setError(engineConnectionState.value.value)
}
}
}
if (hasSetAStep) {
setSteps(steps)
}
}
)
}, [])
return {
hasIssues,
overallState,
internetConnected,
steps,
issues,
error,
setHasCopied,
hasCopied,
}
}
export const NetworkHealthIndicator = () => { export const NetworkHealthIndicator = () => {
const { const {
hasIssues, hasIssues,
@ -205,7 +82,7 @@ export const NetworkHealthIndicator = () => {
error, error,
setHasCopied, setHasCopied,
hasCopied, hasCopied,
} = useNetworkStatus() } = useNetworkContext()
return ( return (
<Popover className="relative"> <Popover className="relative">
@ -259,18 +136,18 @@ export const NetworkHealthIndicator = () => {
size="lg" size="lg"
icon={ icon={
hasIssueToIcon[ hasIssueToIcon[
issues[name as ConnectingTypeGroup].toString() String(issues[name as ConnectingTypeGroup])
] ]
} }
iconClassName={ iconClassName={
hasIssueToIconColors[ hasIssueToIconColors[
issues[name as ConnectingTypeGroup].toString() String(issues[name as ConnectingTypeGroup])
].icon ].icon
} }
bgClassName={ bgClassName={
'rounded-sm ' + 'rounded-sm ' +
hasIssueToIconColors[ hasIssueToIconColors[
issues[name as ConnectingTypeGroup].toString() String(issues[name as ConnectingTypeGroup])
].bg ].bg
} }
/> />

View File

@ -1,8 +1,7 @@
import { fireEvent, render, screen } from '@testing-library/react' import { render, screen } from '@testing-library/react'
import { BrowserRouter } from 'react-router-dom' import { BrowserRouter } from 'react-router-dom'
import ProjectSidebarMenu from './ProjectSidebarMenu' import ProjectSidebarMenu from './ProjectSidebarMenu'
import { SettingsAuthProviderJest } from './SettingsAuthProvider' import { SettingsAuthProviderJest } from './SettingsAuthProvider'
import { APP_NAME } from 'lib/constants'
import { CommandBarProvider } from './CommandBar/CommandBarProvider' import { CommandBarProvider } from './CommandBar/CommandBarProvider'
import { Project } from 'wasm-lib/kcl/bindings/Project' import { Project } from 'wasm-lib/kcl/bindings/Project'
@ -31,43 +30,6 @@ const projectWellFormed = {
} satisfies Project } satisfies Project
describe('ProjectSidebarMenu tests', () => { describe('ProjectSidebarMenu tests', () => {
test('Renders the project name', () => {
render(
<BrowserRouter>
<CommandBarProvider>
<SettingsAuthProviderJest>
<ProjectSidebarMenu project={projectWellFormed} enableMenu={true} />
</SettingsAuthProviderJest>
</CommandBarProvider>
</BrowserRouter>
)
fireEvent.click(screen.getByTestId('project-sidebar-toggle'))
expect(screen.getByTestId('projectName')).toHaveTextContent(
projectWellFormed.name
)
expect(screen.getByTestId('createdAt')).toHaveTextContent(
`Created ${now.toLocaleDateString()}`
)
})
test('Renders app name if given no project', () => {
render(
<BrowserRouter>
<CommandBarProvider>
<SettingsAuthProviderJest>
<ProjectSidebarMenu enableMenu={true} />
</SettingsAuthProviderJest>
</CommandBarProvider>
</BrowserRouter>
)
fireEvent.click(screen.getByTestId('project-sidebar-toggle'))
expect(screen.getByTestId('projectName')).toHaveTextContent(APP_NAME)
})
test('Disables popover menu by default', () => { test('Disables popover menu by default', () => {
render( render(
<BrowserRouter> <BrowserRouter>

View File

@ -5,7 +5,6 @@ import { paths } from 'lib/paths'
import { isTauri } from '../lib/isTauri' import { isTauri } from '../lib/isTauri'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { Fragment } from 'react' import { Fragment } from 'react'
import { FileTree } from './FileTree'
import { sep } from '@tauri-apps/api/path' import { sep } from '@tauri-apps/api/path'
import { Logo } from './Logo' import { Logo } from './Logo'
import { APP_NAME } from 'lib/constants' import { APP_NAME } from 'lib/constants'
@ -138,41 +137,7 @@ function ProjectMenuPopover({
> >
{({ close }) => ( {({ close }) => (
<> <>
<div className="flex items-center gap-4 px-4 py-3"> <div className="flex flex-col gap-2 p-4">
<div>
<p className="m-0 text-mono" data-testid="projectName">
{project?.name ? project.name : APP_NAME}
</p>
{project?.metadata && project.metadata.created && (
<p
className="m-0 text-xs text-chalkboard-80 dark:text-chalkboard-40"
data-testid="createdAt"
>
Created{' '}
{new Date(project.metadata.created).toLocaleDateString()}
</p>
)}
</div>
</div>
{isTauri() ? (
<FileTree
file={file}
className="overflow-hidden border-0 border-y border-chalkboard-30 dark:border-chalkboard-80"
onNavigateToFile={close}
/>
) : (
<div className="flex-1 p-4 text-sm overflow-hidden">
<p>
In the browser version of Modeling App you can only have one
part, and the code is stored in your browser's storage.
</p>
<p className="my-6">
Please save any code you want to keep more permanently, as
your browser's storage is not guaranteed to be permanent.
</p>
</div>
)}
<div className="flex flex-col gap-2 p-4 dark:bg-chalkboard-90">
<ActionButton <ActionButton
Element="button" Element="button"
iconStart={{ icon: 'exportFile', className: 'p-1' }} iconStart={{ icon: 'exportFile', className: 'p-1' }}

View File

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

View File

@ -0,0 +1,87 @@
import {
InteractionMapItem,
interactionMap,
sortInteractionMapByCategory,
} from 'lib/settings/initialKeybindings'
import { ForwardedRef, forwardRef } from 'react'
import { useLocation } from 'react-router-dom'
interface AllKeybindingsFieldsProps {}
export const AllKeybindingsFields = forwardRef(
(
props: AllKeybindingsFieldsProps,
scrollRef: ForwardedRef<HTMLDivElement>
) => {
// This is how we will get the interaction map from the context
// in the future whene franknoirot/editable-hotkeys is merged.
// const { state } = useInteractionMapContext()
return (
<div className="relative overflow-y-auto pb-16">
<div ref={scrollRef} className="flex flex-col gap-12">
{Object.entries(interactionMap)
.sort(sortInteractionMapByCategory)
.map(([category, categoryItems]) => (
<div className="flex flex-col gap-4 px-2 pr-4">
<h2
id={`category-${category}`}
className="text-xl mt-6 first-of-type:mt-0 capitalize font-bold"
>
{category}
</h2>
{categoryItems.map((item) => (
<KeybindingField
key={category + '-' + item.name}
category={category}
item={item}
/>
))}
</div>
))}
</div>
</div>
)
}
)
function KeybindingField({
item,
category,
}: {
item: InteractionMapItem
category: string
}) {
const location = useLocation()
return (
<div
className={
'flex gap-16 justify-between items-start py-1 px-2 -my-1 -mx-2 ' +
(location.hash === `#${item.name}`
? 'bg-primary/5 dark:bg-chalkboard-90'
: '')
}
id={item.name}
>
<div>
<h3 className="text-lg font-normal capitalize tracking-wide">
{item.title}
</h3>
<p className="text-xs text-chalkboard-60 dark:text-chalkboard-50">
{item.description}
</p>
</div>
<div className="flex-1 flex flex-wrap justify-end gap-3">
{item.sequence.split(' ').map((chord, i) => (
<kbd
key={`${category}-${item.name}-${chord}-${i}`}
className="py-0.5 px-1.5 rounded bg-primary/10 dark:bg-chalkboard-80"
>
{chord}
</kbd>
))}
</div>
</div>
)
}

View File

@ -0,0 +1,238 @@
import decamelize from 'decamelize'
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
import { Setting } from 'lib/settings/initialSettings'
import { SetEventTypes, SettingsLevel } from 'lib/settings/settingsTypes'
import {
shouldHideSetting,
shouldShowSettingInput,
} from 'lib/settings/settingsUtils'
import { Fragment } from 'react/jsx-runtime'
import { SettingsSection } from './SettingsSection'
import { useLocation, useNavigate } from 'react-router-dom'
import { isTauri } from 'lib/isTauri'
import { ActionButton } from 'components/ActionButton'
import { SettingsFieldInput } from './SettingsFieldInput'
import { getInitialDefaultDir, showInFolder } from 'lib/tauri'
import toast from 'react-hot-toast'
import { APP_VERSION } from 'routes/Settings'
import { createAndOpenNewProject, getSettingsFolderPaths } from 'lib/tauriFS'
import { paths } from 'lib/paths'
import { useDotDotSlash } from 'hooks/useDotDotSlash'
import { sep } from '@tauri-apps/api/path'
import { ForwardedRef, forwardRef } from 'react'
interface AllSettingsFieldsProps {
searchParamTab: SettingsLevel
isFileSettings: boolean
}
export const AllSettingsFields = forwardRef(
(
{ searchParamTab, isFileSettings }: AllSettingsFieldsProps,
scrollRef: ForwardedRef<HTMLDivElement>
) => {
const location = useLocation()
const navigate = useNavigate()
const dotDotSlash = useDotDotSlash()
const {
settings: { send, context },
} = useSettingsAuthContext()
const projectPath =
isFileSettings && isTauri()
? decodeURI(
location.pathname
.replace(paths.FILE + '/', '')
.replace(paths.SETTINGS, '')
.slice(0, decodeURI(location.pathname).lastIndexOf(sep()))
)
: undefined
function restartOnboarding() {
send({
type: `set.app.onboardingStatus`,
data: { level: 'user', value: '' },
})
if (isFileSettings) {
navigate(dotDotSlash(1) + paths.ONBOARDING.INDEX)
} else {
createAndOpenNewProject(navigate)
}
}
return (
<div className="relative overflow-y-auto">
<div ref={scrollRef} className="flex flex-col gap-4 px-2">
{Object.entries(context)
.filter(([_, categorySettings]) =>
// Filter out categories that don't have any non-hidden settings
Object.values(categorySettings).some(
(setting) => !shouldHideSetting(setting, searchParamTab)
)
)
.map(([category, categorySettings]) => (
<Fragment key={category}>
<h2
id={`category-${category}`}
className="text-xl mt-6 first-of-type:mt-0 capitalize font-bold"
>
{decamelize(category, { separator: ' ' })}
</h2>
{Object.entries(categorySettings)
.filter(
// Filter out settings that don't have a Component or inputType
// or are hidden on the current level or the current platform
(item: [string, Setting<unknown>]) =>
shouldShowSettingInput(item[1], searchParamTab)
)
.map(([settingName, s]) => {
const setting = s as Setting
const parentValue =
setting[setting.getParentLevel(searchParamTab)]
return (
<SettingsSection
title={decamelize(settingName, {
separator: ' ',
})}
id={settingName}
className={
location.hash === `#${settingName}`
? 'bg-primary/5 dark:bg-chalkboard-90'
: ''
}
key={`${category}-${settingName}-${searchParamTab}`}
description={setting.description}
settingHasChanged={
setting[searchParamTab] !== undefined &&
setting[searchParamTab] !==
setting.getFallback(searchParamTab)
}
parentLevel={setting.getParentLevel(searchParamTab)}
onFallback={() =>
send({
type: `set.${category}.${settingName}`,
data: {
level: searchParamTab,
value:
parentValue !== undefined
? parentValue
: setting.getFallback(searchParamTab),
},
} as SetEventTypes)
}
>
<SettingsFieldInput
category={category}
settingName={settingName}
settingsLevel={searchParamTab}
setting={setting}
/>
</SettingsSection>
)
})}
</Fragment>
))}
<h2 id="settings-resets" className="text-2xl mt-6 font-bold">
Resets
</h2>
<SettingsSection
title="Onboarding"
description="Replay the onboarding process"
>
<ActionButton
Element="button"
onClick={restartOnboarding}
iconStart={{
icon: 'refresh',
size: 'sm',
className: 'p-1',
}}
>
Replay Onboarding
</ActionButton>
</SettingsSection>
<SettingsSection
title="Reset settings"
description={`Restore settings to their default values. Your settings are saved in
${
isTauri()
? ' a file in the app data folder for your OS.'
: " your browser's local storage."
}
`}
>
<div className="flex flex-col items-start gap-4">
{isTauri() && (
<ActionButton
Element="button"
onClick={async () => {
const paths = await getSettingsFolderPaths(
projectPath ? decodeURIComponent(projectPath) : undefined
)
showInFolder(paths[searchParamTab])
}}
iconStart={{
icon: 'folder',
size: 'sm',
className: 'p-1',
}}
>
Show in folder
</ActionButton>
)}
<ActionButton
Element="button"
onClick={async () => {
const defaultDirectory = await getInitialDefaultDir()
send({
type: 'Reset settings',
defaultDirectory,
})
toast.success('Settings restored to default')
}}
iconStart={{
icon: 'refresh',
size: 'sm',
className: 'p-1 text-chalkboard-10',
bgClassName: 'bg-destroy-70',
}}
>
Restore default settings
</ActionButton>
</div>
</SettingsSection>
<h2 id="settings-about" className="text-2xl mt-6 font-bold">
About Modeling App
</h2>
<div className="text-sm mb-12">
<p>
{/* This uses a Vite plugin, set in vite.config.ts
to inject the version from package.json */}
App version {APP_VERSION}.{' '}
<a
href={`https://github.com/KittyCAD/modeling-app/releases/tag/v${APP_VERSION}`}
target="_blank"
rel="noopener noreferrer"
>
View release on GitHub
</a>
</p>
<p className="max-w-2xl mt-6">
Don't see the feature you want? Check to see if it's on{' '}
<a
href="https://github.com/KittyCAD/modeling-app/discussions"
target="_blank"
rel="noopener noreferrer"
>
our roadmap
</a>
, and start a discussion if you don't see it! Your feedback will
help us prioritize what to build next.
</p>
</div>
</div>
</div>
)
}
)

View File

@ -0,0 +1,35 @@
import {
interactionMap,
sortInteractionMapByCategory,
} from 'lib/settings/initialKeybindings'
interface KeybindingSectionsListProps {
scrollRef: React.RefObject<HTMLDivElement>
}
export function KeybindingsSectionsList({
scrollRef,
}: KeybindingSectionsListProps) {
return (
<div className="flex w-32 flex-col gap-3 pr-2 py-1 border-0 border-r border-r-chalkboard-20 dark:border-r-chalkboard-90">
{Object.entries(interactionMap)
.sort(sortInteractionMapByCategory)
.map(([category]) => (
<button
key={category}
onClick={() =>
scrollRef.current
?.querySelector(`#category-${category}`)
?.scrollIntoView({
block: 'center',
behavior: 'smooth',
})
}
className="capitalize text-left border-none px-1"
>
{category}
</button>
))}
</div>
)
}

View File

@ -3,11 +3,23 @@ import { CustomIcon } from 'components/CustomIcon'
import decamelize from 'decamelize' import decamelize from 'decamelize'
import Fuse from 'fuse.js' import Fuse from 'fuse.js'
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext' import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
import { interactionMap } from 'lib/settings/initialKeybindings'
import { Setting } from 'lib/settings/initialSettings' import { Setting } from 'lib/settings/initialSettings'
import { SettingsLevel } from 'lib/settings/settingsTypes'
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { useHotkeys } from 'react-hotkeys-hook' import { useHotkeys } from 'react-hotkeys-hook'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
type ExtendedSettingsLevel = SettingsLevel | 'keybindings'
export type SettingsSearchItem = {
name: string
displayName: string
description: string
category: string
level: ExtendedSettingsLevel
}
export function SettingsSearchBar() { export function SettingsSearchBar() {
const inputRef = useRef<HTMLInputElement>(null) const inputRef = useRef<HTMLInputElement>(null)
useHotkeys( useHotkeys(
@ -21,29 +33,40 @@ export function SettingsSearchBar() {
const navigate = useNavigate() const navigate = useNavigate()
const [query, setQuery] = useState('') const [query, setQuery] = useState('')
const { settings } = useSettingsAuthContext() const { settings } = useSettingsAuthContext()
const settingsAsSearchable = useMemo( const settingsAsSearchable: SettingsSearchItem[] = useMemo(
() => () => [
Object.entries(settings.state.context).flatMap( ...Object.entries(settings.state.context).flatMap(
([category, categorySettings]) => ([category, categorySettings]) =>
Object.entries(categorySettings).flatMap(([settingName, setting]) => { Object.entries(categorySettings).flatMap(([settingName, setting]) => {
const s = setting as Setting const s = setting as Setting
return ['project', 'user'] return (['project', 'user'] satisfies SettingsLevel[])
.filter((l) => s.hideOnLevel !== l) .filter((l) => s.hideOnLevel !== l)
.map((l) => ({ .map((l) => ({
category: decamelize(category, { separator: ' ' }), category: decamelize(category, { separator: ' ' }),
settingName: settingName, name: settingName,
settingNameDisplay: decamelize(settingName, { separator: ' ' }), description: s.description ?? '',
setting: s, displayName: decamelize(settingName, { separator: ' ' }),
level: l, level: l as ExtendedSettingsLevel,
})) }))
}) })
), ),
...Object.entries(interactionMap).flatMap(
([category, categoryKeybindings]) =>
categoryKeybindings.map((keybinding) => ({
name: keybinding.name,
displayName: keybinding.title,
description: keybinding.description,
category: category,
level: 'keybindings' as ExtendedSettingsLevel,
}))
),
],
[settings.state.context] [settings.state.context]
) )
const [searchResults, setSearchResults] = useState(settingsAsSearchable) const [searchResults, setSearchResults] = useState(settingsAsSearchable)
const fuse = new Fuse(settingsAsSearchable, { const fuse = new Fuse(settingsAsSearchable, {
keys: ['category', 'settingNameDisplay', 'setting.description'], keys: ['category', 'displayName', 'description'],
includeScore: true, includeScore: true,
}) })
@ -52,16 +75,8 @@ export function SettingsSearchBar() {
setSearchResults(query.length > 0 ? results : settingsAsSearchable) setSearchResults(query.length > 0 ? results : settingsAsSearchable)
}, [query]) }, [query])
function handleSelection({ function handleSelection({ level, name }: SettingsSearchItem) {
level, navigate(`?tab=${level}#${name}`)
settingName,
}: {
category: string
settingName: string
setting: Setting<unknown>
level: string
}) {
navigate(`?tab=${level}#${settingName}`)
} }
return ( return (
@ -87,18 +102,18 @@ export function SettingsSearchBar() {
<Combobox.Options className="absolute top-full mt-2 right-0 w-80 overflow-y-auto z-50 max-h-96 cursor-pointer bg-chalkboard-10 dark:bg-chalkboard-100 border border-solid border-primary dark:border-chalkboard-30 rounded"> <Combobox.Options className="absolute top-full mt-2 right-0 w-80 overflow-y-auto z-50 max-h-96 cursor-pointer bg-chalkboard-10 dark:bg-chalkboard-100 border border-solid border-primary dark:border-chalkboard-30 rounded">
{searchResults?.map((option) => ( {searchResults?.map((option) => (
<Combobox.Option <Combobox.Option
key={`${option.category}-${option.settingName}-${option.level}`} key={`${option.category}-${option.name}-${option.level}`}
value={option} value={option}
className="flex flex-col items-start gap-2 px-4 py-2 ui-active:bg-primary/10 dark:ui-active:bg-chalkboard-90" className="flex flex-col items-start gap-2 px-4 py-2 ui-active:bg-primary/10 dark:ui-active:bg-chalkboard-90"
> >
<p className="flex-grow text-base capitalize m-0 leading-none"> <p className="flex-grow text-base capitalize m-0 leading-none">
{option.level} ·{' '} {option.level} ·{' '}
{decamelize(option.category, { separator: ' ' })} ·{' '} {decamelize(option.category, { separator: ' ' })} ·{' '}
{option.settingNameDisplay} {option.displayName}
</p> </p>
{option.setting.description && ( {option.description && (
<p className="text-xs leading-tight text-chalkboard-70 dark:text-chalkboard-50"> <p className="text-xs leading-tight text-chalkboard-70 dark:text-chalkboard-50">
{option.setting.description} {option.description}
</p> </p>
)} )}
</Combobox.Option> </Combobox.Option>

View File

@ -0,0 +1,68 @@
import decamelize from 'decamelize'
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
import { Setting } from 'lib/settings/initialSettings'
import { SettingsLevel } from 'lib/settings/settingsTypes'
import { shouldHideSetting } from 'lib/settings/settingsUtils'
interface SettingsSectionsListProps {
searchParamTab: SettingsLevel
scrollRef: React.RefObject<HTMLDivElement>
}
export function SettingsSectionsList({
searchParamTab,
scrollRef,
}: SettingsSectionsListProps) {
const {
settings: { context },
} = useSettingsAuthContext()
return (
<div className="flex w-32 flex-col gap-3 pr-2 py-1 border-0 border-r border-r-chalkboard-20 dark:border-r-chalkboard-90">
{Object.entries(context)
.filter(([_, categorySettings]) =>
// Filter out categories that don't have any non-hidden settings
Object.values(categorySettings).some(
(setting: Setting) => !shouldHideSetting(setting, searchParamTab)
)
)
.map(([category]) => (
<button
key={category}
onClick={() =>
scrollRef.current
?.querySelector(`#category-${category}`)
?.scrollIntoView({
block: 'center',
behavior: 'smooth',
})
}
className="capitalize text-left border-none px-1"
>
{decamelize(category, { separator: ' ' })}
</button>
))}
<button
onClick={() =>
scrollRef.current?.querySelector(`#settings-resets`)?.scrollIntoView({
block: 'center',
behavior: 'smooth',
})
}
className="capitalize text-left border-none px-1"
>
Resets
</button>
<button
onClick={() =>
scrollRef.current?.querySelector(`#settings-about`)?.scrollIntoView({
block: 'center',
behavior: 'smooth',
})
}
className="capitalize text-left border-none px-1"
>
About
</button>
</div>
)
}

View File

@ -34,6 +34,15 @@ export function SettingsTabs({
)} )}
</RadioGroup.Option> </RadioGroup.Option>
)} )}
<RadioGroup.Option value="keybindings">
{({ checked }) => (
<SettingsTabButton
checked={checked}
icon="keyboard"
text="Keybindings"
/>
)}
</RadioGroup.Option>
</RadioGroup> </RadioGroup>
) )
} }

View File

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

View File

@ -4,8 +4,9 @@ import { getNormalisedCoordinates } from '../lib/utils'
import Loading from './Loading' import Loading from './Loading'
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext' import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
import { useModelingContext } from 'hooks/useModelingContext' import { useModelingContext } from 'hooks/useModelingContext'
import { useNetworkContext } from 'hooks/useNetworkContext'
import { NetworkHealthState } from 'hooks/useNetworkStatus'
import { ClientSideScene } from 'clientSideScene/ClientSideSceneComp' import { ClientSideScene } from 'clientSideScene/ClientSideSceneComp'
import { NetworkHealthState, useNetworkStatus } from './NetworkHealthIndicator'
import { butName } from 'lib/cameraControls' import { butName } from 'lib/cameraControls'
import { sendSelectEventToEngine } from 'lib/selections' import { sendSelectEventToEngine } from 'lib/selections'
@ -28,8 +29,43 @@ export const Stream = ({ className = '' }: { className?: string }) => {
})) }))
const { settings } = useSettingsAuthContext() const { settings } = useSettingsAuthContext()
const { state } = useModelingContext() const { state } = useModelingContext()
const { overallState } = useNetworkStatus() const { overallState } = useNetworkContext()
const isNetworkOkay = overallState === NetworkHealthState.Ok
const isNetworkOkay =
overallState === NetworkHealthState.Ok ||
overallState === NetworkHealthState.Weak
// Linux has a default behavior to paste text on middle mouse up
// This adds a listener to block that pasting if the click target
// is not a text input, so users can move in the 3D scene with
// middle mouse drag with a text input focused without pasting.
useEffect(() => {
const handlePaste = (e: ClipboardEvent) => {
const isHtmlElement = e.target && e.target instanceof HTMLElement
const isEditable =
(isHtmlElement && !('explicitOriginalTarget' in e)) ||
('explicitOriginalTarget' in e &&
((e.explicitOriginalTarget as HTMLElement).contentEditable ===
'true' ||
['INPUT', 'TEXTAREA'].some(
(tagName) =>
tagName === (e.explicitOriginalTarget as HTMLElement).tagName
)))
if (!isEditable) {
e.preventDefault()
e.stopPropagation()
e.stopImmediatePropagation()
}
}
globalThis?.window?.document?.addEventListener('paste', handlePaste, {
capture: true,
})
return () =>
globalThis?.window?.document?.removeEventListener('paste', handlePaste, {
capture: true,
})
}, [])
useEffect(() => { useEffect(() => {
if ( if (
@ -43,6 +79,7 @@ export const Stream = ({ className = '' }: { className?: string }) => {
}, [mediaStream]) }, [mediaStream])
const handleMouseDown: MouseEventHandler<HTMLDivElement> = (e) => { const handleMouseDown: MouseEventHandler<HTMLDivElement> = (e) => {
if (!isNetworkOkay) return
if (!videoRef.current) return if (!videoRef.current) return
if (state.matches('Sketch')) return if (state.matches('Sketch')) return
if (state.matches('Sketch no face')) return if (state.matches('Sketch no face')) return
@ -58,6 +95,7 @@ export const Stream = ({ className = '' }: { className?: string }) => {
} }
const handleMouseUp: MouseEventHandler<HTMLDivElement> = (e) => { const handleMouseUp: MouseEventHandler<HTMLDivElement> = (e) => {
if (!isNetworkOkay) return
if (!videoRef.current) return if (!videoRef.current) return
setButtonDownInStream(undefined) setButtonDownInStream(undefined)
if (state.matches('Sketch')) return if (state.matches('Sketch')) return
@ -72,6 +110,7 @@ export const Stream = ({ className = '' }: { className?: string }) => {
} }
const handleMouseMove: MouseEventHandler<HTMLVideoElement> = (e) => { const handleMouseMove: MouseEventHandler<HTMLVideoElement> = (e) => {
if (!isNetworkOkay) return
if (state.matches('Sketch')) return if (state.matches('Sketch')) return
if (state.matches('Sketch no face')) return if (state.matches('Sketch no face')) return
if (!clickCoords) return if (!clickCoords) return
@ -87,8 +126,8 @@ export const Stream = ({ className = '' }: { className?: string }) => {
return ( return (
<div <div
id="stream" className="absolute inset-0 z-0"
className={className} data-testid="stream"
onMouseUp={handleMouseUp} onMouseUp={handleMouseUp}
onMouseDown={handleMouseDown} onMouseDown={handleMouseDown}
onContextMenu={(e) => e.preventDefault()} onContextMenu={(e) => e.preventDefault()}
@ -103,7 +142,6 @@ export const Stream = ({ className = '' }: { className?: string }) => {
onMouseMoveCapture={handleMouseMove} onMouseMoveCapture={handleMouseMove}
className="w-full cursor-pointer h-full" className="w-full cursor-pointer h-full"
disablePictureInPicture disablePictureInPicture
style={{ transitionDuration: '200ms', transitionProperty: 'filter' }}
id="video-stream" id="video-stream"
/> />
<ClientSideScene <ClientSideScene
@ -112,7 +150,7 @@ export const Stream = ({ className = '' }: { className?: string }) => {
{!isNetworkOkay && !isLoading && ( {!isNetworkOkay && !isLoading && (
<div className="text-center absolute inset-0"> <div className="text-center absolute inset-0">
<Loading> <Loading>
<span data-testid="loading-stream">Stream disconnected</span> <span data-testid="loading-stream">Stream disconnected...</span>
</Loading> </Loading>
</div> </div>
)} )}

View File

@ -140,7 +140,11 @@ export async function applyConstraintIntersect({
value: valueUsedInTransform, value: valueUsedInTransform,
initialVariableName: 'offset', initialVariableName: 'offset',
}) })
if (segName === tagInfo?.tag && Number(value) === valueUsedInTransform) { if (
!variableName &&
segName === tagInfo?.tag &&
Number(value) === valueUsedInTransform
) {
return { return {
modifiedAst, modifiedAst,
pathToNodeMap, pathToNodeMap,
@ -169,6 +173,10 @@ export async function applyConstraintIntersect({
createVariableDeclaration(variableName, valueNode) createVariableDeclaration(variableName, valueNode)
) )
_modifiedAst.body = newBody _modifiedAst.body = newBody
Object.values(_pathToNodeMap).forEach((pathToNode) => {
const index = pathToNode.findIndex((a) => a[0] === 'body') + 1
pathToNode[index][0] = Number(pathToNode[index][0]) + 1
})
} }
return { return {
modifiedAst: _modifiedAst, modifiedAst: _modifiedAst,

View File

@ -1,6 +1,6 @@
import { toolTips } from '../../useStore' import { toolTips } from '../../useStore'
import { Selections } from 'lib/selections' import { Selection, Selections } from 'lib/selections'
import { Program, Value } from '../../lang/wasm' import { PathToNode, Program, Value } from '../../lang/wasm'
import { import {
getNodePathFromSourceRange, getNodePathFromSourceRange,
getNodeFromPath, getNodeFromPath,
@ -14,15 +14,30 @@ import { kclManager } from 'lib/singletons'
export function removeConstrainingValuesInfo({ export function removeConstrainingValuesInfo({
selectionRanges, selectionRanges,
pathToNodes,
}: { }: {
selectionRanges: Selections selectionRanges: Selections
pathToNodes?: Array<PathToNode>
}) { }) {
const paths = selectionRanges.codeBasedSelections.map(({ range }) => const paths =
getNodePathFromSourceRange(kclManager.ast, range) pathToNodes ||
) selectionRanges.codeBasedSelections.map(({ range }) =>
getNodePathFromSourceRange(kclManager.ast, range)
)
const nodes = paths.map( const nodes = paths.map(
(pathToNode) => getNodeFromPath<Value>(kclManager.ast, pathToNode).node (pathToNode) => getNodeFromPath<Value>(kclManager.ast, pathToNode).node
) )
const updatedSelectionRanges = pathToNodes
? {
otherSelections: [],
codeBasedSelections: nodes.map(
(node): Selection => ({
range: [node.start, node.end],
type: 'default',
})
),
}
: selectionRanges
const isAllTooltips = nodes.every( const isAllTooltips = nodes.every(
(node) => (node) =>
node?.type === 'CallExpression' && node?.type === 'CallExpression' &&
@ -31,31 +46,36 @@ export function removeConstrainingValuesInfo({
try { try {
const transforms = getRemoveConstraintsTransforms( const transforms = getRemoveConstraintsTransforms(
selectionRanges, updatedSelectionRanges,
kclManager.ast, kclManager.ast,
'removeConstrainingValues' 'removeConstrainingValues'
) )
const enabled = isAllTooltips && transforms.every(Boolean) const enabled = isAllTooltips && transforms.every(Boolean)
return { enabled, transforms } return { enabled, transforms, updatedSelectionRanges }
} catch (e) { } catch (e) {
console.error(e) console.error(e)
return { enabled: false, transforms: [] } return { enabled: false, transforms: [], updatedSelectionRanges }
} }
} }
export function applyRemoveConstrainingValues({ export function applyRemoveConstrainingValues({
selectionRanges, selectionRanges,
pathToNodes,
}: { }: {
selectionRanges: Selections selectionRanges: Selections
pathToNodes?: Array<PathToNode>
}): { }): {
modifiedAst: Program modifiedAst: Program
pathToNodeMap: PathToNodeMap pathToNodeMap: PathToNodeMap
} { } {
const { transforms } = removeConstrainingValuesInfo({ selectionRanges }) const { transforms, updatedSelectionRanges } = removeConstrainingValuesInfo({
selectionRanges,
pathToNodes,
})
return transformAstSketchLines({ return transformAstSketchLines({
ast: kclManager.ast, ast: kclManager.ast,
selectionRanges, selectionRanges: updatedSelectionRanges,
transformInfos: transforms, transformInfos: transforms,
programMemory: kclManager.programMemory, programMemory: kclManager.programMemory,
referenceSegName: '', referenceSegName: '',

View File

@ -120,6 +120,10 @@ export async function applyConstraintAbsDistance({
createVariableDeclaration(variableName, valueNode) createVariableDeclaration(variableName, valueNode)
) )
_modifiedAst.body = newBody _modifiedAst.body = newBody
Object.values(pathToNodeMap).forEach((pathToNode) => {
const index = pathToNode.findIndex((a) => a[0] === 'body') + 1
pathToNode[index][0] = Number(pathToNode[index][0]) + 1
})
} }
return { modifiedAst: _modifiedAst, pathToNodeMap } return { modifiedAst: _modifiedAst, pathToNodeMap }
} }

View File

@ -98,7 +98,11 @@ export async function applyConstraintAngleBetween({
value: valueUsedInTransform, value: valueUsedInTransform,
initialVariableName: 'angle', initialVariableName: 'angle',
} as any) } as any)
if (segName === tagInfo?.tag && Number(value) === valueUsedInTransform) { if (
segName === tagInfo?.tag &&
Number(value) === valueUsedInTransform &&
!variableName
) {
return { return {
modifiedAst, modifiedAst,
pathToNodeMap, pathToNodeMap,
@ -128,6 +132,10 @@ export async function applyConstraintAngleBetween({
createVariableDeclaration(variableName, valueNode) createVariableDeclaration(variableName, valueNode)
) )
_modifiedAst.body = newBody _modifiedAst.body = newBody
Object.values(_pathToNodeMap).forEach((pathToNode) => {
const index = pathToNode.findIndex((a) => a[0] === 'body') + 1
pathToNode[index][0] = Number(pathToNode[index][0]) + 1
})
} }
return { return {
modifiedAst: _modifiedAst, modifiedAst: _modifiedAst,

View File

@ -106,7 +106,11 @@ export async function applyConstraintHorzVertDistance({
value: valueUsedInTransform, value: valueUsedInTransform,
initialVariableName: constraint === 'setHorzDistance' ? 'xDis' : 'yDis', initialVariableName: constraint === 'setHorzDistance' ? 'xDis' : 'yDis',
} as any) } as any)
if (segName === tagInfo?.tag && Number(value) === valueUsedInTransform) { if (
!variableName &&
segName === tagInfo?.tag &&
Number(value) === valueUsedInTransform
) {
return { return {
modifiedAst, modifiedAst,
pathToNodeMap, pathToNodeMap,
@ -133,6 +137,10 @@ export async function applyConstraintHorzVertDistance({
createVariableDeclaration(variableName, valueNode) createVariableDeclaration(variableName, valueNode)
) )
_modifiedAst.body = newBody _modifiedAst.body = newBody
Object.values(pathToNodeMap).forEach((pathToNode) => {
const index = pathToNode.findIndex((a) => a[0] === 'body') + 1
pathToNode[index][0] = Number(pathToNode[index][0]) + 1
})
} }
return { return {
modifiedAst: _modifiedAst, modifiedAst: _modifiedAst,

View File

@ -138,13 +138,17 @@ export async function applyConstraintAngleLength({
createVariableDeclaration(variableName, valueNode) createVariableDeclaration(variableName, valueNode)
) )
_modifiedAst.body = newBody _modifiedAst.body = newBody
Object.values(pathToNodeMap).forEach((pathToNode) => {
const index = pathToNode.findIndex((a) => a[0] === 'body') + 1
pathToNode[index][0] = Number(pathToNode[index][0]) + 1
})
} }
return { return {
modifiedAst: _modifiedAst, modifiedAst: _modifiedAst,
pathToNodeMap, pathToNodeMap,
} }
} catch (e) { } catch (e) {
console.log('erorr', e) console.log('error', e)
throw e throw e
} }
} }

View File

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

View File

@ -39,7 +39,7 @@ const UserSidebarMenu = ({ user }: { user?: User }) => {
<Popover className="relative"> <Popover className="relative">
{user?.image && !imageLoadFailed ? ( {user?.image && !imageLoadFailed ? (
<Popover.Button <Popover.Button
className="border-0 rounded-full w-fit min-w-max p-0 group" className="relative border-0 rounded-full w-fit min-w-max p-0 group"
data-testid="user-sidebar-toggle" data-testid="user-sidebar-toggle"
> >
<div className="rounded-full border overflow-hidden"> <div className="rounded-full border overflow-hidden">
@ -51,6 +51,9 @@ const UserSidebarMenu = ({ user }: { user?: User }) => {
onError={() => setImageLoadFailed(true)} onError={() => setImageLoadFailed(true)}
/> />
</div> </div>
<Tooltip position="bottom-right" delay={1000}>
User menu
</Tooltip>
</Popover.Button> </Popover.Button>
) : ( ) : (
<ActionButton <ActionButton
@ -59,7 +62,7 @@ const UserSidebarMenu = ({ user }: { user?: User }) => {
className="border-transparent !px-0" className="border-transparent !px-0"
data-testid="user-sidebar-toggle" data-testid="user-sidebar-toggle"
> >
<Tooltip position="left" delay={1000}> <Tooltip position="bottom-right" delay={1000}>
User menu User menu
</Tooltip> </Tooltip>
</ActionButton> </ActionButton>

View File

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

View File

@ -474,19 +474,13 @@ const completionRequester = (client: LanguageServerClient) => {
} }
export const copilotPlugin = (options: LanguageServerOptions): Extension => { export const copilotPlugin = (options: LanguageServerOptions): Extension => {
let plugin: LanguageServerPlugin | null = null
return [ return [
documentUri.of(options.documentUri), documentUri.of(options.documentUri),
languageId.of('kcl'), languageId.of('kcl'),
workspaceFolders.of(options.workspaceFolders), workspaceFolders.of(options.workspaceFolders),
ViewPlugin.define( ViewPlugin.define(
(view) => (view) =>
(plugin = new LanguageServerPlugin( new LanguageServerPlugin(options.client, view, options.allowHTMLContent)
options.client,
view,
options.allowHTMLContent
))
), ),
completionDecoration, completionDecoration,
Prec.highest(completionPlugin(options.client)), Prec.highest(completionPlugin(options.client)),

View File

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

View File

@ -0,0 +1,25 @@
import { createContext, useContext } from 'react'
import {
ConnectingTypeGroup,
initialConnectingTypeGroupState,
} from '../lang/std/engineConnection'
import { NetworkStatus, NetworkHealthState } from './useNetworkStatus'
export const NetworkContext = createContext<NetworkStatus>({
hasIssues: undefined,
overallState: NetworkHealthState.Disconnected,
internetConnected: true,
steps: structuredClone(initialConnectingTypeGroupState),
issues: {
[ConnectingTypeGroup.WebSocket]: undefined,
[ConnectingTypeGroup.ICE]: undefined,
[ConnectingTypeGroup.WebRTC]: undefined,
},
error: undefined,
setHasCopied: (b: boolean) => {},
hasCopied: false,
pingPongHealth: undefined,
} as NetworkStatus)
export const useNetworkContext = () => {
return useContext(NetworkContext)
}

View File

@ -0,0 +1,228 @@
import { useEffect, useState } from 'react'
import {
ConnectingType,
ConnectingTypeGroup,
DisconnectingType,
EngineCommandManagerEvents,
EngineConnectionEvents,
EngineConnectionStateType,
ErrorType,
initialConnectingTypeGroupState,
} from '../lang/std/engineConnection'
import { engineCommandManager } from '../lib/singletons'
export enum NetworkHealthState {
Ok,
Weak,
Issue,
Disconnected,
}
export interface NetworkStatus {
hasIssues: boolean | undefined
overallState: NetworkHealthState
internetConnected: boolean
steps: typeof initialConnectingTypeGroupState
issues: Record<ConnectingTypeGroup, boolean | undefined>
error: ErrorType | undefined
setHasCopied: (b: boolean) => void
hasCopied: boolean
pingPongHealth: undefined | 'OK' | 'TIMEOUT'
}
// Must be called from one place in the application.
// We've chosen the <Router /> component for this.
export function useNetworkStatus() {
const [steps, setSteps] = useState(
structuredClone(initialConnectingTypeGroupState)
)
const [internetConnected, setInternetConnected] = useState<boolean>(true)
const [overallState, setOverallState] = useState<NetworkHealthState>(
NetworkHealthState.Disconnected
)
const [pingPongHealth, setPingPongHealth] = useState<
undefined | 'OK' | 'TIMEOUT'
>(undefined)
const [hasCopied, setHasCopied] = useState<boolean>(false)
const [error, setError] = useState<ErrorType | undefined>(undefined)
const hasIssue = (i: [ConnectingType, boolean | undefined]) =>
i[1] === undefined ? i[1] : !i[1]
const [issues, setIssues] = useState<
Record<ConnectingTypeGroup, boolean | undefined>
>({
[ConnectingTypeGroup.WebSocket]: undefined,
[ConnectingTypeGroup.ICE]: undefined,
[ConnectingTypeGroup.WebRTC]: undefined,
})
const [hasIssues, setHasIssues] = useState<boolean | undefined>(undefined)
useEffect(() => {
setOverallState(
!internetConnected
? NetworkHealthState.Disconnected
: hasIssues || hasIssues === undefined
? NetworkHealthState.Issue
: pingPongHealth === 'TIMEOUT'
? NetworkHealthState.Weak
: NetworkHealthState.Ok
)
}, [hasIssues, internetConnected, pingPongHealth])
useEffect(() => {
const onlineCallback = () => {
setInternetConnected(true)
}
const offlineCallback = () => {
setInternetConnected(false)
setSteps(structuredClone(initialConnectingTypeGroupState))
}
window.addEventListener('online', onlineCallback)
window.addEventListener('offline', offlineCallback)
return () => {
window.removeEventListener('online', onlineCallback)
window.removeEventListener('offline', offlineCallback)
}
}, [])
useEffect(() => {
const issues = {
[ConnectingTypeGroup.WebSocket]: steps[
ConnectingTypeGroup.WebSocket
].reduce(
(acc: boolean | undefined, a) =>
acc === true || acc === undefined ? acc : hasIssue(a),
false
),
[ConnectingTypeGroup.ICE]: steps[ConnectingTypeGroup.ICE].reduce(
(acc: boolean | undefined, a) =>
acc === true || acc === undefined ? acc : hasIssue(a),
false
),
[ConnectingTypeGroup.WebRTC]: steps[ConnectingTypeGroup.WebRTC].reduce(
(acc: boolean | undefined, a) =>
acc === true || acc === undefined ? acc : hasIssue(a),
false
),
}
setIssues(issues)
}, [steps])
useEffect(() => {
setHasIssues(
issues[ConnectingTypeGroup.WebSocket] ||
issues[ConnectingTypeGroup.ICE] ||
issues[ConnectingTypeGroup.WebRTC]
)
}, [issues])
useEffect(() => {
const onPingPongChange = ({ detail: state }: CustomEvent) => {
setPingPongHealth(state)
}
const onConnectionStateChange = ({
detail: engineConnectionState,
}: CustomEvent) => {
setSteps((steps) => {
let nextSteps = structuredClone(steps)
if (
engineConnectionState.type === EngineConnectionStateType.Connecting
) {
const groups = Object.values(nextSteps)
for (let group of groups) {
for (let step of group) {
if (step[0] !== engineConnectionState.value.type) continue
step[1] = true
}
}
}
if (
engineConnectionState.type === EngineConnectionStateType.Disconnecting
) {
const groups = Object.values(nextSteps)
for (let group of groups) {
for (let step of group) {
if (
engineConnectionState.value.type === DisconnectingType.Error
) {
if (
engineConnectionState.value.value.lastConnectingValue
?.type === step[0]
) {
step[1] = false
}
}
}
if (engineConnectionState.value.type === DisconnectingType.Error) {
setError(engineConnectionState.value.value)
}
}
}
// Reset the state of all steps if we have disconnected.
if (
engineConnectionState.type === EngineConnectionStateType.Disconnected
) {
return structuredClone(initialConnectingTypeGroupState)
}
return nextSteps
})
}
const onEngineAvailable = ({ detail: engineConnection }: CustomEvent) => {
engineConnection.addEventListener(
EngineConnectionEvents.PingPongChanged,
onPingPongChange as EventListener
)
engineConnection.addEventListener(
EngineConnectionEvents.ConnectionStateChanged,
onConnectionStateChange as EventListener
)
// Tell EngineConnection to start firing events.
window.dispatchEvent(new CustomEvent('use-network-status-ready', {}))
}
engineCommandManager.addEventListener(
EngineCommandManagerEvents.EngineAvailable,
onEngineAvailable as EventListener
)
return () => {
engineCommandManager.removeEventListener(
EngineCommandManagerEvents.EngineAvailable,
onEngineAvailable as EventListener
)
// When the component is unmounted these should be assigned, but it's possible
// the component mounts and unmounts before engine is available.
engineCommandManager.engineConnection?.addEventListener(
EngineConnectionEvents.PingPongChanged,
onPingPongChange as EventListener
)
engineCommandManager.engineConnection?.addEventListener(
EngineConnectionEvents.ConnectionStateChanged,
onConnectionStateChange as EventListener
)
}
}, [])
return {
hasIssues,
overallState,
internetConnected,
steps,
issues,
error,
setHasCopied,
hasCopied,
pingPongHealth,
}
}

View File

@ -43,7 +43,7 @@ export function useSetupEngineManager(
engineCommandManager.pool = settings.pool engineCommandManager.pool = settings.pool
} }
useLayoutEffect(() => { const startEngineInstance = () => {
// Load the engine command manager once with the initial width and height, // Load the engine command manager once with the initial width and height,
// then we do not want to reload it. // then we do not want to reload it.
const { width: quadWidth, height: quadHeight } = getDimensions( const { width: quadWidth, height: quadHeight } = getDimensions(
@ -73,7 +73,12 @@ export function useSetupEngineManager(
}) })
hasSetNonZeroDimensions.current = true hasSetNonZeroDimensions.current = true
} }
}, [streamRef?.current?.offsetWidth, streamRef?.current?.offsetHeight]) }
useLayoutEffect(startEngineInstance, [
streamRef?.current?.offsetWidth,
streamRef?.current?.offsetHeight,
])
useEffect(() => { useEffect(() => {
const handleResize = deferExecution(() => { const handleResize = deferExecution(() => {
@ -96,8 +101,20 @@ export function useSetupEngineManager(
} }
}, 500) }, 500)
const onOnline = () => {
startEngineInstance()
}
const onOffline = () => {
engineCommandManager.tearDown()
}
window.addEventListener('online', onOnline)
window.addEventListener('offline', onOffline)
window.addEventListener('resize', handleResize) window.addEventListener('resize', handleResize)
return () => { return () => {
window.removeEventListener('online', onOnline)
window.removeEventListener('offline', onOffline)
window.removeEventListener('resize', handleResize) window.removeEventListener('resize', handleResize)
} }
}, []) }, [])

View File

@ -7,12 +7,10 @@ import { authMachine } from 'machines/authMachine'
import { settingsMachine } from 'machines/settingsMachine' import { settingsMachine } from 'machines/settingsMachine'
import { homeMachine } from 'machines/homeMachine' import { homeMachine } from 'machines/homeMachine'
import { Command, CommandSetConfig, CommandSetSchema } from 'lib/commandTypes' import { Command, CommandSetConfig, CommandSetSchema } from 'lib/commandTypes'
import {
NetworkHealthState,
useNetworkStatus,
} from 'components/NetworkHealthIndicator'
import { useKclContext } from 'lang/KclProvider' import { useKclContext } from 'lang/KclProvider'
import { useStore } from 'useStore' import { useStore } from 'useStore'
import { useNetworkContext } from 'hooks/useNetworkContext'
import { NetworkHealthState } from 'hooks/useNetworkStatus'
// This might not be necessary, AnyStateMachine from xstate is working // This might not be necessary, AnyStateMachine from xstate is working
export type AllMachines = export type AllMachines =
@ -47,7 +45,7 @@ export default function useStateMachineCommands<
onCancel, onCancel,
}: UseStateMachineCommandsArgs<T, S>) { }: UseStateMachineCommandsArgs<T, S>) {
const { commandBarSend } = useCommandsContext() const { commandBarSend } = useCommandsContext()
const { overallState } = useNetworkStatus() const { overallState } = useNetworkContext()
const { isExecuting } = useKclContext() const { isExecuting } = useKclContext()
const { isStreamReady } = useStore((s) => ({ const { isStreamReady } = useStore((s) => ({
isStreamReady: s.isStreamReady, isStreamReady: s.isStreamReady,
@ -55,7 +53,10 @@ export default function useStateMachineCommands<
useEffect(() => { useEffect(() => {
const disableAllButtons = const disableAllButtons =
overallState !== NetworkHealthState.Ok || isExecuting || !isStreamReady (overallState !== NetworkHealthState.Ok &&
overallState !== NetworkHealthState.Weak) ||
isExecuting ||
!isStreamReady
const newCommands = state.nextEvents const newCommands = state.nextEvents
.filter((_) => !allCommandsRequireNetwork || !disableAllButtons) .filter((_) => !allCommandsRequireNetwork || !disableAllButtons)
.filter((e) => !['done.', 'error.'].some((n) => e.includes(n))) .filter((e) => !['done.', 'error.'].some((n) => e.includes(n)))

View File

@ -147,15 +147,33 @@ code {
#code-mirror-override .cm-activeLine, #code-mirror-override .cm-activeLine,
#code-mirror-override .cm-activeLineGutter { #code-mirror-override .cm-activeLineGutter {
@apply bg-primary/10; @apply bg-primary/5;
} }
.dark #code-mirror-override .cm-activeLine, .dark #code-mirror-override .cm-activeLine,
.dark #code-mirror-override .cm-activeLineGutter { .dark #code-mirror-override .cm-activeLineGutter {
@apply bg-primary/20; @apply bg-chalkboard-70/20;
mix-blend-mode: lighten; mix-blend-mode: lighten;
} }
#code-mirror-override .cm-focused .cm-activeLine,
#code-mirror-override .cm-focused .cm-activeLineGutter {
@apply bg-primary/10;
}
.dark #code-mirror-override .cm-focused .cm-activeLine,
.dark #code-mirror-override .cm-focused .cm-activeLineGutter {
@apply bg-chalkboard-70/40;
}
#code-mirror-override .cm-matchingBracket {
@apply bg-primary/20;
}
.dark #code-mirror-override .cm-matchingBracket {
@apply bg-chalkboard-70/60;
}
#code-mirror-override .cm-gutters { #code-mirror-override .cm-gutters {
@apply bg-chalkboard-10/30; @apply bg-chalkboard-10/30;
} }
@ -171,22 +189,8 @@ code {
@apply caret-chalkboard-10; @apply caret-chalkboard-10;
} }
#code-mirror-override .cm-focused .cm-cursor { #code-mirror-override .cm-focused {
width: 0px; outline: none;
}
#code-mirror-override .cm-cursor {
display: block;
width: 1ch;
@apply mix-blend-multiply;
@apply border-l-primary;
}
.dark #code-mirror-override .cm-cursor {
@apply border-l-chalkboard-10;
}
#code-mirror-override.blink .cm-cursor {
animation: blink 1200ms ease-out infinite;
} }
@keyframes blink { @keyframes blink {
@ -249,3 +253,10 @@ code {
.cm-ghostText * { .cm-ghostText * {
color: rgb(120, 120, 120, 0.8) !important; color: rgb(120, 120, 120, 0.8) !important;
} }
@layer components {
kbd.hotkey {
@apply font-mono text-xs inline-block px-1 py-0.5 rounded-sm;
@apply bg-chalkboard-20 dark:bg-chalkboard-90;
}
}

View File

@ -41,7 +41,10 @@ export class KclManager {
engineCommandManager: EngineCommandManager engineCommandManager: EngineCommandManager
private _defferer = deferExecution((code: string) => { private _defferer = deferExecution((code: string) => {
const ast = this.safeParse(code) const ast = this.safeParse(code)
if (!ast) return if (!ast) {
this.clearAst()
return
}
try { try {
const fmtAndStringify = (ast: Program) => const fmtAndStringify = (ast: Program) =>
JSON.stringify(parse(recast(ast))) JSON.stringify(parse(recast(ast)))
@ -91,7 +94,7 @@ export class KclManager {
set kclErrors(kclErrors) { set kclErrors(kclErrors) {
this._kclErrors = kclErrors this._kclErrors = kclErrors
let diagnostics = kclErrorsToDiagnostics(kclErrors) let diagnostics = kclErrorsToDiagnostics(kclErrors)
editorManager.setDiagnostics(diagnostics) editorManager.addDiagnostics(diagnostics)
this._kclErrorsCallBack(kclErrors) this._kclErrorsCallBack(kclErrors)
} }
@ -145,6 +148,18 @@ export class KclManager {
this._executeCallback = callback this._executeCallback = callback
} }
clearAst() {
this._ast = {
body: [],
start: 0,
end: 0,
nonCodeMeta: {
nonCodeNodes: {},
start: [],
},
}
}
safeParse(code: string): Program | null { safeParse(code: string): Program | null {
try { try {
const ast = parse(code) const ast = parse(code)
@ -185,6 +200,11 @@ export class KclManager {
const currentExecutionId = executionId || Date.now() const currentExecutionId = executionId || Date.now()
this._cancelTokens.set(currentExecutionId, false) this._cancelTokens.set(currentExecutionId, false)
// here we're going to clear diagnostics since we're the first
// one in. We're the only location where diagnostics are cleared;
// everything from here on out should be *appending*.
editorManager.clearDiagnostics()
this.isExecuting = true this.isExecuting = true
await this.ensureWasmInit() await this.ensureWasmInit()
const { logs, errors, programMemory } = await executeAst({ const { logs, errors, programMemory } = await executeAst({
@ -234,6 +254,7 @@ export class KclManager {
} = { updates: 'none' } } = { updates: 'none' }
) { ) {
await this.ensureWasmInit() await this.ensureWasmInit()
const newCode = recast(ast) const newCode = recast(ast)
const newAst = this.safeParse(newCode) const newAst = this.safeParse(newCode)
if (!newAst) return if (!newAst) return
@ -243,6 +264,11 @@ export class KclManager {
await this?.engineCommandManager?.waitForReady await this?.engineCommandManager?.waitForReady
this._ast = { ...newAst } this._ast = { ...newAst }
// here we're going to clear diagnostics since we're the first
// one in. We're the only location where diagnostics are cleared;
// everything from here on out should be *appending*.
editorManager.clearDiagnostics()
const { logs, errors, programMemory } = await executeAst({ const { logs, errors, programMemory } = await executeAst({
ast: newAst, ast: newAst,
engineCommandManager: this.engineCommandManager, engineCommandManager: this.engineCommandManager,
@ -281,14 +307,20 @@ export class KclManager {
if (!force) return this._defferer(codeManager.code) if (!force) return this._defferer(codeManager.code)
const ast = this.safeParse(codeManager.code) const ast = this.safeParse(codeManager.code)
if (!ast) return if (!ast) {
this.clearAst()
return
}
this.ast = { ...ast } this.ast = { ...ast }
return this.executeAst(ast, zoomToFit) return this.executeAst(ast, zoomToFit)
} }
format() { format() {
const originalCode = codeManager.code const originalCode = codeManager.code
const ast = this.safeParse(originalCode) const ast = this.safeParse(originalCode)
if (!ast) return if (!ast) {
this.clearAst()
return
}
const code = recast(ast) const code = recast(ast)
if (originalCode === code) return if (originalCode === code) return
@ -352,25 +384,55 @@ export class KclManager {
return this?.engineCommandManager?.defaultPlanes return this?.engineCommandManager?.defaultPlanes
} }
showPlanes() { showPlanes(all = false) {
if (!this.defaultPlanes) return if (!this.defaultPlanes) return Promise.all([])
void this.engineCommandManager.setPlaneHidden(this.defaultPlanes.xy, false) const thePromises = [
void this.engineCommandManager.setPlaneHidden(this.defaultPlanes.yz, false) this.engineCommandManager.setPlaneHidden(this.defaultPlanes.xy, false),
void this.engineCommandManager.setPlaneHidden(this.defaultPlanes.xz, false) this.engineCommandManager.setPlaneHidden(this.defaultPlanes.yz, false),
this.engineCommandManager.setPlaneHidden(this.defaultPlanes.xz, false),
]
if (all) {
thePromises.push(
this.engineCommandManager.setPlaneHidden(
this.defaultPlanes.negXy,
false
)
)
thePromises.push(
this.engineCommandManager.setPlaneHidden(
this.defaultPlanes.negYz,
false
)
)
thePromises.push(
this.engineCommandManager.setPlaneHidden(
this.defaultPlanes.negXz,
false
)
)
}
return Promise.all(thePromises)
} }
hidePlanes() { hidePlanes(all = false) {
if (!this.defaultPlanes) return if (!this.defaultPlanes) return Promise.all([])
void this.engineCommandManager.setPlaneHidden(this.defaultPlanes.xy, true) const thePromises = [
void this.engineCommandManager.setPlaneHidden(this.defaultPlanes.yz, true) this.engineCommandManager.setPlaneHidden(this.defaultPlanes.xy, true),
void this.engineCommandManager.setPlaneHidden(this.defaultPlanes.xz, true) this.engineCommandManager.setPlaneHidden(this.defaultPlanes.yz, true),
} this.engineCommandManager.setPlaneHidden(this.defaultPlanes.xz, true),
exitEditMode() { ]
this.engineCommandManager.sendSceneCommand({ if (all) {
type: 'modeling_cmd_req', thePromises.push(
cmd_id: uuidv4(), this.engineCommandManager.setPlaneHidden(this.defaultPlanes.negXy, true)
cmd: { type: 'edit_mode_exit' }, )
}) thePromises.push(
this.engineCommandManager.setPlaneHidden(this.defaultPlanes.negYz, true)
)
thePromises.push(
this.engineCommandManager.setPlaneHidden(this.defaultPlanes.negXz, true)
)
}
return Promise.all(thePromises)
} }
defaultSelectionFilter() { defaultSelectionFilter() {
defaultSelectionFilter(this.programMemory, this.engineCommandManager) defaultSelectionFilter(this.programMemory, this.engineCommandManager)
@ -386,24 +448,11 @@ function defaultSelectionFilter(
) as SketchGroup | ExtrudeGroup ) as SketchGroup | ExtrudeGroup
firstSketchOrExtrudeGroup && firstSketchOrExtrudeGroup &&
engineCommandManager.sendSceneCommand({ engineCommandManager.sendSceneCommand({
type: 'modeling_cmd_batch_req', type: 'modeling_cmd_req',
batch_id: uuidv4(), cmd_id: uuidv4(),
responses: false, cmd: {
requests: [ type: 'set_selection_filter',
{ filter: ['face', 'edge', 'solid2d', 'curve'],
cmd_id: uuidv4(), },
cmd: {
type: 'edit_mode_enter',
target: firstSketchOrExtrudeGroup.id,
},
},
{
cmd_id: uuidv4(),
cmd: {
type: 'set_selection_filter',
filter: ['face', 'edge', 'solid2d'],
},
},
],
}) })
} }

View File

@ -22,7 +22,7 @@ export default class CodeManager {
return return
} }
const storedCode = safeLSGetItem(PERSIST_CODE_TOKEN) || '' const storedCode = safeLSGetItem(PERSIST_CODE_TOKEN)
// TODO #819 remove zustand persistence logic in a few months // TODO #819 remove zustand persistence logic in a few months
// short term migration, shouldn't make a difference for tauri app users // short term migration, shouldn't make a difference for tauri app users
// anyway since that's filesystem based. // anyway since that's filesystem based.
@ -68,7 +68,9 @@ export default class CodeManager {
this._currentFilePath = path this._currentFilePath = path
} }
// This updates the code state and calls the updateState function. /**
* This updates the code state and calls the updateState function.
*/
updateCodeState(code: string): void { updateCodeState(code: string): void {
if (this._code !== code) { if (this._code !== code) {
this.code = code this.code = code
@ -76,7 +78,9 @@ export default class CodeManager {
} }
} }
// Update the code in the editor. /**
* Update the code in the editor.
*/
updateCodeEditor(code: string): void { updateCodeEditor(code: string): void {
this.code = code this.code = code
if (editorManager.editorView) { if (editorManager.editorView) {
@ -90,7 +94,9 @@ export default class CodeManager {
} }
} }
// Update the code, state, and the code the code mirror editor sees. /**
* Update the code, state, and the code the code mirror editor sees.
*/
updateCodeStateEditor(code: string): void { updateCodeStateEditor(code: string): void {
if (this._code !== code) { if (this._code !== code) {
this.code = code this.code = code

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