Compare commits

...

54 Commits

Author SHA1 Message Date
cccedceea0 Bump to v0.3.0 (#378) 2023-09-04 11:01:51 -04:00
ed68a34560 disable high dpi video streaming (#374) 2023-09-01 20:29:03 -04:00
00ee913e3f Upload release artifacts to the release (on top of dl.kittycad.io) (#371)
* Upload release artifacts to the release (on top of dl.kittycad.io)
Fixes #365

* Remove test
2023-09-01 14:20:48 -04:00
46cc67e2db messing around with arc and bezier (#363)
updates



fixes



updates



add another test



updates



updates



updates



updates



updates



updates



add test for error;



updates



updates



fixups



updates



updates



fixes



updates



fixes



updates



fixes



updates



updates



updates



bump

Signed-off-by: Jess Frazelle <github@jessfraz.com>
2023-08-31 22:19:23 -07:00
ff1be34f54 Revert mute-reset behavior (#369)
I have a hunch this is causing more problems than it fixes.

Signed-off-by: Paul R. Tagliamonte <paul@kittycad.io>
2023-08-31 22:57:58 -04:00
848bf61277 Don't fetch for user if in dev with a local engine (#368)
* Don't fetch for user if in dev with a local engine
But rather return a dummy user (created by @paultag) so that
teammates using locally-running engines can bypass auth.

Signed-off-by: Frank Noirot <frank@kittycad.io>

* Use env var to be more explicit

---------

Signed-off-by: Frank Noirot <frank@kittycad.io>
2023-08-31 16:23:12 -04:00
043333d3bc Franknoirot/fix onboarding units feedback followup (#367) 2023-08-31 16:08:15 -04:00
19d90b8081 bump kitty lib (#364) 2023-09-01 06:07:27 +10:00
4837c52908 Redo how Spans are used from the Engine (#359)
* Redo how Spans are used from the Engine

I don't like all the Sentry-specific stuff we've got to work around, and
I want to add a bunch more spans and more cleanly end the transaction.

This isn't generic enough to pull out of this code (yet?), but we
clearly need some class of abstraction due to the highly async pattern
in the WebRTC code.

I want to add in more tags, but there are a lot of events we need to
wait on. I'd like to hook into the <video> 'play' eventListener, but
it's hard to do from all the way down in the Engine.

Signed-off-by: Paul R. Tagliamonte <paul@kittycad.io>
2023-08-31 12:59:46 -04:00
afcf820bdd Franknoirot/fix onboarding units (#366)
* Fix up camera step copy and pane opacity for step

* Fix broken onboarding redirect with double slash

* Fix pane height for web bug from blur filter
I found a bug with browser behavior, at least on Chrome.
If you use `backdrop-filter: blur()` at all, you can't
have any children that overflow. The browser will ignore
any attempt and make those children max full-height.
This broke our side panels after I added blur, but
only in Chrome/browser target.

Signed-off-by: Frank Noirot <frank@kittycad.io>

* Fix bug with unit system
Changing the unit system didn't also change the
base unit in the onboarding anymore. It needed
updated to use XState the same way as `/settings`

Signed-off-by: Frank Noirot <frank@kittycad.io>

* Fix AppHeader item spacing when there's no toolbar

---------

Signed-off-by: Frank Noirot <frank@kittycad.io>
2023-08-31 10:41:24 -04:00
18959510f8 Franknoirot/expandable toolbar (#343)
* Add basic Popover functionality

* Fix up light mode of basic bar

* Add support for 2D and 3D mode styling

* Turn toolbar buttons back on

* Remove ActionButton until after tool logic refactor

* Add transitions

* Add styles to always center toolbar in header
2023-08-31 09:47:59 -04:00
798cbe968a Franknoirot/live system theme (#358)
* Only show the Replay Onboarding button in file settings
Resolves #351. Eventually we will implement more sophisticated
logic for which settings should be shown where.

Signed-off-by: Frank Noirot <frank@kittycad.io>

* Remove unnecessary console.log

Signed-off-by: Frank Noirot <frank@kittycad.io>

* Respond to system theme changes in real-time
If the user has their "theme" setting to "system".
I tried to use the [XState invoked callback approach](https://xstate.js.org/docs/guides/communication.html#invoking-callbacks),
but I could not find any way to respond to the latest context/state values within the
media listener; I kept receiving stale state.

Signed-off-by: Frank Noirot <frank@kittycad.io>

---------

Signed-off-by: Frank Noirot <frank@kittycad.io>
2023-08-31 09:34:13 -04:00
9cbc088ba3 Only show the Replay Onboarding button in file settings (#355) 2023-08-31 08:27:05 -04:00
2693a5609b Add subtle transitions to sidebars (#344) 2023-08-31 08:17:52 -04:00
3507da7b39 Tweak text constrast, blinking cursor (#338) 2023-08-31 08:17:26 -04:00
56cfb6d1f0 remove excessive serialisation (#362)
remove excessive serialization
2023-08-31 14:44:22 +10:00
2b974ef1de Bump schemars from 0.8.12 to 0.8.13 in /src/wasm-lib (#341)
Bumps [schemars](https://github.com/GREsau/schemars) from 0.8.12 to 0.8.13.
- [Release notes](https://github.com/GREsau/schemars/releases)
- [Changelog](https://github.com/GREsau/schemars/blob/master/CHANGELOG.md)
- [Commits](https://github.com/GREsau/schemars/compare/v0.8.12...v0.8.13)

---
updated-dependencies:
- dependency-name: schemars
  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>
2023-08-30 21:06:40 -07:00
253f1992fd Bump tauri-build from 1.3.0 to 1.4.0 in /src-tauri (#282)
Bumps [tauri-build](https://github.com/tauri-apps/tauri) from 1.3.0 to 1.4.0.
- [Release notes](https://github.com/tauri-apps/tauri/releases)
- [Commits](https://github.com/tauri-apps/tauri/compare/tauri-build-v1.3...tauri-build-v1.4)

---
updated-dependencies:
- dependency-name: tauri-build
  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>
2023-08-30 21:06:03 -07:00
76d3794b45 Bump bson from 2.6.1 to 2.7.0 in /src/wasm-lib (#360)
Bumps [bson](https://github.com/mongodb/bson-rust) from 2.6.1 to 2.7.0.
- [Release notes](https://github.com/mongodb/bson-rust/releases)
- [Commits](https://github.com/mongodb/bson-rust/compare/v2.6.1...v2.7.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>
2023-08-30 21:05:35 -07:00
e52c8c9db6 Bump kittycad from 0.2.22 to 0.2.23 in /src/wasm-lib (#361)
Bumps [kittycad](https://github.com/KittyCAD/kittycad.rs) from 0.2.22 to 0.2.23.
- [Release notes](https://github.com/KittyCAD/kittycad.rs/releases)
- [Commits](https://github.com/KittyCAD/kittycad.rs/compare/v0.2.22...v0.2.23)

---
updated-dependencies:
- dependency-name: kittycad
  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>
2023-08-30 21:05:21 -07:00
eb48d51309 rename lossy to unreliable (#357)
* rename lossy to unreliable

* fmt

* missed a rename
2023-08-31 07:39:03 +10:00
f3274e03ff refactor callbacks (#334)
refactor callbacks
2023-08-30 15:19:37 -04:00
46937199a3 Start to clean up Sentry now that the app is back up again. (#356)
* Start to clean up Sentry now that the app is back up again.

Remove Sentry from local development I thought .env.development
was for dev.kc.io, not just local dev. Someone can add this to .local
if they need to test the Sentry stuff for now.

Signed-off-by: Paul Tagliamonte <paul@kittycad.io>
2023-08-30 13:14:52 -04:00
e2a4798c2f Update production Sentry values (#354)
Signed-off-by: Paul Tagliamonte <paul@kittycad.io>
2023-08-30 10:52:26 -04:00
659e6d5b45 Add in Sentry, WebRTC Statistics (#345)
Collect WebRTC Statistical Information

Add in some instrumentation to track the duration of the setup phase,
and set up a job to make periodic use of the WebRTC Statistics API
to collect information about the connection (specifically, each track),
including transport-level information, timing information and bandwidth
information.

Sentry isn't the best place for that information, but it'll work until we
can work out a good long-term solution for it.

Signed-off-by: Paul Tagliamonte <paul@kittycad.io>
2023-08-30 10:34:14 -04:00
1fbd0ad675 Bump to v0.2.0 (#352) 2023-08-29 22:19:09 -04:00
743ea1af4d Kcl updates for snapshot stuff for cli (#350)
* updates for snapshot file

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

* updates

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

---------

Signed-off-by: Jess Frazelle <github@jessfraz.com>
2023-08-29 19:13:30 -07:00
2b1a556b81 Wrap await in try/catch to fix sign-in in tauri builds (#349)
Fixes #342
2023-08-29 21:45:40 -04:00
853389ba22 bump kittycad.rs (#348)
fixups

Signed-off-by: Jess Frazelle <github@jessfraz.com>
2023-08-29 16:31:19 -07:00
023af60781 fmt and move error stuff locally (#347)
* fmt

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

* some fixups for errors

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

* some fixups for errors

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

* bump version

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

* fix tsc

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

---------

Signed-off-by: Jess Frazelle <github@jessfraz.com>
2023-08-29 14:12:48 -07:00
18db6f2dc1 change name for initial publish to cargo (#340)
* change name for initial publish to cargo

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

* add license

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>

* fix

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

---------

Signed-off-by: Jess Frazelle <github@jessfraz.com>
2023-08-29 12:35:45 -07:00
4afec15323 bugfix: don't show a toast when onboarding changes (#346) 2023-08-29 15:27:02 -04:00
152108f7a5 Refactor to just CommandBar and GlobalState (#337)
* Refactor to just CommandBar and GlobalState

* @Irev-Dev review: consolidate uses of useContext
2023-08-29 10:48:55 -04:00
32d928ae0c Franknoirot/cmd bar (#328)
* Add XState and naive ActionBar

* Add basic dialog and combobox

* Selectable commands in command bar

* Add a few (broken) file actions

* Home commands

* Add subcommand descriptions, cleanup on navigate

* Refactor: move command creation and types to lib

* Refactor to allow any machine to add commands

* Add auth to command bar, add ability to hide cmds

* Refactor: consolidate theme utilities

* Add settings as machine and command set

* Fix: type tweaks

* Fix: only allow auth to navigate from signin

* Remove zustand-powered settings

* Fix: remove zustand settings from App

* Fix: browser infinite redirect

* Feature: allow commands to be hidden per-platform

* Fix: tsc errors

* Fix: hide default project directory from cmd bar

* Polish: transitions, css tweaks

* Feature: label current value in options settings

* Fix broken debug panel UI

* Refactor: move settings toasts to actions

* Tweak: css rounding

* Fix: set default directory recursion and reload 🐞

* Refactor: move machines to their own directory

* Fix formatting

* @Irev-Dev clean-up catches, import cleanup
2023-08-29 10:31:49 +10:00
6f0fae625f quick fik (#339) 2023-08-29 10:28:27 +10:00
9bc47cf14a fix export and prepare for cli lib (#325)
* add a test

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

* fixes

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

* fix

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

* fixup and make cleaner cfg options

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>

* redo

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

* rearrange

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>

* updates

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

* bincode error

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

* updates

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

* working

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

* switch to bson

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

* remove all bincode

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

* fix clippy

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

---------

Signed-off-by: Jess Frazelle <github@jessfraz.com>
2023-08-28 14:58:24 -07:00
eea47aae1e Bump to v0.1.0 (#335) 2023-08-28 17:23:37 -04:00
25b9b4cf98 add isReducedMotion util (#333) 2023-08-28 18:48:31 +10:00
0f3f0b3b68 Docs macros (#318)
* initial port

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

* updates

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

* start of macro

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>

* updates

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

* more macros

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

* updates

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

* new

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

* fixes

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

* fix clippy

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

* updates

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

* start of generated docs

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>

* fixups

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

* fix

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

* fix

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>

* 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>

* 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>

* updates

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

* updates for objects

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

* fixiups

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>

* updates

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

* updates

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

* descriptions

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

* descriptions

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 vecs

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

* fix clippy

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

---------

Signed-off-by: Jess Frazelle <github@jessfraz.com>
2023-08-25 13:41:04 -07:00
33eb6126d4 bump rust types (#321)
* get rid of noisy log

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

* fix types in rust

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

---------

Signed-off-by: Jess Frazelle <github@jessfraz.com>
2023-08-25 11:29:18 -07:00
dccb83f614 add plausible (#322)
Signed-off-by: Jess Frazelle <github@jessfraz.com>
2023-08-25 11:09:36 -07:00
b56a3398ad Fix up message structure to match the new Engine messages (#316)
* Fix up message structure to match the new Engine messages

The types are still jacked up, I reckon we need to bump the node
@KittyCAD dep.

Signed-off-by: Paul Tagliamonte <paul@kittycad.io>

* update types

* fmt

* export tsc

* fmt again

---------

Signed-off-by: Paul Tagliamonte <paul@kittycad.io>
Co-authored-by: Kurt Hutten Irev-Dev <k.hutten@protonmail.ch>
2023-08-25 14:16:37 +10:00
11658e2ff5 cleanup code we are no longer using (#319)
Signed-off-by: Jess Frazelle <github@jessfraz.com>
2023-08-25 10:28:59 +10:00
de255acc59 Port executor (#287)
* parent

initial types

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>

more port

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>

fixups

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>

fixups

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>

fixes

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>

fixups

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

fixes

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

use the function

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

ipdates

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

fixes

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

fixups

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>

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>

fixes

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>

updates

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

pipe sjhit

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

updates

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

cleanup and pipes

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

updates

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

fixups

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

updates

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

attempt to call the function

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>

fix tests

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

better

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

add first function

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

start of stdlib

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

updates

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

organize better

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

fixes

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

cleanup

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

fixes

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

boilerplace

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

boilerplace

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

more functions

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

more stuff

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

more path segment functions

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

reorganize files

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

extrude boilerplate

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

extrude

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

updates

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

sketch boilerplate

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

updates

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

comment out extrude for now

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

more executor test passing

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

rename meta

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

updates

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

cleanup unneeded deps

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

generate executor typoes

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

updates

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

remove path to node

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

updates for tests js

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

updates

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

ignore wasm file

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

fixes

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

start of websocket connection

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

boilerplate for engine connection

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

fix

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

updates

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

send the modeling cmd

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

implement close

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>

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>

remove refid

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

remove refid

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

do sketch start

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

almost done w sketch port

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

updates

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

add more tests

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

fix deserialize and tests

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

fix tests remove logging

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

fix the return type

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

make compile

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

more tests pass

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

updates

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

expect any string

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

add failing test

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

fix the tests

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

fix tests

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

fix more tests

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

replace wasm_execute

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

fix more tests

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

updates

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

add more tests

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

make all tests pass

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

fixes

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

fix remaining tests

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

add a warpper

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

start of server side ws/webrtc

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

more nonweb working

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>

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>

add test mock

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

fixes

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

mutable engine

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

blocking snd engine cmd

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

updates

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

tmp

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

* tmp

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

* updates

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

* fix clippy

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

* fixups

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

* build wasm only

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

* fix cargo builds

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>

* more logging

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

* push

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

---------

Signed-off-by: Jess Frazelle <github@jessfraz.com>
2023-08-24 15:34:51 -07:00
d33ddb2f1b fix typo in function name, cleanup unused args (#317)
Signed-off-by: Jess Frazelle <github@jessfraz.com>
2023-08-24 11:41:38 -07:00
a0730ded4e Remove EventTarget from EngineConnection (#315)
Signed-off-by: Paul R. Tagliamonte <paul@kittycad.io>
2023-08-24 10:46:02 -04:00
afd2b507ef refactor engine connection to use callbacks (#314)
Less type maintenence
2023-08-24 09:46:45 -04:00
8983a8231b update for types only kittycad.rs (#312)
Signed-off-by: Jess Frazelle <github@jessfraz.com>
2023-08-24 07:08:15 +10:00
9dd708db5d Make the timeout for a WebRTC/WebSocket connection pair configurable (#310)
Signed-off-by: Paul Tagliamonte <paul@kittycad.io>
2023-08-24 06:49:04 +10:00
c25dd1800c Track the connection time in the console (#311)
In the future, this should be sent as telemetry, but at least this would
give us a bit of info about the latency sitatuion locally if we start
seeing the refresh happen a lot.

Signed-off-by: Paul Tagliamonte <paul@kittycad.io>
2023-08-23 15:29:03 -04:00
e56e7ba0fa Detect when a video stream fails from the server (#307)
It's not clear /what/ is breaking the connection, but this will add
retry when it does. The term 'muted' here is a bit hard to grok, it's a
read-only indication of the *peer*'s state

https://developer.mozilla.org/en-US/docs/Web/API/MediaStreamTrack/muted

Signed-off-by: Paul R. Tagliamonte <paul@kittycad.io>
2023-08-22 20:07:39 -04:00
dbe4e7faa6 Clean up after a closed EngineConnection (#304)
Because we gate a lot of things off this.foo?, leaving the closed
websocket/pc/etc in-place was causing a noisy log, and mucking with
reestablishing a broken connection.

While I was in here, I fixed a style nit from yarn.

Signed-off-by: Paul R. Tagliamonte <paul@kittycad.io>
2023-08-22 18:18:22 -04:00
148e125dd7 test parse errors are thrown (#294) 2023-08-22 13:28:02 +10:00
75bb91c7e1 Build out EngineConnection's retry and timeout logic (#299)
* Build out EngineConnection's retry and timeout logic

 * Migrate the EngineConnection to be an EventTarget for other parts of the
   code (mostly the EngineManager, but maybe others?) to listen to, rather
   than having a boolean 'done' promise, and remove callbacks in favor of
   the eventListeners.

 * When a WebRTC connection is online, send a 'ping' command every 10 seconds.
   The UDP stream likely needs something similar, but the connection is
   maintained by the WebRTC video stream for now.

 * Begin to migrate code to use a more generic object "send" helper
   which can handle the JSON encoding, as well as connection retry logic
   in the future.

 * Add a watchdog to trigger 5 seconds after a connection is initiated
   to cancel and retry the connection if it's not become ready by the
   time it wakes up. This won't watch an established connection yet.

Signed-off-by: Paul R. Tagliamonte <paul@kittycad.io>
2023-08-21 16:53:31 -04:00
113 changed files with 33053 additions and 4161 deletions

View File

@ -1,3 +1,7 @@
VITE_KC_API_WS_MODELING_URL=wss://api.dev.kittycad.io/ws/modeling/commands
VITE_KC_API_BASE_URL=https://api.dev.kittycad.io
VITE_KC_SITE_BASE_URL=https://dev.kittycad.io
VITE_KC_SKIP_AUTH=false
VITE_KC_CONNECTION_TIMEOUT_MS=5000
VITE_KC_CONNECTION_WEBRTC_REPORT_STATS_MS=0
VITE_KC_SENTRY_DSN=

View File

@ -1,3 +1,7 @@
VITE_KC_API_WS_MODELING_URL=wss://api.kittycad.io/ws/modeling/commands
VITE_KC_API_BASE_URL=https://api.kittycad.io
VITE_KC_SITE_BASE_URL=https://kittycad.io
VITE_KC_SITE_BASE_URL=https://kittycad.io
VITE_KC_SKIP_AUTH=false
VITE_KC_CONNECTION_TIMEOUT_MS=15000
VITE_KC_CONNECTION_WEBRTC_REPORT_STATS_MS=30000
VITE_KC_SENTRY_DSN=https://a814f2f66734989a90367f48feee28ca@o1042111.ingest.sentry.io/4505789425844224

1
.eslintignore Normal file
View File

@ -0,0 +1 @@
src/wasm-lib/pkg/wasm_lib.js

View File

@ -22,7 +22,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
dir: ['src/wasm-lib', 'src-tauri']
dir: ['src/wasm-lib']
steps:
- uses: actions/checkout@v3

View File

@ -22,7 +22,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
dir: ['src/wasm-lib', 'src-tauri']
dir: ['src/wasm-lib']
steps:
- uses: actions/checkout@v3
- name: Install latest rust

View File

@ -24,7 +24,7 @@ jobs:
runs-on: ubuntu-latest-8-cores
strategy:
matrix:
dir: ['src/wasm-lib', 'src-tauri']
dir: ['src/wasm-lib']
steps:
- uses: actions/checkout@v3
- name: Install latest rust
@ -45,4 +45,7 @@ jobs:
shell: bash
run: |-
cd "${{ matrix.dir }}"
cargo llvm-cov nextest --lcov --output-path lcov.info --test-threads=1 --no-fail-fast
cargo test --all
env:
KITTYCAD_API_TOKEN: ${{secrets.KITTYCAD_API_TOKEN}}

View File

@ -124,6 +124,8 @@ jobs:
publish-apps-release:
runs-on: ubuntu-20.04
if: github.event_name == 'release'
permissions:
contents: write
needs: [build-test-web, build-apps]
env:
VERSION_NO_V: ${{ needs.build-test-web.outputs.version }}
@ -189,3 +191,8 @@ jobs:
with:
path: last_update.json
destination: dl.kittycad.io/releases/modeling-app
- name: Upload release files to Github
uses: softprops/action-gh-release@v1
with:
files: artifact/*/kittycad-modeling-app*

1
.gitignore vendored
View File

@ -25,5 +25,6 @@ yarn-error.log*
# rust
src/wasm-lib/target
src/wasm-lib/bindings
src/wasm-lib/kcl/bindings
public/wasm_lib_bg.wasm
src/wasm-lib/lcov.info

19014
docs/kcl.json Normal file

File diff suppressed because it is too large Load Diff

3398
docs/kcl.md Normal file

File diff suppressed because it is too large Load Diff

View File

@ -11,6 +11,7 @@
/>
<link rel="apple-touch-icon" href="/logo192.png" />
<link rel="manifest" href="/manifest.json" />
<script defer data-domain="app.kittycad.io" src="https://plausible.corp.kittycad.io/js/script.js"></script>
<title>KittyCAD Modeling App</title>
</head>
<body class="body-bg">

View File

@ -1,6 +1,6 @@
{
"name": "untitled-app",
"version": "0.0.4",
"version": "0.3.0",
"private": true,
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^6.4.2",
@ -8,8 +8,10 @@
"@fortawesome/free-solid-svg-icons": "^6.4.2",
"@fortawesome/react-fontawesome": "^0.2.0",
"@headlessui/react": "^1.7.13",
"@kittycad/lib": "^0.0.29",
"@headlessui/tailwindcss": "^0.2.0",
"@kittycad/lib": "^0.0.35",
"@react-hook/resize-observer": "^1.2.6",
"@sentry/react": "^7.65.0",
"@tauri-apps/api": "^1.3.0",
"@testing-library/jest-dom": "^5.14.1",
"@testing-library/react": "^13.0.0",
@ -22,6 +24,7 @@
"@xstate/react": "^3.2.2",
"crypto-js": "^4.1.1",
"formik": "^2.4.3",
"fuse.js": "^6.6.2",
"http-server": "^14.1.1",
"re-resizable": "^6.9.9",
"react": "^18.2.0",
@ -54,15 +57,15 @@
"build:both:local": "yarn build:wasm && vite build",
"test": "vitest --mode development",
"test:nowatch": "vitest run --mode development",
"test:rust": "(cd src/wasm-lib && cargo test && cargo clippy)",
"test:rust": "(cd src/wasm-lib && cargo test --all && cargo clippy --all --tests)",
"test:cov": "vitest run --coverage --mode development",
"simpleserver:ci": "http-server ./public --cors -p 3000 &",
"simpleserver": "http-server ./public --cors -p 3000",
"fmt": "prettier --write ./src",
"fmt-check": "prettier --check ./src",
"build:wasm": "yarn wasm-prep && (cd src/wasm-lib && wasm-pack build --target web --out-dir pkg && cargo test --all) && cp src/wasm-lib/pkg/wasm_lib_bg.wasm public && yarn fmt && yarn remove-importmeta",
"build:wasm": "yarn wasm-prep && (cd src/wasm-lib && wasm-pack build --target web --out-dir pkg && cargo test -p kcl-lib export_bindings) && cp src/wasm-lib/pkg/wasm_lib_bg.wasm public && yarn fmt && yarn remove-importmeta",
"remove-importmeta": "sed -i 's/import.meta.url//g' \"./src/wasm-lib/pkg/wasm_lib.js\"; sed -i '' 's/import.meta.url//g' \"./src/wasm-lib/pkg/wasm_lib.js\" || echo \"sed for both mac and linux\"",
"wasm-prep": "rm -rf src/wasm-lib/pkg && mkdir src/wasm-lib/pkg && rm -rf src/wasm-lib/bindings",
"wasm-prep": "rm -rf src/wasm-lib/pkg && mkdir src/wasm-lib/pkg && rm -rf src/wasm-lib/kcl/bindings",
"lint": "eslint --fix src",
"bump-jsons": "echo \"$(jq --arg v \"$VERSION\" '.version=$v' package.json --indent 2)\" > package.json && echo \"$(jq --arg v \"$VERSION\" '.package.version=$v' src-tauri/tauri.conf.json --indent 2)\" > src-tauri/tauri.conf.json"
},

77
src-tauri/Cargo.lock generated
View File

@ -648,6 +648,12 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "equivalent"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5"
[[package]]
name = "errno"
version = "0.3.1"
@ -1150,7 +1156,7 @@ dependencies = [
"futures-sink",
"futures-util",
"http",
"indexmap",
"indexmap 1.9.3",
"slab",
"tokio",
"tokio-util",
@ -1163,6 +1169,12 @@ version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
[[package]]
name = "hashbrown"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2c6201b9ff9fd90a5a3bac2e56a830d0caa509576f0e503818ee82c181b3437a"
[[package]]
name = "heck"
version = "0.3.3"
@ -1378,7 +1390,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99"
dependencies = [
"autocfg",
"hashbrown",
"hashbrown 0.12.3",
"serde",
]
[[package]]
name = "indexmap"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d5477fe2230a79769d8dc68e0eabf5437907c0457a5614a9e8dddb67f65eb65d"
dependencies = [
"equivalent",
"hashbrown 0.14.0",
"serde",
]
@ -1628,6 +1651,12 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "minisign-verify"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "933dca44d65cdd53b355d0b73d380a2ff5da71f87f036053188bf1eab6a19881"
[[package]]
name = "miniz_oxide"
version = "0.6.2"
@ -2122,7 +2151,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9bd9647b268a3d3e14ff09c23201133a62589c658db02bb7388c7246aafe0590"
dependencies = [
"base64 0.21.2",
"indexmap",
"indexmap 1.9.3",
"line-wrap",
"quick-xml",
"serde",
@ -2695,14 +2724,15 @@ dependencies = [
[[package]]
name = "serde_with"
version = "2.3.3"
version = "3.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07ff71d2c147a7b57362cead5e22f772cd52f6ab31cfcd9edcd7f6aeb2a0afbe"
checksum = "1402f54f9a3b9e2efe71c1cea24e648acce55887983553eeb858cf3115acfd49"
dependencies = [
"base64 0.13.1",
"base64 0.21.2",
"chrono",
"hex",
"indexmap",
"indexmap 1.9.3",
"indexmap 2.0.0",
"serde",
"serde_json",
"serde_with_macros",
@ -2711,9 +2741,9 @@ dependencies = [
[[package]]
name = "serde_with_macros"
version = "2.3.3"
version = "3.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "881b6f881b17d13214e5d494c939ebab463d01264ce1811e9d4ac3a882e7695f"
checksum = "9197f1ad0e3c173a0222d3c4404fb04c3afe87e962bcb327af73e8301fa203c7"
dependencies = [
"darling",
"proc-macro2",
@ -3022,6 +3052,7 @@ checksum = "d42ba3a2e8556722f31336a0750c10dbb6a81396a1c452977f515da83f69f842"
dependencies = [
"anyhow",
"attohttpc",
"base64 0.21.2",
"cocoa",
"dirs-next",
"embed_plist",
@ -3034,6 +3065,7 @@ dependencies = [
"heck 0.4.1",
"http",
"ignore",
"minisign-verify",
"objc",
"once_cell",
"open",
@ -3055,19 +3087,21 @@ dependencies = [
"tauri-utils",
"tempfile",
"thiserror",
"time",
"tokio",
"url",
"uuid",
"webkit2gtk",
"webview2-com",
"windows 0.39.0",
"zip",
]
[[package]]
name = "tauri-build"
version = "1.3.0"
version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "929b3bd1248afc07b63e33a6a53c3f82c32d0b0a5e216e4530e94c467e019389"
checksum = "7d2edd6a259b5591c8efdeb9d5702cb53515b82a6affebd55c7fd6d3a27b7d1b"
dependencies = [
"anyhow",
"cargo_toml",
@ -3078,7 +3112,6 @@ dependencies = [
"serde_json",
"tauri-utils",
"tauri-winres",
"winnow",
]
[[package]]
@ -3176,12 +3209,13 @@ dependencies = [
[[package]]
name = "tauri-utils"
version = "1.3.0"
version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a6f9c2dafef5cbcf52926af57ce9561bd33bb41d7394f8bb849c0330260d864"
checksum = "03fc02bb6072bb397e1d473c6f76c953cda48b4a2d0cce605df284aa74a12e84"
dependencies = [
"brotli",
"ctor",
"dunce",
"glob",
"heck 0.4.1",
"html5ever",
@ -3397,7 +3431,7 @@ version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56c59d8dd7d0dcbc6428bf7aa2f0e823e26e43b3c9aca15bbc9475d23e5fa12b"
dependencies = [
"indexmap",
"indexmap 1.9.3",
"nom8",
"serde",
"serde_spanned",
@ -3410,7 +3444,7 @@ version = "0.19.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "239410c8609e8125456927e6707163a3b1fdb40561e4b803bc041f466ccfdc13"
dependencies = [
"indexmap",
"indexmap 1.9.3",
"serde",
"serde_spanned",
"toml_datetime 0.6.2",
@ -4228,3 +4262,14 @@ checksum = "6d1526bbe5aaeb5eb06885f4d987bcdfa5e23187055de9b83fe00156a821fabc"
dependencies = [
"libc",
]
[[package]]
name = "zip"
version = "0.6.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "760394e246e4c28189f19d488c058bf16f564016aefac5d32bb1f3b51d5e9261"
dependencies = [
"byteorder",
"crc32fast",
"crossbeam-utils",
]

View File

@ -12,14 +12,14 @@ rust-version = "1.60"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[build-dependencies]
tauri-build = { version = "1.3.0", features = [] }
tauri-build = { version = "1.4.0", features = [] }
[dependencies]
anyhow = "1"
oauth2 = "4.4.1"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
tauri = { version = "1.3.0", features = [ "path-all", "dialog-all", "fs-all", "http-request", "shell-open", "shell-open-api"] }
tauri = { version = "1.3.0", features = [ "updater", "path-all", "dialog-all", "fs-all", "http-request", "shell-open", "shell-open-api"] }
tokio = { version = "1.29.1", features = ["time"] }
toml = "0.6.0"
tauri-plugin-fs-extra = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v1" }

View File

@ -8,7 +8,7 @@
},
"package": {
"productName": "kittycad-modeling-app",
"version": "0.0.4"
"version": "0.3.0"
},
"tauri": {
"allowlist": {

View File

@ -2,7 +2,8 @@ import { render, screen } from '@testing-library/react'
import { App } from './App'
import { describe, test, vi } from 'vitest'
import { BrowserRouter } from 'react-router-dom'
import { GlobalStateProvider } from './hooks/useAuthMachine'
import { GlobalStateProvider } from './components/GlobalStateProvider'
import CommandBarProvider from 'components/CommandBar'
let listener: ((rect: any) => void) | undefined = undefined
;(global as any).ResizeObserver = class ResizeObserver {
@ -43,7 +44,9 @@ function TestWrap({ children }: { children: React.ReactNode }) {
// wrap in router and xState context
return (
<BrowserRouter>
<GlobalStateProvider>{children}</GlobalStateProvider>
<CommandBarProvider>
<GlobalStateProvider>{children}</GlobalStateProvider>
</CommandBarProvider>
</BrowserRouter>
)
}

View File

@ -18,7 +18,7 @@ import {
lineHighlightField,
addLineHighlight,
} from './editor/highlightextension'
import { PaneType, Selections, Themes, useStore } from './useStore'
import { PaneType, Selections, useStore } from './useStore'
import { Logs, KCLErrors } from './components/Logs'
import { CollapsiblePanel } from './components/CollapsiblePanel'
import { MemoryPanel } from './components/MemoryPanel'
@ -41,14 +41,15 @@ import {
import { useHotkeys } from 'react-hotkeys-hook'
import { TEST } from './env'
import { getNormalisedCoordinates } from './lib/utils'
import { getSystemTheme } from './lib/getSystemTheme'
import { Themes, getSystemTheme } from './lib/theme'
import { isTauri } from './lib/isTauri'
import { useLoaderData, useParams } from 'react-router-dom'
import { writeTextFile } from '@tauri-apps/api/fs'
import { PROJECT_ENTRYPOINT } from './lib/tauriFS'
import { IndexLoaderData } from './Router'
import { toast } from 'react-hot-toast'
import { useAuthMachine } from './hooks/useAuthMachine'
import { useGlobalStateContext } from 'hooks/useGlobalStateContext'
import { onboardingPaths } from 'routes/Onboarding'
export function App() {
const { code: loadedCode, project } = useLoaderData() as IndexLoaderData
@ -83,11 +84,8 @@ export function App() {
cmdId,
setCmdId,
formatCode,
debugPanel,
theme,
openPanes,
setOpenPanes,
onboardingStatus,
didDragInStream,
setDidDragInStream,
setStreamDimensions,
@ -122,18 +120,23 @@ export function App() {
cmdId: s.cmdId,
setCmdId: s.setCmdId,
formatCode: s.formatCode,
debugPanel: s.debugPanel,
addKCLError: s.addKCLError,
theme: s.theme,
openPanes: s.openPanes,
setOpenPanes: s.setOpenPanes,
onboardingStatus: s.onboardingStatus,
didDragInStream: s.didDragInStream,
setDidDragInStream: s.setDidDragInStream,
setStreamDimensions: s.setStreamDimensions,
streamDimensions: s.streamDimensions,
}))
const [token] = useAuthMachine((s) => s?.context?.token)
const {
auth: {
context: { token },
},
settings: {
context: { showDebugPanel, theme, onboardingStatus },
},
} = useGlobalStateContext()
const editorTheme = theme === Themes.System ? getSystemTheme() : theme
@ -152,7 +155,7 @@ export function App() {
useHotkeys('shift + d', () => togglePane('debug'))
const paneOpacity =
onboardingStatus === 'camera'
onboardingStatus === onboardingPaths.CAMERA
? 'opacity-20'
: didDragInStream
? 'opacity-40'
@ -250,9 +253,9 @@ export function App() {
const streamWidth = streamRef?.current?.offsetWidth
const streamHeight = streamRef?.current?.offsetHeight
const width = streamWidth ? streamWidth * pixelDensity : 0
const width = streamWidth ? streamWidth : 0
const quadWidth = Math.round(width / 4) * 4
const height = streamHeight ? streamHeight * pixelDensity : 0
const height = streamHeight ? streamHeight : 0
const quadHeight = Math.round(height / 4) * 4
useLayoutEffect(() => {
@ -276,6 +279,8 @@ export function App() {
useEffect(() => {
if (!isStreamReady) return
if (!engineCommandManager) return
let unsubFn: any[] = []
const asyncWrap = async () => {
try {
if (!code) {
@ -286,27 +291,12 @@ export function App() {
setAst(_ast)
resetLogs()
resetKCLErrors()
if (engineCommandManager) {
engineCommandManager.endSession()
engineCommandManager.startNewSession()
}
if (!engineCommandManager) return
engineCommandManager.endSession()
engineCommandManager.startNewSession()
const programMemory = await _executor(
_ast,
{
root: {
log: {
type: 'userVal',
value: (a: any) => {
addLog(a)
},
__meta: [
{
pathToNode: [],
sourceRange: [0, 0],
},
],
},
_0: {
type: 'userVal',
value: 0,
@ -328,33 +318,37 @@ export function App() {
__meta: [],
},
},
pendingMemory: {},
},
engineCommandManager,
{ bodyType: 'root' },
[]
engineCommandManager
)
const { artifactMap, sourceRangeMap } =
await engineCommandManager.waitForAllCommands()
setArtifactMap({ artifactMap, sourceRangeMap })
engineCommandManager.onHover((id) => {
if (!id) {
setHighlightRange([0, 0])
} else {
const sourceRange = sourceRangeMap[id]
setHighlightRange(sourceRange)
}
const unSubHover = engineCommandManager.subscribeToUnreliable({
event: 'highlight_set_entity',
callback: ({ data }) => {
if (!data?.entity_id) {
setHighlightRange([0, 0])
} else {
const sourceRange = sourceRangeMap[data.entity_id]
setHighlightRange(sourceRange)
}
},
})
engineCommandManager.onClick((selections) => {
if (!selections) {
setCursor2()
return
}
const { id, type } = selections
setCursor2({ range: sourceRangeMap[id], type })
const unSubClick = engineCommandManager.subscribeTo({
event: 'select_with_point',
callback: ({ data }) => {
if (!data?.entity_id) {
setCursor2()
return
}
const sourceRange = sourceRangeMap[data.entity_id]
setCursor2({ range: sourceRange, type: 'default' })
},
})
unsubFn.push(unSubHover, unSubClick)
if (programMemory !== undefined) {
setProgramMemory(programMemory)
}
@ -371,7 +365,10 @@ export function App() {
}
}
asyncWrap()
}, [code, isStreamReady])
return () => {
unsubFn.forEach((fn) => fn())
}
}, [code, isStreamReady, engineCommandManager])
const debounceSocketSend = throttle<EngineCommand>((message) => {
engineCommandManager?.sendSceneCommand(message)
@ -525,7 +522,7 @@ export function App() {
</div>
</Resizable>
<Stream className="absolute inset-0 z-0" />
{debugPanel && (
{showDebugPanel && (
<DebugPanel
title="Debug"
className={

View File

@ -1,9 +1,12 @@
import Loading from './components/Loading'
import { useAuthMachine } from './hooks/useAuthMachine'
import { useGlobalStateContext } from 'hooks/useGlobalStateContext'
// Wrapper around protected routes, used in src/Router.tsx
export const Auth = ({ children }: React.PropsWithChildren) => {
const [isLoggedIn] = useAuthMachine((s) => s.matches('checkIfLoggedIn'))
const {
auth: { state },
} = useGlobalStateContext()
const isLoggedIn = state.matches('checkIfLoggedIn')
return isLoggedIn ? (
<Loading>Loading KittyCAD Modeling App...</Loading>

View File

@ -3,8 +3,15 @@ import {
createBrowserRouter,
Outlet,
redirect,
useLocation,
RouterProvider,
} from 'react-router-dom'
import {
matchRoutes,
createRoutesFromChildren,
useNavigationType,
} from 'react-router'
import { useEffect } from 'react'
import { ErrorPage } from './components/ErrorPage'
import { Settings } from './routes/Settings'
import Onboarding, {
@ -24,7 +31,47 @@ import {
} from './lib/tauriFS'
import { metadata, type Metadata } from 'tauri-plugin-fs-extra-api'
import DownloadAppBanner from './components/DownloadAppBanner'
import { GlobalStateProvider } from './hooks/useAuthMachine'
import { GlobalStateProvider } from './components/GlobalStateProvider'
import {
SETTINGS_PERSIST_KEY,
settingsMachine,
} from './machines/settingsMachine'
import { ContextFrom } from 'xstate'
import CommandBarProvider from 'components/CommandBar'
import { TEST, VITE_KC_SENTRY_DSN } from './env'
import * as Sentry from '@sentry/react'
if (VITE_KC_SENTRY_DSN && !TEST) {
Sentry.init({
dsn: VITE_KC_SENTRY_DSN,
// TODO(paultag): pass in the right env here.
// environment: "production",
integrations: [
new Sentry.BrowserTracing({
routingInstrumentation: Sentry.reactRouterV6Instrumentation(
useEffect,
useLocation,
useNavigationType,
createRoutesFromChildren,
matchRoutes
),
}),
new Sentry.Replay(),
],
// Set tracesSampleRate to 1.0 to capture 100%
// of transactions for performance monitoring.
tracesSampleRate: 1.0,
// TODO: Add in kittycad.io endpoints
tracePropagationTargets: ['localhost'],
// Capture Replay for 10% of all sessions,
// plus for 100% of sessions with an error
replaysSessionSampleRate: 0.1,
replaysOnErrorSampleRate: 1.0,
})
}
const prependRoutes =
(routesObject: Record<string, string>) => (prepend: string) => {
@ -68,7 +115,11 @@ const addGlobalContextToElements = (
'element' in route
? {
...route,
element: <GlobalStateProvider>{route.element}</GlobalStateProvider>,
element: (
<CommandBarProvider>
<GlobalStateProvider>{route.element}</GlobalStateProvider>
</CommandBarProvider>
),
}
: route
)
@ -95,26 +146,25 @@ const router = createBrowserRouter(
request,
params,
}): Promise<IndexLoaderData | Response> => {
const store = localStorage.getItem('store')
if (store === null) {
return redirect(paths.ONBOARDING.INDEX)
} else {
const status = JSON.parse(store).state.onboardingStatus || ''
const notEnRouteToOnboarding =
!request.url.includes(paths.ONBOARDING.INDEX) &&
request.method === 'GET'
// '' is the initial state, 'done' and 'dismissed' are the final states
const hasValidOnboardingStatus =
(status !== undefined && status.length === 0) ||
!(status === 'done' || status === 'dismissed')
const shouldRedirectToOnboarding =
notEnRouteToOnboarding && hasValidOnboardingStatus
const fetchedStorage = localStorage?.getItem(SETTINGS_PERSIST_KEY)
const persistedSettings = JSON.parse(fetchedStorage || '{}') as Partial<
ContextFrom<typeof settingsMachine>
>
if (shouldRedirectToOnboarding) {
return redirect(
makeUrlPathRelative(paths.ONBOARDING.INDEX) + status
)
}
const status = persistedSettings.onboardingStatus || ''
const notEnRouteToOnboarding = !request.url.includes(
paths.ONBOARDING.INDEX
)
// '' is the initial state, 'done' and 'dismissed' are the final states
const hasValidOnboardingStatus =
status.length === 0 || !(status === 'done' || status === 'dismissed')
const shouldRedirectToOnboarding =
notEnRouteToOnboarding && hasValidOnboardingStatus
if (shouldRedirectToOnboarding) {
return redirect(
makeUrlPathRelative(paths.ONBOARDING.INDEX) + status.slice(1)
)
}
if (params.id && params.id !== 'new') {
@ -164,9 +214,23 @@ const router = createBrowserRouter(
if (!isTauri()) {
return redirect(paths.FILE + '/new')
}
const projectDir = await initializeProjectDirectory()
const projectsNoMeta = (await readDir(projectDir.dir)).filter(
const fetchedStorage = localStorage?.getItem(SETTINGS_PERSIST_KEY)
const persistedSettings = JSON.parse(fetchedStorage || '{}') as Partial<
ContextFrom<typeof settingsMachine>
>
const projectDir = await initializeProjectDirectory(
persistedSettings.defaultDirectory || ''
)
if (projectDir !== persistedSettings.defaultDirectory) {
localStorage.setItem(
SETTINGS_PERSIST_KEY,
JSON.stringify({
...persistedSettings,
defaultDirectory: projectDir,
})
)
}
const projectsNoMeta = (await readDir(projectDir)).filter(
isProjectDirectory
)
const projects = await Promise.all(

60
src/Toolbar.module.css Normal file
View File

@ -0,0 +1,60 @@
.toolbarWrapper {
@apply relative;
}
.toolbar {
@apply flex gap-4 items-center rounded-full;
@apply border border-cool-20/30 bg-cool-10/50;
}
:global(.dark) .toolbar {
@apply border-cool-100/50 bg-cool-120/50;
}
:global(.sketch) .toolbar {
@apply border-fern-20/20 bg-fern-10/20;
}
:global(.dark .sketch) .toolbar {
@apply border-fern-120/50 bg-fern-100/30;
}
.toolbarCap {
@apply text-sm font-bold;
@apply bg-cool-20/50 text-cool-100;
}
:global(.dark) .toolbarCap {
@apply bg-cool-90/50 text-cool-30;
}
:global(.sketch) .toolbarCap {
@apply bg-fern-20/50 text-fern-100;
}
:global(.dark .sketch) .toolbarCap {
@apply bg-fern-90/50 text-fern-30;
}
.label {
@apply self-stretch flex items-center px-4 py-1;
@apply rounded-l-full;
}
.popoverToggle {
@apply self-stretch m-0 flex items-center px-4 py-1;
@apply rounded-r-full border-none;
@apply hover:bg-cool-20;
}
:global(.dark) .popoverToggle {
@apply hover:bg-cool-90;
}
:global(.sketch) .popoverToggle {
@apply hover:bg-fern-20;
}
:global(.dark .sketch) .popoverToggle {
@apply hover:bg-fern-90;
}

View File

@ -11,6 +11,11 @@ import { SetAngleLength } from './components/Toolbar/setAngleLength'
import { ConvertToVariable } from './components/Toolbar/ConvertVariable'
import { SetAbsDistance } from './components/Toolbar/SetAbsDistance'
import { SetAngleBetween } from './components/Toolbar/SetAngleBetween'
import { Fragment, useEffect } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faSearch, faX } from '@fortawesome/free-solid-svg-icons'
import { Popover, Transition } from '@headlessui/react'
import styles from './Toolbar.module.css'
export const Toolbar = () => {
const {
@ -29,72 +34,26 @@ export const Toolbar = () => {
programMemory: s.programMemory,
}))
return (
<div>
{guiMode.mode === 'default' && (
<button
onClick={() => {
setGuiMode({
mode: 'sketch',
sketchMode: 'selectFace',
})
}}
>
Start Sketch
</button>
)}
{guiMode.mode === 'canEditExtrude' && (
<button
onClick={() => {
if (!ast) return
const pathToNode = getNodePathFromSourceRange(
ast,
selectionRanges.codeBasedSelections[0].range
)
const { modifiedAst } = sketchOnExtrudedFace(
ast,
pathToNode,
programMemory
)
updateAst(modifiedAst)
}}
>
SketchOnFace
</button>
)}
{(guiMode.mode === 'canEditSketch' || false) && (
<button
onClick={() => {
setGuiMode({
mode: 'sketch',
sketchMode: 'sketchEdit',
pathToNode: guiMode.pathToNode,
rotation: guiMode.rotation,
position: guiMode.position,
})
}}
>
Edit Sketch
</button>
)}
{guiMode.mode === 'canEditSketch' && (
<>
useEffect(() => {
console.log('guiMode', guiMode)
}, [guiMode])
function ToolbarButtons() {
return (
<>
{guiMode.mode === 'default' && (
<button
onClick={() => {
if (!ast) return
const pathToNode = getNodePathFromSourceRange(
ast,
selectionRanges.codeBasedSelections[0].range
)
const { modifiedAst, pathToExtrudeArg } = extrudeSketch(
ast,
pathToNode
)
updateAst(modifiedAst, { focusPath: pathToExtrudeArg })
setGuiMode({
mode: 'sketch',
sketchMode: 'selectFace',
})
}}
>
ExtrudeSketch
Start Sketch
</button>
)}
{guiMode.mode === 'canEditExtrude' && (
<button
onClick={() => {
if (!ast) return
@ -102,77 +61,182 @@ export const Toolbar = () => {
ast,
selectionRanges.codeBasedSelections[0].range
)
const { modifiedAst, pathToExtrudeArg } = extrudeSketch(
const { modifiedAst } = sketchOnExtrudedFace(
ast,
pathToNode,
false
programMemory
)
updateAst(modifiedAst, { focusPath: pathToExtrudeArg })
updateAst(modifiedAst)
}}
>
ExtrudeSketch (w/o pipe)
SketchOnFace
</button>
</>
)}
{guiMode.mode === 'sketch' && (
<button onClick={() => setGuiMode({ mode: 'default' })}>
Exit sketch
</button>
)}
{toolTips
.filter(
// (sketchFnName) => !['angledLineThatIntersects'].includes(sketchFnName)
(sketchFnName) => ['line'].includes(sketchFnName)
)
.map((sketchFnName) => {
if (
guiMode.mode !== 'sketch' ||
!('isTooltip' in guiMode || guiMode.sketchMode === 'sketchEdit')
)
return null
return (
)}
{(guiMode.mode === 'canEditSketch' || false) && (
<button
onClick={() => {
setGuiMode({
mode: 'sketch',
sketchMode: 'sketchEdit',
pathToNode: guiMode.pathToNode,
rotation: guiMode.rotation,
position: guiMode.position,
})
}}
>
Edit Sketch
</button>
)}
{guiMode.mode === 'canEditSketch' && (
<>
<button
key={sketchFnName}
onClick={() =>
setGuiMode({
...guiMode,
...(guiMode.sketchMode === sketchFnName
? {
sketchMode: 'sketchEdit',
// todo: ...guiMod is adding isTooltip: true, will probably just fix with xstate migtaion
}
: {
sketchMode: sketchFnName,
isTooltip: true,
}),
})
}
onClick={() => {
if (!ast) return
const pathToNode = getNodePathFromSourceRange(
ast,
selectionRanges.codeBasedSelections[0].range
)
const { modifiedAst, pathToExtrudeArg } = extrudeSketch(
ast,
pathToNode
)
updateAst(modifiedAst, { focusPath: pathToExtrudeArg })
}}
>
{sketchFnName}
{guiMode.sketchMode === sketchFnName && '✅'}
ExtrudeSketch
</button>
<button
onClick={() => {
if (!ast) return
const pathToNode = getNodePathFromSourceRange(
ast,
selectionRanges.codeBasedSelections[0].range
)
const { modifiedAst, pathToExtrudeArg } = extrudeSketch(
ast,
pathToNode,
false
)
updateAst(modifiedAst, { focusPath: pathToExtrudeArg })
}}
>
ExtrudeSketch (w/o pipe)
</button>
</>
)}
{guiMode.mode === 'sketch' && (
<button onClick={() => setGuiMode({ mode: 'default' })}>
Exit sketch
</button>
)}
{toolTips
.filter(
// (sketchFnName) => !['angledLineThatIntersects'].includes(sketchFnName)
(sketchFnName) => ['line'].includes(sketchFnName)
)
})}
<br></br>
<ConvertToVariable />
<HorzVert horOrVert="horizontal" />
<HorzVert horOrVert="vertical" />
<EqualLength />
<EqualAngle />
<SetHorzVertDistance buttonType="alignEndsVertically" />
<SetHorzVertDistance buttonType="setHorzDistance" />
<SetAbsDistance buttonType="snapToYAxis" />
<SetAbsDistance buttonType="xAbs" />
<SetHorzVertDistance buttonType="alignEndsHorizontally" />
<SetAbsDistance buttonType="snapToXAxis" />
<SetHorzVertDistance buttonType="setVertDistance" />
<SetAbsDistance buttonType="yAbs" />
<SetAngleLength angleOrLength="setAngle" />
<SetAngleLength angleOrLength="setLength" />
<Intersect />
<RemoveConstrainingValues />
<SetAngleBetween />
</div>
.map((sketchFnName) => {
if (
guiMode.mode !== 'sketch' ||
!('isTooltip' in guiMode || guiMode.sketchMode === 'sketchEdit')
)
return null
return (
<button
key={sketchFnName}
onClick={() =>
setGuiMode({
...guiMode,
...(guiMode.sketchMode === sketchFnName
? {
sketchMode: 'sketchEdit',
// todo: ...guiMod is adding isTooltip: true, will probably just fix with xstate migtaion
}
: {
sketchMode: sketchFnName,
isTooltip: true,
}),
})
}
>
{sketchFnName}
{guiMode.sketchMode === sketchFnName && '✅'}
</button>
)
})}
<ConvertToVariable />
<HorzVert horOrVert="horizontal" />
<HorzVert horOrVert="vertical" />
<EqualLength />
<EqualAngle />
<SetHorzVertDistance buttonType="alignEndsVertically" />
<SetHorzVertDistance buttonType="setHorzDistance" />
<SetAbsDistance buttonType="snapToYAxis" />
<SetAbsDistance buttonType="xAbs" />
<SetHorzVertDistance buttonType="alignEndsHorizontally" />
<SetAbsDistance buttonType="snapToXAxis" />
<SetHorzVertDistance buttonType="setVertDistance" />
<SetAbsDistance buttonType="yAbs" />
<SetAngleLength angleOrLength="setAngle" />
<SetAngleLength angleOrLength="setLength" />
<Intersect />
<RemoveConstrainingValues />
<SetAngleBetween />
</>
)
}
return (
<Popover className={styles.toolbarWrapper + ' ' + guiMode.mode}>
<div className={styles.toolbar}>
<span className={styles.toolbarCap + ' ' + styles.label}>
{guiMode.mode === 'sketch' ? '2D' : '3D'}
</span>
<menu className="flex flex-1 gap-2 py-0.5 overflow-hidden whitespace-nowrap">
<ToolbarButtons />
</menu>
<Popover.Button
className={styles.toolbarCap + ' ' + styles.popoverToggle}
>
<FontAwesomeIcon icon={faSearch} />
</Popover.Button>
</div>
<Transition
as={Fragment}
enter="transition ease-out duration-200"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition ease-out duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<Popover.Overlay className="fixed inset-0 bg-chalkboard-110/20 dark:bg-chalkboard-110/50" />
</Transition>
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="opacity-0 translate-y-1 scale-95"
enterTo="opacity-100 translate-y-0 scale-100"
leave="transition ease-out duration-75"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-2"
>
<Popover.Panel className="absolute top-0 w-screen max-w-xl left-1/2 -translate-x-1/2 flex flex-col gap-8 bg-chalkboard-10 dark:bg-chalkboard-100 p-5 rounded border border-chalkboard-20/30 dark:border-chalkboard-70/50">
<section className="flex justify-between items-center">
<p
className={`${styles.toolbarCap} ${styles.label} !self-center rounded-r-full w-fit`}
>
You're in {guiMode.mode === 'sketch' ? '2D' : '3D'}
</p>
<Popover.Button className="p-2 flex items-center justify-center rounded-sm bg-chalkboard-20 text-chalkboard-110 dark:bg-chalkboard-70 dark:text-chalkboard-20 border-none hover:bg-chalkboard-30 dark:hover:bg-chalkboard-60">
<FontAwesomeIcon icon={faX} className="w-4 h-4" />
</Popover.Button>
</section>
<section>
<ToolbarButtons />
</section>
</Popover.Panel>
</Transition>
</Popover>
)
}

View File

@ -8,11 +8,13 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
const iconSizes = {
sm: 12,
md: 14.4,
lg: 18,
lg: 20,
xl: 28,
}
export interface ActionIconProps extends React.PropsWithChildren {
icon?: SolidIconDefinition | BrandIconDefinition
className?: string
bgClassName?: string
iconClassName?: string
size?: keyof typeof iconSizes
@ -20,6 +22,7 @@ export interface ActionIconProps extends React.PropsWithChildren {
export const ActionIcon = ({
icon = faCircleExclamation,
className,
bgClassName,
iconClassName,
size = 'md',
@ -28,7 +31,9 @@ export const ActionIcon = ({
return (
<div
className={
'p-1 w-fit inline-grid place-content-center ' +
`p-${
size === 'xl' ? '2' : '1'
} w-fit inline-grid place-content-center ${className} ` +
(bgClassName ||
'bg-chalkboard-100 group-hover:bg-chalkboard-90 hover:bg-chalkboard-90 dark:bg-liquid-20 dark:group-hover:bg-liquid-10 dark:hover:bg-liquid-10')
}
@ -40,7 +45,7 @@ export const ActionIcon = ({
height={iconSizes[size]}
className={
iconClassName ||
'text-liquid-20 group-hover:text-liquid-10 hover:text-liquid-10 dark:text-liquid-100 dark:group-hover:text-liquid-100 dark:hover:text-liquid-100'
'text-liquid-20 h-auto group-hover:text-liquid-10 hover:text-liquid-10 dark:text-liquid-100 dark:group-hover:text-liquid-100 dark:hover:text-liquid-100'
}
/>
)}

View File

@ -0,0 +1,7 @@
/*
Some CSS cannot be represented
in Tailwind, such as complex grid layouts.
*/
.header {
grid-template-columns: 1fr auto 1fr;
}

View File

@ -2,7 +2,8 @@ import { Toolbar } from '../Toolbar'
import UserSidebarMenu from './UserSidebarMenu'
import { ProjectWithEntryPointMetadata } from '../Router'
import ProjectSidebarMenu from './ProjectSidebarMenu'
import { useAuthMachine } from '../hooks/useAuthMachine'
import { useGlobalStateContext } from 'hooks/useGlobalStateContext'
import styles from './AppHeader.module.css'
interface AppHeaderProps extends React.PropsWithChildren {
showToolbar?: boolean
@ -18,12 +19,18 @@ export const AppHeader = ({
className = '',
enableMenu = false,
}: AppHeaderProps) => {
const [user] = useAuthMachine((s) => s?.context?.user)
const {
auth: {
context: { user },
},
} = useGlobalStateContext()
return (
<header
className={
'overlaid-panes sticky top-0 z-20 py-1 px-5 bg-chalkboard-10/50 dark:bg-chalkboard-100/50 border-b dark:border-b-2 border-chalkboard-30 dark:border-chalkboard-90 flex justify-between items-center ' +
(showToolbar ? 'grid ' : 'flex justify-between ') +
styles.header +
' overlaid-panes sticky top-0 z-20 py-1 px-5 bg-chalkboard-10/70 dark:bg-chalkboard-100/50 border-b dark:border-b-2 border-chalkboard-30 dark:border-chalkboard-90 items-center ' +
className
}
>
@ -35,7 +42,11 @@ export const AppHeader = ({
</div>
)}
{/* If there are children, show them, otherwise show User menu */}
{children || <UserSidebarMenu user={user} />}
{children || (
<div className="ml-auto">
<UserSidebarMenu user={user} />
</div>
)}
</header>
)
}

View File

@ -1,10 +1,10 @@
.panel {
@apply relative overflow-auto z-0;
@apply bg-chalkboard-20/40;
@apply bg-chalkboard-10/70 backdrop-blur-sm;
}
:global(.dark) .panel {
@apply bg-chalkboard-110/50;
@apply bg-chalkboard-110/50 backdrop-blur-0;
}
.header {

View File

@ -0,0 +1,290 @@
import { Combobox, Dialog, Transition } from '@headlessui/react'
import {
Dispatch,
Fragment,
SetStateAction,
createContext,
useState,
} from 'react'
import { useHotkeys } from 'react-hotkeys-hook'
import { ActionIcon } from './ActionIcon'
import { faSearch } from '@fortawesome/free-solid-svg-icons'
import Fuse from 'fuse.js'
import { Command, SubCommand } from '../lib/commands'
import { useCommandsContext } from 'hooks/useCommandsContext'
export type SortedCommand = {
item: Partial<Command | SubCommand> & { name: string }
}
export const CommandsContext = createContext(
{} as {
commands: Command[]
addCommands: (commands: Command[]) => void
removeCommands: (commands: Command[]) => void
commandBarOpen: boolean
setCommandBarOpen: Dispatch<SetStateAction<boolean>>
}
)
export const CommandBarProvider = ({
children,
}: {
children: React.ReactNode
}) => {
const [commands, internalSetCommands] = useState([] as Command[])
const [commandBarOpen, setCommandBarOpen] = useState(false)
const addCommands = (newCommands: Command[]) => {
internalSetCommands((prevCommands) => [...newCommands, ...prevCommands])
}
const removeCommands = (newCommands: Command[]) => {
internalSetCommands((prevCommands) =>
prevCommands.filter((command) => !newCommands.includes(command))
)
}
return (
<CommandsContext.Provider
value={{
commands,
addCommands,
removeCommands,
commandBarOpen,
setCommandBarOpen,
}}
>
{children}
<CommandBar />
</CommandsContext.Provider>
)
}
const CommandBar = () => {
const { commands, commandBarOpen, setCommandBarOpen } = useCommandsContext()
useHotkeys('meta+k', () => {
if (commands.length === 0) return
setCommandBarOpen(!commandBarOpen)
})
const [selectedCommand, setSelectedCommand] = useState<SortedCommand | null>(
null
)
// keep track of the current subcommand index
const [subCommandIndex, setSubCommandIndex] = useState<number>()
const [subCommandData, setSubCommandData] = useState<{
[key: string]: string
}>({})
// if the subcommand index is null, we're not in a subcommand
const inSubCommand =
selectedCommand &&
'meta' in selectedCommand.item &&
selectedCommand.item.meta?.args !== undefined &&
subCommandIndex !== undefined
const currentSubCommand =
inSubCommand && 'meta' in selectedCommand.item
? selectedCommand.item.meta?.args[subCommandIndex]
: undefined
const [query, setQuery] = useState('')
const availableCommands =
inSubCommand && currentSubCommand
? currentSubCommand.type === 'string'
? query
? [{ name: query }]
: currentSubCommand.options
: currentSubCommand.options
: commands
const fuse = new Fuse(availableCommands || [], {
keys: ['name', 'description'],
})
const filteredCommands = query
? fuse.search(query)
: availableCommands?.map((c) => ({ item: c } as SortedCommand))
function clearState() {
setQuery('')
setCommandBarOpen(false)
setSelectedCommand(null)
setSubCommandIndex(undefined)
setSubCommandData({})
}
function handleCommandSelection(entry: SortedCommand) {
// If we have subcommands and have not yet gathered all the
// data required from them, set the selected command to the
// current command and increment the subcommand index
if (selectedCommand === null && 'meta' in entry.item && entry.item.meta) {
setSelectedCommand(entry)
setSubCommandIndex(0)
setQuery('')
return
}
const { item } = entry
// If we have just selected a command with no subcommands, run it
const isCommandWithoutSubcommands =
'callback' in item && !('meta' in item && item.meta)
if (isCommandWithoutSubcommands) {
if (item.callback === undefined) return
item.callback()
setCommandBarOpen(false)
return
}
// If we have subcommands and have not yet gathered all the
// data required from them, set the selected command to the
// current command and increment the subcommand index
if (
selectedCommand &&
subCommandIndex !== undefined &&
'meta' in selectedCommand.item
) {
const subCommand = selectedCommand.item.meta?.args[subCommandIndex]
if (subCommand) {
const newSubCommandData = {
...subCommandData,
[subCommand.name]: item.name,
}
const newSubCommandIndex = subCommandIndex + 1
// If we have subcommands and have gathered all the data required
// from them, run the command with the gathered data
if (
selectedCommand.item.callback &&
selectedCommand.item.meta?.args.length === newSubCommandIndex
) {
selectedCommand.item.callback(newSubCommandData)
setCommandBarOpen(false)
} else {
// Otherwise, set the subcommand data and increment the subcommand index
setSubCommandData(newSubCommandData)
setSubCommandIndex(newSubCommandIndex)
setQuery('')
}
}
}
}
function getDisplayValue(command: Command) {
if (command.meta?.displayValue === undefined || !command.meta.args)
return command.name
return command.meta?.displayValue(
command.meta.args.map((c) =>
subCommandData[c.name] ? subCommandData[c.name] : `<${c.name}>`
)
)
}
return (
<Transition.Root
show={
commandBarOpen &&
availableCommands?.length !== undefined &&
availableCommands.length > 0
}
as={Fragment}
afterLeave={() => clearState()}
>
<Dialog
onClose={() => {
setCommandBarOpen(false)
clearState()
}}
className="fixed inset-0 overflow-y-auto p-4 pt-[25vh]"
>
<Transition.Child
enter="duration-100 ease-out"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="duration-75 ease-in"
leaveFrom="opacity-100"
leaveTo="opacity-0"
as={Fragment}
>
<Dialog.Overlay className="fixed z-40 inset-0 bg-chalkboard-10/70 dark:bg-chalkboard-110/50" />
</Transition.Child>
<Transition.Child
enter="duration-100 ease-out"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="duration-75 ease-in"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
as={Fragment}
>
<Combobox
value={selectedCommand}
onChange={handleCommandSelection}
className="rounded relative mx-auto z-40 p-2 bg-chalkboard-10 dark:bg-chalkboard-100 border dark:border-chalkboard-70 max-w-xl w-full shadow-lg"
as="div"
>
<div className="flex gap-2 items-center">
<ActionIcon icon={faSearch} size="xl" className="rounded-sm" />
<div>
{inSubCommand && (
<p className="text-liquid-70 dark:text-liquid-30">
{selectedCommand.item &&
getDisplayValue(selectedCommand.item as Command)}
</p>
)}
<Combobox.Input
onChange={(event) => setQuery(event.target.value)}
className="bg-transparent focus:outline-none w-full"
onKeyDown={(event) => {
if (event.metaKey && event.key === 'k')
setCommandBarOpen(false)
if (
inSubCommand &&
event.key === 'Backspace' &&
!event.currentTarget.value
) {
setSubCommandIndex(subCommandIndex - 1)
setSelectedCommand(null)
}
}}
displayValue={(command: SortedCommand) =>
command !== null ? command.item.name : ''
}
placeholder={
inSubCommand
? `Enter <${currentSubCommand?.name}>`
: 'Search for a command'
}
value={query}
autoCapitalize="off"
autoComplete="off"
autoCorrect="off"
spellCheck="false"
/>
</div>
</div>
<Combobox.Options static className="max-h-96 overflow-y-auto">
{filteredCommands?.map((commandResult) => (
<Combobox.Option
key={commandResult.item.name}
value={commandResult}
className="my-2 first:mt-4 last:mb-4 ui-active:bg-liquid-10 dark:ui-active:bg-liquid-90 py-1 px-2"
>
<p>{commandResult.item.name}</p>
{(commandResult.item as SubCommand).description && (
<p className="mt-0.5 text-liquid-70 dark:text-liquid-30 text-sm">
{(commandResult.item as SubCommand).description}
</p>
)}
</Combobox.Option>
))}
</Combobox.Options>
</Combobox>
</Transition.Child>
</Dialog>
</Transition.Root>
)
}
export default CommandBarProvider

View File

@ -5,6 +5,7 @@ import { EngineCommand } from '../lang/std/engineConnection'
import { useState } from 'react'
import { ActionButton } from '../components/ActionButton'
import { faCheck } from '@fortawesome/free-solid-svg-icons'
import { isReducedMotion } from 'lang/util'
type SketchModeCmd = Extract<
Extract<EngineCommand, { type: 'modeling_cmd_req' }>['cmd'],
@ -22,7 +23,7 @@ export const DebugPanel = ({ className, ...props }: CollapsiblePanelProps) => {
y_axis: { x: 0, y: 1, z: 0 },
distance_to_plane: 100,
ortho: true,
animated: true, // TODO #273 get prefers reduced motion from CSS
animated: !isReducedMotion(),
})
if (!sketchModeCmd) return null
return (

View File

@ -39,6 +39,7 @@ export const ExportButton = ({ children, className }: ExportButtonProps) => {
const initialValues: OutputFormat = {
type: defaultType,
storage: 'embedded',
presentation: 'compact',
}
const formik = useFormik({
initialValues,
@ -82,6 +83,8 @@ export const ExportButton = ({ children, className }: ExportButtonProps) => {
},
})
const yo = formik.values
return (
<>
<ActionButton
@ -127,7 +130,9 @@ export const ExportButton = ({ children, className }: ExportButtonProps) => {
id="storage"
name="storage"
onChange={formik.handleChange}
value={formik.values.storage}
value={
'storage' in formik.values ? formik.values.storage : ''
}
className="bg-chalkboard-20 dark:bg-chalkboard-90 w-full"
>
{type === 'gltf' && (

View File

@ -0,0 +1,158 @@
import { useMachine } from '@xstate/react'
import { useNavigate } from 'react-router-dom'
import { paths } from '../Router'
import {
authCommandBarMeta,
authMachine,
TOKEN_PERSIST_KEY,
} from '../machines/authMachine'
import withBaseUrl from '../lib/withBaseURL'
import React, { createContext, useEffect, useRef } from 'react'
import useStateMachineCommands from '../hooks/useStateMachineCommands'
import {
SETTINGS_PERSIST_KEY,
settingsCommandBarMeta,
settingsMachine,
} from 'machines/settingsMachine'
import { toast } from 'react-hot-toast'
import { setThemeClass, Themes } from 'lib/theme'
import {
AnyStateMachine,
ContextFrom,
InterpreterFrom,
Prop,
StateFrom,
} from 'xstate'
import { useCommandsContext } from 'hooks/useCommandsContext'
type MachineContext<T extends AnyStateMachine> = {
state: StateFrom<T>
context: ContextFrom<T>
send: Prop<InterpreterFrom<T>, 'send'>
}
type GlobalContext = {
auth: MachineContext<typeof authMachine>
settings: MachineContext<typeof settingsMachine>
}
export const GlobalStateContext = createContext({} as GlobalContext)
export const GlobalStateProvider = ({
children,
}: {
children: React.ReactNode
}) => {
const navigate = useNavigate()
const { commands } = useCommandsContext()
// Settings machine setup
const retrievedSettings = useRef(
localStorage?.getItem(SETTINGS_PERSIST_KEY) || '{}'
)
const persistedSettings = Object.assign(
settingsMachine.initialState.context,
JSON.parse(retrievedSettings.current) as Partial<
(typeof settingsMachine)['context']
>
)
const [settingsState, settingsSend] = useMachine(settingsMachine, {
context: persistedSettings,
actions: {
toastSuccess: (context, event) => {
const truncatedNewValue =
'data' in event && event.data instanceof Object
? (context[Object.keys(event.data)[0] as keyof typeof context]
.toString()
.substring(0, 28) as any)
: undefined
toast.success(
event.type +
(truncatedNewValue
? ` to "${truncatedNewValue}${
truncatedNewValue.length === 28 ? '...' : ''
}"`
: '')
)
},
},
})
useStateMachineCommands({
state: settingsState,
send: settingsSend,
commands,
owner: 'settings',
commandBarMeta: settingsCommandBarMeta,
})
// Listen for changes to the system theme and update the app theme accordingly
// This is only done if the theme setting is set to 'system'.
// It can't be done in XState (in an invoked callback, for example)
// because there doesn't seem to be a good way to listen to
// events outside of the machine that also depend on the machine's context
useEffect(() => {
const matcher = window.matchMedia('(prefers-color-scheme: dark)')
const listener = (e: MediaQueryListEvent) => {
if (settingsState.context.theme !== 'system') return
setThemeClass(e.matches ? Themes.Dark : Themes.Light)
}
matcher.addEventListener('change', listener)
return () => matcher.removeEventListener('change', listener)
}, [settingsState.context])
// Auth machine setup
const [authState, authSend] = useMachine(authMachine, {
actions: {
goToSignInPage: () => {
navigate(paths.SIGN_IN)
logout()
},
goToIndexPage: () => {
if (window.location.pathname.includes(paths.SIGN_IN)) {
navigate(paths.INDEX)
}
},
},
})
useStateMachineCommands({
state: authState,
send: authSend,
commands,
commandBarMeta: authCommandBarMeta,
owner: 'auth',
})
return (
<GlobalStateContext.Provider
value={{
auth: {
state: authState,
context: authState.context,
send: authSend,
},
settings: {
state: settingsState,
context: settingsState.context,
send: settingsSend,
},
}}
>
{children}
</GlobalStateContext.Provider>
)
}
export default GlobalStateProvider
export function logout() {
const url = withBaseUrl('/logout')
localStorage.removeItem(TOKEN_PERSIST_KEY)
return fetch(url, {
method: 'POST',
credentials: 'include',
})
}

View File

@ -1,7 +1,8 @@
import ReactJson from 'react-json-view'
import { useEffect } from 'react'
import { Themes, useStore } from '../useStore'
import { useStore } from '../useStore'
import { CollapsiblePanel, CollapsiblePanelProps } from './CollapsiblePanel'
import { Themes } from '../lib/theme'
const ReactJsonTypeHack = ReactJson as any

View File

@ -14,12 +14,12 @@ describe('processMemory', () => {
return a - 2
}
const otherVar = myFn(5)
const theExtrude = startSketchAt([0, 0])
const theExtrude = startSketchAt([0, 0])
|> lineTo([-2.4, myVar], %)
|> lineTo([-0.76, otherVar], %)
|> extrude(4, %)
const theSketch = startSketchAt([0, 0])
|> lineTo([-3.35, 0.17], %)
|> lineTo([0.98, 5.16], %)
@ -28,30 +28,20 @@ describe('processMemory', () => {
show(theExtrude, theSketch)`
const ast = parser_wasm(code)
const programMemory = await enginelessExecutor(ast, {
root: {
log: {
type: 'userVal',
value: (a: any) => {
console.log('raw log', a)
},
__meta: [],
},
},
pendingMemory: {},
root: {},
})
const output = processMemory(programMemory)
expect(output.myVar).toEqual(5)
expect(output.myFn).toEqual('__function__')
expect(output.otherVar).toEqual(3)
expect(output).toEqual({
myVar: 5,
myFn: '__function__',
myFn: undefined,
otherVar: 3,
theExtrude: [],
theSketch: [
{ type: 'toPoint', to: [-3.35, 0.17], from: [0, 0] },
{ type: 'toPoint', to: [0.98, 5.16], from: [-3.35, 0.17] },
{ type: 'toPoint', to: [2.15, 4.32], from: [0.98, 5.16] },
{ type: 'toPoint', to: [-3.35, 0.17], from: [0, 0], name: '' },
{ type: 'toPoint', to: [0.98, 5.16], from: [-3.35, 0.17], name: '' },
{ type: 'toPoint', to: [2.15, 4.32], from: [0.98, 5.16], name: '' },
],
})
})

View File

@ -1,8 +1,9 @@
import ReactJson from 'react-json-view'
import { CollapsiblePanel, CollapsiblePanelProps } from './CollapsiblePanel'
import { Themes, useStore } from '../useStore'
import { useStore } from '../useStore'
import { useMemo } from 'react'
import { ProgramMemory } from '../lang/executor'
import { Themes } from '../lib/theme'
interface MemoryPanelProps extends CollapsiblePanelProps {
theme?: Exclude<Themes, Themes.System>

View File

@ -1,10 +1,11 @@
import { Popover } from '@headlessui/react'
import { Popover, Transition } from '@headlessui/react'
import { ActionButton } from './ActionButton'
import { faHome } from '@fortawesome/free-solid-svg-icons'
import { ProjectWithEntryPointMetadata, paths } from '../Router'
import { isTauri } from '../lib/isTauri'
import { Link } from 'react-router-dom'
import { ExportButton } from './ExportButton'
import { Fragment } from 'react'
const ProjectSidebarMenu = ({
project,
@ -34,7 +35,7 @@ const ProjectSidebarMenu = ({
) : (
<Popover className="relative">
<Popover.Button
className="border-0 px-1 pr-2 pl-0 flex items-center gap-4 focus:outline-none focus:ring-2 focus:ring-energy-50"
className="border-0 p-0.5 pr-2 flex items-center gap-4 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-energy-50"
data-testid="project-sidebar-toggle"
>
<img
@ -46,54 +47,77 @@ const ProjectSidebarMenu = ({
{isTauri() && project?.name ? project.name : 'KittyCAD Modeling App'}
</span>
</Popover.Button>
<Popover.Overlay className="fixed z-20 inset-0 bg-chalkboard-110/50" />
<Transition
enter="duration-200 ease-out"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="duration-100 ease-in"
leaveFrom="opacity-100"
leaveTo="opacity-0"
as={Fragment}
>
<Popover.Overlay className="fixed z-20 inset-0 bg-chalkboard-110/50" />
</Transition>
<Popover.Panel className="fixed inset-0 right-auto z-30 w-64 bg-chalkboard-10 dark:bg-chalkboard-100 border border-energy-100 shadow-md rounded-r-lg overflow-hidden">
<div className="flex items-center gap-4 px-4 py-3 bg-energy-100">
<img
src="/kitt-8bit-winking.svg"
alt="KittyCAD App"
className="h-9 w-auto"
/>
<Transition
enter="duration-100 ease-out"
enterFrom="opacity-0 -translate-x-1/4"
enterTo="opacity-100 translate-x-0"
leave="duration-75 ease-in"
leaveFrom="opacity-100 translate-x-0"
leaveTo="opacity-0 -translate-x-4"
as={Fragment}
>
<Popover.Panel className="fixed inset-0 right-auto z-30 w-64 bg-chalkboard-10 dark:bg-chalkboard-100 border border-energy-100 dark:border-energy-100/50 shadow-md rounded-r-lg overflow-hidden">
<div className="flex items-center gap-4 px-4 py-3 bg-energy-100">
<img
src="/kitt-8bit-winking.svg"
alt="KittyCAD App"
className="h-9 w-auto"
/>
<div>
<p
className="m-0 text-energy-10 text-mono"
data-testid="projectName"
>
{project?.name ? project.name : 'KittyCAD Modeling App'}
</p>
{project?.entrypoint_metadata && (
<p className="m-0 text-energy-40 text-xs" data-testid="createdAt">
Created{' '}
{project?.entrypoint_metadata.createdAt.toLocaleDateString()}
<div>
<p
className="m-0 text-energy-10 text-mono"
data-testid="projectName"
>
{project?.name ? project.name : 'KittyCAD Modeling App'}
</p>
{project?.entrypoint_metadata && (
<p
className="m-0 text-energy-40 text-xs"
data-testid="createdAt"
>
Created{' '}
{project?.entrypoint_metadata.createdAt.toLocaleDateString()}
</p>
)}
</div>
</div>
<div className="p-4 flex flex-col gap-2">
<ExportButton
className={{
button:
'border-transparent dark:border-transparent dark:hover:border-energy-60',
}}
>
Export Model
</ExportButton>
{isTauri() && (
<ActionButton
Element="link"
to={paths.HOME}
icon={{
icon: faHome,
}}
className="border-transparent dark:border-transparent dark:hover:border-energy-60"
>
Go to Home
</ActionButton>
)}
</div>
</div>
<div className="p-4 flex flex-col gap-2">
<ExportButton
className={{
button:
'border-transparent dark:border-transparent dark:hover:border-energy-60',
}}
>
Export Model
</ExportButton>
{isTauri() && (
<ActionButton
Element="link"
to={paths.HOME}
icon={{
icon: faHome,
}}
className="border-transparent dark:border-transparent dark:hover:border-energy-60"
>
Go to Home
</ActionButton>
)}
</div>
</Popover.Panel>
</Popover.Panel>
</Transition>
</Popover>
)
}

View File

@ -2,7 +2,8 @@ import { fireEvent, render, screen } from '@testing-library/react'
import UserSidebarMenu from './UserSidebarMenu'
import { BrowserRouter } from 'react-router-dom'
import { Models } from '@kittycad/lib'
import { GlobalStateProvider } from '../hooks/useAuthMachine'
import { GlobalStateProvider } from './GlobalStateProvider'
import CommandBarProvider from './CommandBar'
type User = Models['User_type']
@ -94,7 +95,9 @@ function TestWrap({ children }: { children: React.ReactNode }) {
// wrap in router and xState context
return (
<BrowserRouter>
<GlobalStateProvider>{children}</GlobalStateProvider>
<CommandBarProvider>
<GlobalStateProvider>{children}</GlobalStateProvider>
</CommandBarProvider>
</BrowserRouter>
)
}

View File

@ -1,13 +1,13 @@
import { Popover } from '@headlessui/react'
import { Popover, Transition } from '@headlessui/react'
import { ActionButton } from './ActionButton'
import { faBars, faGear, faSignOutAlt } from '@fortawesome/free-solid-svg-icons'
import { faGithub } from '@fortawesome/free-brands-svg-icons'
import { useNavigate } from 'react-router-dom'
import { useState } from 'react'
import { Fragment, useState } from 'react'
import { paths } from '../Router'
import makeUrlPathRelative from '../lib/makeUrlPathRelative'
import { useAuthMachine } from '../hooks/useAuthMachine'
import { Models } from '@kittycad/lib'
import { useGlobalStateContext } from 'hooks/useGlobalStateContext'
type User = Models['User_type']
@ -15,7 +15,9 @@ const UserSidebarMenu = ({ user }: { user?: User }) => {
const displayedName = getDisplayName(user)
const [imageLoadFailed, setImageLoadFailed] = useState(false)
const navigate = useNavigate()
const [_, send] = useAuthMachine()
const {
auth: { send },
} = useGlobalStateContext()
// Fallback logic for displaying user's "name":
// 1. user.name
@ -59,82 +61,102 @@ const UserSidebarMenu = ({ user }: { user?: User }) => {
Menu
</ActionButton>
)}
<Popover.Overlay className="fixed z-20 inset-0 bg-chalkboard-110/50" />
<Transition
enter="duration-200 ease-out"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="duration-100 ease-in"
leaveFrom="opacity-100"
leaveTo="opacity-0"
as={Fragment}
>
<Popover.Overlay className="fixed z-20 inset-0 bg-chalkboard-110/50" />
</Transition>
<Popover.Panel className="fixed inset-0 left-auto z-30 w-64 bg-chalkboard-10 dark:bg-chalkboard-100 border border-liquid-100 shadow-md rounded-l-lg overflow-hidden">
{({ close }) => (
<>
{user && (
<div className="flex items-center gap-4 px-4 py-3 bg-liquid-100">
{user.image && !imageLoadFailed && (
<div className="rounded-full shadow-inner overflow-hidden">
<img
src={user.image}
alt={user.name || ''}
className="h-8 w-8"
referrerPolicy="no-referrer"
onError={() => setImageLoadFailed(true)}
/>
</div>
)}
<div>
<p
className="m-0 text-liquid-10 text-mono"
data-testid="username"
>
{displayedName || ''}
</p>
{displayedName !== user.email && (
<p
className="m-0 text-liquid-40 text-xs"
data-testid="email"
>
{user.email}
</p>
<Transition
enter="duration-100 ease-out"
enterFrom="opacity-0 translate-x-1/4"
enterTo="opacity-100 translate-x-0"
leave="duration-75 ease-in"
leaveFrom="opacity-100 translate-x-0"
leaveTo="opacity-0 translate-x-4"
as={Fragment}
>
<Popover.Panel className="fixed inset-0 left-auto z-30 w-64 bg-chalkboard-10 dark:bg-chalkboard-100 border border-liquid-100 dark:border-liquid-100/50 shadow-md rounded-l-lg overflow-hidden">
{({ close }) => (
<>
{user && (
<div className="flex items-center gap-4 px-4 py-3 bg-liquid-100">
{user.image && !imageLoadFailed && (
<div className="rounded-full shadow-inner overflow-hidden">
<img
src={user.image}
alt={user.name || ''}
className="h-8 w-8"
referrerPolicy="no-referrer"
onError={() => setImageLoadFailed(true)}
/>
</div>
)}
<div>
<p
className="m-0 text-liquid-10 text-mono"
data-testid="username"
>
{displayedName || ''}
</p>
{displayedName !== user.email && (
<p
className="m-0 text-liquid-40 text-xs"
data-testid="email"
>
{user.email}
</p>
)}
</div>
</div>
)}
<div className="p-4 flex flex-col gap-2">
<ActionButton
Element="button"
icon={{ icon: faGear }}
className="border-transparent dark:border-transparent dark:hover:border-liquid-60"
onClick={() => {
// since /settings is a nested route the sidebar doesn't close
// automatically when navigating to it
close()
navigate(makeUrlPathRelative(paths.SETTINGS))
}}
>
Settings
</ActionButton>
<ActionButton
Element="link"
to="https://github.com/KittyCAD/modeling-app/discussions"
icon={{ icon: faGithub }}
className="border-transparent dark:border-transparent dark:hover:border-liquid-60"
>
Request a feature
</ActionButton>
<ActionButton
Element="button"
onClick={() => send('Log out')}
icon={{
icon: faSignOutAlt,
bgClassName: 'bg-destroy-80',
iconClassName:
'text-destroy-20 group-hover:text-destroy-10 hover:text-destroy-10',
}}
className="border-transparent dark:border-transparent hover:border-destroy-40 dark:hover:border-destroy-60"
>
Sign out
</ActionButton>
</div>
)}
<div className="p-4 flex flex-col gap-2">
<ActionButton
Element="button"
icon={{ icon: faGear }}
className="border-transparent dark:border-transparent dark:hover:border-liquid-60"
onClick={() => {
// since /settings is a nested route the sidebar doesn't close
// automatically when navigating to it
close()
navigate(makeUrlPathRelative(paths.SETTINGS))
}}
>
Settings
</ActionButton>
<ActionButton
Element="link"
to="https://github.com/KittyCAD/modeling-app/discussions"
icon={{ icon: faGithub }}
className="border-transparent dark:border-transparent dark:hover:border-liquid-60"
>
Request a feature
</ActionButton>
<ActionButton
Element="button"
onClick={() => send('logout')}
icon={{
icon: faSignOutAlt,
bgClassName: 'bg-destroy-80',
iconClassName:
'text-destroy-20 group-hover:text-destroy-10 hover:text-destroy-10',
}}
className="border-transparent dark:border-transparent hover:border-destroy-40 dark:hover:border-destroy-60"
>
Sign out
</ActionButton>
</div>
</>
)}
</Popover.Panel>
</>
)}
</Popover.Panel>
</Transition>
</Popover>
)
}

View File

@ -8,4 +8,9 @@ export const VITE_KC_API_WS_MODELING_URL = import.meta.env
.VITE_KC_API_WS_MODELING_URL
export const VITE_KC_API_BASE_URL = import.meta.env.VITE_KC_API_BASE_URL
export const VITE_KC_SITE_BASE_URL = import.meta.env.VITE_KC_SITE_BASE_URL
export const VITE_KC_CONNECTION_WEBRTC_REPORT_STATS_MS = import.meta.env
.VITE_KC_CONNECTION_WEBRTC_REPORT_STATS_MS
export const VITE_KC_CONNECTION_TIMEOUT_MS = import.meta.env
.VITE_KC_CONNECTION_TIMEOUT_MS
export const VITE_KC_SENTRY_DSN = import.meta.env.VITE_KC_SENTRY_DSN
export const TEST = import.meta.env.TEST

View File

@ -1,54 +0,0 @@
import { createActorContext } from '@xstate/react'
import { useNavigate } from 'react-router-dom'
import { paths } from '../Router'
import { authMachine, TOKEN_PERSIST_KEY } from '../lib/authMachine'
import withBaseUrl from '../lib/withBaseURL'
export const AuthMachineContext = createActorContext(authMachine)
export const GlobalStateProvider = ({
children,
}: {
children: React.ReactNode
}) => {
const navigate = useNavigate()
return (
<AuthMachineContext.Provider
machine={() =>
authMachine.withConfig({
actions: {
goToSignInPage: () => {
navigate(paths.SIGN_IN)
logout()
},
goToIndexPage: () => navigate(paths.INDEX),
},
})
}
>
{children}
</AuthMachineContext.Provider>
)
}
export function useAuthMachine<T>(
selector: (
state: Parameters<Parameters<typeof AuthMachineContext.useSelector>[0]>[0]
) => T = () => null as T
): [T, ReturnType<typeof AuthMachineContext.useActor>[1]] {
// useActor api normally `[state, send] = useActor`
// we're only interested in send because of the selector
const send = AuthMachineContext.useActor()[1]
const selection = AuthMachineContext.useSelector(selector)
return [selection, send]
}
export function logout() {
const url = withBaseUrl('/logout')
localStorage.removeItem(TOKEN_PERSIST_KEY)
return fetch(url, {
method: 'POST',
credentials: 'include',
})
}

View File

@ -0,0 +1,6 @@
import { CommandsContext } from 'components/CommandBar'
import { useContext } from 'react'
export const useCommandsContext = () => {
return useContext(CommandsContext)
}

View File

@ -0,0 +1,6 @@
import { GlobalStateContext } from 'components/GlobalStateProvider'
import { useContext } from 'react'
export const useGlobalStateContext = () => {
return useContext(GlobalStateContext)
}

View File

@ -0,0 +1,42 @@
import { useEffect } from 'react'
import { AnyStateMachine, StateFrom } from 'xstate'
import { Command, CommandBarMeta, createMachineCommand } from '../lib/commands'
import { useCommandsContext } from './useCommandsContext'
interface UseStateMachineCommandsArgs<T extends AnyStateMachine> {
state: StateFrom<T>
send: Function
commandBarMeta?: CommandBarMeta
commands: Command[]
owner: string
}
export default function useStateMachineCommands<T extends AnyStateMachine>({
state,
send,
commandBarMeta,
owner,
}: UseStateMachineCommandsArgs<T>) {
const { addCommands, removeCommands } = useCommandsContext()
useEffect(() => {
const newCommands = state.nextEvents
.filter((e) => !['done.', 'error.'].some((n) => e.includes(n)))
.map((type) =>
createMachineCommand<T>({
type,
state,
send,
commandBarMeta,
owner,
})
)
.filter((c) => c !== null) as Command[]
addCommands(newCommands)
return () => {
removeCommands(newCommands)
}
}, [state])
}

View File

@ -1,67 +0,0 @@
import { useEffect } from 'react'
import { useStore } from '../useStore'
import { parse } from 'toml'
import {
createDir,
BaseDirectory,
readDir,
readTextFile,
} from '@tauri-apps/api/fs'
export const useTauriBoot = () => {
const { defaultDir, setDefaultDir, setHomeMenuItems } = useStore((s) => ({
defaultDir: s.defaultDir,
setDefaultDir: s.setDefaultDir,
setHomeMenuItems: s.setHomeMenuItems,
}))
useEffect(() => {
const isTauri = (window as any).__TAURI__
if (!isTauri) return
const run = async () => {
if (!defaultDir.base) {
createDir('puffin-projects/example', {
dir: BaseDirectory.Home,
recursive: true,
})
setDefaultDir({
base: BaseDirectory.Home,
dir: 'puffin-projects',
})
} else {
const directoryResult = await readDir(defaultDir.dir, {
dir: defaultDir.base,
recursive: true,
})
const puffinProjects = directoryResult.filter(
(file) =>
!file?.name?.startsWith('.') &&
file?.children?.find((child) => child?.name === 'wax.toml')
)
const tomlFiles = await Promise.all(
puffinProjects.map(async (file) => {
const parsedToml = parse(
await readTextFile(`${file.path}/wax.toml`, {
dir: defaultDir.base,
})
)
const mainPath = parsedToml?.package?.main
const projectName = parsedToml?.package?.name
return {
file,
mainPath,
projectName,
}
})
)
setHomeMenuItems(
tomlFiles.map(({ file, mainPath, projectName }) => ({
name: projectName,
path: mainPath ? `${file.path}/${mainPath}` : file.path,
}))
)
}
}
run()
}, [])
}

View File

@ -86,8 +86,18 @@ code {
@apply bg-transparent;
}
#code-mirror-override .cm-activeLine,
#code-mirror-override .cm-activeLineGutter {
@apply bg-liquid-10/50;
}
.dark #code-mirror-override .cm-activeLine,
.dark #code-mirror-override .cm-activeLineGutter {
@apply bg-liquid-80/50;
}
#code-mirror-override .cm-gutters {
@apply bg-chalkboard-10/50;
@apply bg-chalkboard-10/30;
}
.dark #code-mirror-override .cm-gutters {
@ -99,14 +109,24 @@ code {
}
#code-mirror-override .cm-cursor {
display: block;
width: 200px;
background: linear-gradient(
to right,
rgb(0, 55, 94) 0%,
#0084e2ff 2%,
#0084e255 5%,
transparent 100%
);
width: 1ch;
@apply bg-liquid-40 mix-blend-multiply;
animation: blink 2s ease-out infinite;
}
.dark #code-mirror-override .cm-cursor {
@apply bg-liquid-50;
}
@keyframes blink {
0%,
100% {
opacity: 0;
}
15% {
opacity: 0.75;
}
}
.react-json-view {

View File

@ -2,23 +2,10 @@ import ReactDOM from 'react-dom/client'
import './index.css'
import reportWebVitals from './reportWebVitals'
import { Toaster } from 'react-hot-toast'
import { Themes, useStore } from './useStore'
import { Router } from './Router'
import { HotkeysProvider } from 'react-hotkeys-hook'
import { getSystemTheme } from './lib/getSystemTheme'
const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement)
function setThemeClass(state: Partial<{ theme: Themes }>) {
const systemTheme = state.theme === Themes.System && getSystemTheme()
if (state.theme === Themes.Dark || systemTheme === Themes.Dark) {
document.body.classList.add('dark')
} else {
document.body.classList.remove('dark')
}
}
const { theme } = useStore.getState()
setThemeClass({ theme })
useStore.subscribe(setThemeClass)
root.render(
<HotkeysProvider>

View File

@ -1,4 +1,5 @@
import { parser_wasm } from './abstractSyntaxTree'
import { KCLUnexpectedError } from './errors'
import { initPromise } from './rust'
beforeAll(() => initPromise)
@ -213,7 +214,6 @@ describe('testing function declaration', () => {
id: null,
params: [],
body: {
type: 'BlockStatement',
start: 17,
end: 19,
body: [],
@ -266,7 +266,6 @@ describe('testing function declaration', () => {
},
],
body: {
type: 'BlockStatement',
start: 21,
end: 39,
body: [
@ -343,7 +342,6 @@ const myVar = funcN(1, 2)`
},
],
body: {
type: 'BlockStatement',
start: 21,
end: 37,
body: [
@ -1570,8 +1568,8 @@ const key = 'c'`
it('comments nested within a block statement', () => {
const code = `const mySketch = startSketchAt([0,0])
|> lineTo({ to: [0, 1], tag: 'myPath' }, %)
|> lineTo([1, 1], %) /* this is
a comment
|> lineTo([1, 1], %) /* this is
a comment
spanning a few lines */
|> lineTo({ to: [1,0], tag: "rightPath" }, %)
|> close(%)
@ -1584,9 +1582,8 @@ const key = 'c'`
expect(sketchNonCodeMeta[indexOfSecondLineToExpression]).toEqual({
type: 'NoneCodeNode',
start: 106,
end: 168,
value:
' /* this is \n a comment \n spanning a few lines */\n ',
end: 166,
value: ' /* this is\n a comment\n spanning a few lines */\n ',
})
})
it('comments in a pipe expression', () => {
@ -1706,3 +1703,19 @@ describe('should recognise callExpresions in binaryExpressions', () => {
])
})
})
describe('parsing errors', () => {
it('should return an error when there is a unexpected closed curly brace', async () => {
const code = `const myVar = startSketchAt([}], %)`
let _theError
try {
const result = expect(parser_wasm(code))
console.log('result', result)
} catch (e) {
_theError = e
}
const theError = _theError as any
expect(theError).toEqual(new KCLUnexpectedError('Brace', [[29, 30]]))
})
})

View File

@ -3,21 +3,21 @@ import { parse_js } from '../wasm-lib/pkg/wasm_lib'
import { initPromise } from './rust'
import { Token } from './tokeniser'
import { KCLError } from './errors'
import { KclError as RustKclError } from '../wasm-lib/kcl/bindings/KclError'
export const rangeTypeFix = (ranges: number[][]): [number, number][] =>
ranges.map(([start, end]) => [start, end])
export const parser_wasm = (code: string): Program => {
try {
const program: Program = parse_js(code)
return program
} catch (e: any) {
const parsed: {
kind: string
msg: string
sourceRanges: [number, number][]
} = JSON.parse(e.toString())
const kclError: KCLError = new KCLError(
const parsed: RustKclError = JSON.parse(e.toString())
const kclError = new KCLError(
parsed.kind,
parsed.msg,
parsed.sourceRanges
rangeTypeFix(parsed.sourceRanges)
)
console.log(kclError)
@ -31,15 +31,11 @@ export async function asyncParser(code: string): Promise<Program> {
const program: Program = parse_js(code)
return program
} catch (e: any) {
const parsed: {
kind: string
msg: string
sourceRanges: [number, number][]
} = JSON.parse(e.toString())
const kclError: KCLError = new KCLError(
const parsed: RustKclError = JSON.parse(e.toString())
const kclError = new KCLError(
parsed.kind,
parsed.msg,
parsed.sourceRanges
rangeTypeFix(parsed.sourceRanges)
)
console.log(kclError)

View File

@ -1,20 +1,20 @@
export type { Program } from '../wasm-lib/bindings/Program'
export type { Value } from '../wasm-lib/bindings/Value'
export type { ObjectExpression } from '../wasm-lib/bindings/ObjectExpression'
export type { MemberExpression } from '../wasm-lib/bindings/MemberExpression'
export type { PipeExpression } from '../wasm-lib/bindings/PipeExpression'
export type { VariableDeclaration } from '../wasm-lib/bindings/VariableDeclaration'
export type { PipeSubstitution } from '../wasm-lib/bindings/PipeSubstitution'
export type { Identifier } from '../wasm-lib/bindings/Identifier'
export type { UnaryExpression } from '../wasm-lib/bindings/UnaryExpression'
export type { BinaryExpression } from '../wasm-lib/bindings/BinaryExpression'
export type { ReturnStatement } from '../wasm-lib/bindings/ReturnStatement'
export type { ExpressionStatement } from '../wasm-lib/bindings/ExpressionStatement'
export type { CallExpression } from '../wasm-lib/bindings/CallExpression'
export type { VariableDeclarator } from '../wasm-lib/bindings/VariableDeclarator'
export type { BinaryPart } from '../wasm-lib/bindings/BinaryPart'
export type { Literal } from '../wasm-lib/bindings/Literal'
export type { ArrayExpression } from '../wasm-lib/bindings/ArrayExpression'
export type { Program } from '../wasm-lib/kcl/bindings/Program'
export type { Value } from '../wasm-lib/kcl/bindings/Value'
export type { ObjectExpression } from '../wasm-lib/kcl/bindings/ObjectExpression'
export type { MemberExpression } from '../wasm-lib/kcl/bindings/MemberExpression'
export type { PipeExpression } from '../wasm-lib/kcl/bindings/PipeExpression'
export type { VariableDeclaration } from '../wasm-lib/kcl/bindings/VariableDeclaration'
export type { PipeSubstitution } from '../wasm-lib/kcl/bindings/PipeSubstitution'
export type { Identifier } from '../wasm-lib/kcl/bindings/Identifier'
export type { UnaryExpression } from '../wasm-lib/kcl/bindings/UnaryExpression'
export type { BinaryExpression } from '../wasm-lib/kcl/bindings/BinaryExpression'
export type { ReturnStatement } from '../wasm-lib/kcl/bindings/ReturnStatement'
export type { ExpressionStatement } from '../wasm-lib/kcl/bindings/ExpressionStatement'
export type { CallExpression } from '../wasm-lib/kcl/bindings/CallExpression'
export type { VariableDeclarator } from '../wasm-lib/kcl/bindings/VariableDeclarator'
export type { BinaryPart } from '../wasm-lib/kcl/bindings/BinaryPart'
export type { Literal } from '../wasm-lib/kcl/bindings/Literal'
export type { ArrayExpression } from '../wasm-lib/kcl/bindings/ArrayExpression'
export type SyntaxType =
| 'Program'
@ -22,7 +22,6 @@ export type SyntaxType =
| 'BinaryExpression'
| 'CallExpression'
| 'Identifier'
| 'BlockStatement'
| 'ReturnStatement'
| 'VariableDeclaration'
| 'VariableDeclarator'

View File

@ -11,51 +11,52 @@ describe('testing artifacts', () => {
const mySketch001 = startSketchAt([0, 0])
|> lineTo([-1.59, -1.54], %)
|> lineTo([0.46, -5.82], %)
// |> rx(45, %)
// |> rx(45, %)
show(mySketch001)`
const programMemory = await enginelessExecutor(parser_wasm(code))
// @ts-ignore
const shown = programMemory?.return?.map(
// @ts-ignore
(a) => programMemory?.root?.[a.name]
)
expect(shown).toEqual([
{
type: 'sketchGroup',
start: {
type: 'base',
to: [0, 0],
from: [0, 0],
name: '',
__geoMeta: {
id: '66366561-6465-4734-a463-366330356563',
id: expect.any(String),
sourceRange: [21, 42],
pathToNode: [],
},
},
value: [
{
type: 'toPoint',
name: '',
to: [-1.59, -1.54],
from: [0, 0],
__geoMeta: {
sourceRange: [48, 73],
id: '30366338-6462-4330-a364-303935626163',
pathToNode: [],
id: expect.any(String),
},
},
{
type: 'toPoint',
to: [0.46, -5.82],
from: [-1.59, -1.54],
name: '',
__geoMeta: {
sourceRange: [79, 103],
id: '32653334-6331-4231-b162-663334363535',
pathToNode: [],
id: expect.any(String),
},
},
],
position: [0, 0, 0],
rotation: [0, 0, 0, 1],
id: '39643164-6130-4734-b432-623638393262',
__meta: [{ sourceRange: [21, 42], pathToNode: [] }],
id: expect.any(String),
__meta: [{ sourceRange: [21, 42] }],
},
])
})
@ -69,21 +70,20 @@ const mySketch001 = startSketchAt([0, 0])
|> extrude(2, %)
show(mySketch001)`
const programMemory = await enginelessExecutor(parser_wasm(code))
// @ts-ignore
const shown = programMemory?.return?.map(
// @ts-ignore
(a) => programMemory?.root?.[a.name]
)
expect(shown).toEqual([
{
type: 'extrudeGroup',
id: '65383433-3839-4333-b836-343263636638',
id: expect.any(String),
value: [],
height: 2,
position: [0, 0, 0],
rotation: [0, 0, 0, 1],
__meta: [
{ sourceRange: [127, 140], pathToNode: [] },
{ sourceRange: [21, 42], pathToNode: [] },
],
__meta: [{ sourceRange: [21, 42] }],
},
])
})
@ -106,37 +106,33 @@ const sk2 = startSketchAt([0, 0])
|> lineTo([2.5, 0], %)
// |> transform(theTransf, %)
|> extrude(2, %)
show(theExtrude, sk2)`
const programMemory = await enginelessExecutor(parser_wasm(code))
// @ts-ignore
const geos = programMemory?.return?.map(
// @ts-ignore
({ name }) => programMemory?.root?.[name]
)
expect(geos).toEqual([
{
type: 'extrudeGroup',
id: '63333330-3631-4230-b664-623132643731',
id: expect.any(String),
value: [],
height: 2,
position: [0, 0, 0],
rotation: [0, 0, 0, 1],
__meta: [
{ sourceRange: [212, 227], pathToNode: [] },
{ sourceRange: [13, 34], pathToNode: [] },
],
__meta: [{ sourceRange: [13, 34] }],
},
{
type: 'extrudeGroup',
id: '33316639-3438-4661-a334-663262383737',
id: expect.any(String),
value: [],
height: 2,
position: [0, 0, 0],
rotation: [0, 0, 0, 1],
__meta: [
{ sourceRange: [453, 466], pathToNode: [] },
{ sourceRange: [302, 323], pathToNode: [] },
],
__meta: [{ sourceRange: [302, 323] }],
},
])
})

View File

@ -1,11 +1,13 @@
import { Diagnostic } from '@codemirror/lint'
import { KclError as RustKclError } from '../wasm-lib/kcl/bindings/KclError'
type ExtractKind<T> = T extends { kind: infer K } ? K : never
export class KCLError {
kind: string | undefined
kind: ExtractKind<RustKclError> | 'name'
sourceRanges: [number, number][]
msg: string
constructor(
kind: string | undefined,
kind: ExtractKind<RustKclError> | 'name',
msg: string,
sourceRanges: [number, number][]
) {
@ -39,11 +41,18 @@ export class KCLTypeError extends KCLError {
export class KCLUnimplementedError extends KCLError {
constructor(msg: string, sourceRanges: [number, number][]) {
super('unimplemented feature', msg, sourceRanges)
super('unimplemented', msg, sourceRanges)
Object.setPrototypeOf(this, KCLUnimplementedError.prototype)
}
}
export class KCLUnexpectedError extends KCLError {
constructor(msg: string, sourceRanges: [number, number][]) {
super('unexpected', msg, sourceRanges)
Object.setPrototypeOf(this, KCLUnexpectedError.prototype)
}
}
export class KCLValueAlreadyDefined extends KCLError {
constructor(key: string, sourceRanges: [number, number][]) {
super('name', `Key ${key} was already defined elsewhere`, sourceRanges)

View File

@ -5,7 +5,7 @@ import { ProgramMemory } from './executor'
import { initPromise } from './rust'
import { enginelessExecutor } from '../lib/testHelpers'
import { vi } from 'vitest'
import { KCLUndefinedValueError } from './errors'
import { KCLError } from './errors'
beforeAll(() => initPromise)
@ -30,29 +30,6 @@ const newVar = myVar + 1`
const { root } = await exe(code)
expect(root.myVar.value).toBe('a str another str')
})
it('test with function call', async () => {
const code = `
const myVar = "hello"
log(5, myVar)`
const programMemoryOverride: ProgramMemory['root'] = {
log: {
type: 'userVal',
value: vi.fn(),
__meta: [
{
sourceRange: [0, 0],
pathToNode: [],
},
],
},
}
const { root } = await enginelessExecutor(parser_wasm(code), {
root: programMemoryOverride,
pendingMemory: {},
})
expect(root.myVar.value).toBe('hello')
expect(programMemoryOverride.log.value).toHaveBeenCalledWith(5, 'hello')
})
it('fn funcN = () => {} execute', async () => {
const { root } = await exe(
[
@ -84,8 +61,7 @@ show(mySketch)
from: [0, 0],
__geoMeta: {
sourceRange: [43, 80],
id: '37333036-3033-4432-b530-643030303837',
pathToNode: [],
id: expect.any(String),
},
name: 'myPath',
},
@ -93,10 +69,10 @@ show(mySketch)
type: 'toPoint',
to: [2, 3],
from: [0, 2],
name: '',
__geoMeta: {
sourceRange: [86, 102],
id: '32343136-3330-4134-a462-376437386365',
pathToNode: [],
id: expect.any(String),
},
},
{
@ -105,8 +81,7 @@ show(mySketch)
from: [2, 3],
__geoMeta: {
sourceRange: [108, 151],
id: '32306132-6130-4138-b832-636363326330',
pathToNode: [],
id: expect.any(String),
},
name: 'rightPath',
},
@ -170,13 +145,12 @@ show(mySketch)
expect(root.mySk1).toEqual({
type: 'sketchGroup',
start: {
type: 'base',
to: [0, 0],
from: [0, 0],
name: '',
__geoMeta: {
id: '37663863-3664-4366-a637-623739336334',
id: expect.any(String),
sourceRange: [14, 34],
pathToNode: [],
},
},
value: [
@ -184,10 +158,10 @@ show(mySketch)
type: 'toPoint',
to: [1, 1],
from: [0, 0],
name: '',
__geoMeta: {
sourceRange: [40, 56],
id: '34356231-3362-4363-b935-393033353034',
pathToNode: [],
id: expect.any(String),
},
},
{
@ -196,8 +170,7 @@ show(mySketch)
from: [1, 1],
__geoMeta: {
sourceRange: [62, 100],
id: '39623339-3538-4366-b633-356630326639',
pathToNode: [],
id: expect.any(String),
},
name: 'myPath',
},
@ -205,17 +178,17 @@ show(mySketch)
type: 'toPoint',
to: [1, 1],
from: [0, 1],
name: '',
__geoMeta: {
sourceRange: [106, 122],
id: '30636135-6232-4335-b665-366562303161',
pathToNode: [],
id: expect.any(String),
},
},
],
position: [0, 0, 0],
rotation: [0, 0, 0, 1],
id: '30376661-3039-4965-b532-653665313731',
__meta: [{ sourceRange: [14, 34], pathToNode: [] }],
id: expect.any(String),
__meta: [{ sourceRange: [14, 34] }],
})
})
it('execute array expression', async () => {
@ -230,13 +203,6 @@ show(mySketch)
value: 3,
__meta: [
{
pathToNode: [
['body', ''],
[0, 'index'],
['declarations', 'VariableDeclaration'],
[0, 'index'],
['init', 'VariableDeclaration'],
],
sourceRange: [14, 15],
},
],
@ -246,25 +212,8 @@ show(mySketch)
value: [1, '2', 3, 9],
__meta: [
{
pathToNode: [
['body', ''],
[1, 'index'],
['declarations', 'VariableDeclaration'],
[0, 'index'],
['init', 'VariableDeclaration'],
],
sourceRange: [27, 49],
},
{
pathToNode: [
['body', ''],
[0, 'index'],
['declarations', 'VariableDeclaration'],
[0, 'index'],
['init', 'VariableDeclaration'],
],
sourceRange: [14, 15],
},
],
},
})
@ -280,13 +229,6 @@ show(mySketch)
value: { aStr: 'str', anum: 2, identifier: 3, binExp: 9 },
__meta: [
{
pathToNode: [
['body', ''],
[1, 'index'],
['declarations', 'VariableDeclaration'],
[0, 'index'],
['init', 'VariableDeclaration'],
],
sourceRange: [27, 83],
},
],
@ -302,13 +244,6 @@ show(mySketch)
value: '123',
__meta: [
{
pathToNode: [
['body', ''],
[1, 'index'],
['declarations', 'VariableDeclaration'],
[0, 'index'],
['init', 'VariableDeclaration'],
],
sourceRange: [41, 50],
},
],
@ -451,17 +386,18 @@ const theExtrude = startSketchAt([0, 0])
|> extrude(4, %)
show(theExtrude)`
await expect(exe(code)).rejects.toEqual(
new KCLUndefinedValueError('Memory item myVarZ not found', [[100, 106]])
new KCLError(
'undefined_value',
'memory item key `myVarZ` is not defined',
[[100, 106]]
)
)
})
})
// helpers
async function exe(
code: string,
programMemory: ProgramMemory = { root: {}, pendingMemory: {} }
) {
async function exe(code: string, programMemory: ProgramMemory = { root: {} }) {
const ast = parser_wasm(code)
const result = await enginelessExecutor(ast, programMemory)

View File

@ -1,35 +1,19 @@
import {
Program,
BinaryPart,
BinaryExpression,
PipeExpression,
ObjectExpression,
MemberExpression,
Identifier,
CallExpression,
ArrayExpression,
UnaryExpression,
} from './abstractSyntaxTreeTypes'
import { InternalFnNames } from './std/stdTypes'
import { internalFns } from './std/std'
import {
KCLUndefinedValueError,
KCLValueAlreadyDefined,
KCLSyntaxError,
KCLSemanticError,
KCLTypeError,
} from './errors'
import { Program } from './abstractSyntaxTreeTypes'
import {
EngineCommandManager,
ArtifactMap,
SourceRangeMap,
} from './std/engineConnection'
import { ProgramReturn } from '../wasm-lib/kcl/bindings/ProgramReturn'
import { execute_wasm } from '../wasm-lib/pkg/wasm_lib'
import { KCLError } from './errors'
import { KclError as RustKclError } from '../wasm-lib/kcl/bindings/KclError'
import { rangeTypeFix } from './abstractSyntaxTree'
export type SourceRange = [number, number]
export type PathToNode = [string | number, string][] // [pathKey, nodeType][]
export type Metadata = {
sourceRange: SourceRange
pathToNode: PathToNode
}
export type Position = [number, number, number]
export type Rotation = [number, number, number, number]
@ -41,7 +25,6 @@ interface BasePath {
__geoMeta: {
id: string
sourceRange: SourceRange
pathToNode: PathToNode
}
}
@ -68,9 +51,7 @@ export interface AngledLineTo extends BasePath {
interface GeoMeta {
__geoMeta: {
id: string
refId?: string
sourceRange: SourceRange
pathToNode: PathToNode
}
}
@ -118,69 +99,16 @@ type MemoryItem = UserVal | SketchGroup | ExtrudeGroup
interface Memory {
[key: string]: MemoryItem
}
interface PendingMemory {
[key: string]: Promise<MemoryItem>
}
export interface ProgramMemory {
root: Memory
pendingMemory: Partial<PendingMemory>
return?: Identifier[]
}
const addItemToMemory = (
programMemory: ProgramMemory,
key: string,
sourceRange: [[number, number]],
value: MemoryItem | Promise<MemoryItem>
) => {
const _programMemory = programMemory
if (_programMemory.root[key] || _programMemory.pendingMemory[key]) {
throw new KCLValueAlreadyDefined(key, sourceRange)
}
if (value instanceof Promise) {
_programMemory.pendingMemory[key] = value
value.then((resolvedValue) => {
_programMemory.root[key] = resolvedValue
delete _programMemory.pendingMemory[key]
})
} else {
_programMemory.root[key] = value
}
return _programMemory
}
const promisifyMemoryItem = async (obj: MemoryItem) => {
if (obj.value instanceof Promise) {
const resolvedGuy = await obj.value
return {
...obj,
value: resolvedGuy,
}
}
return obj
}
const getMemoryItem = async (
programMemory: ProgramMemory,
key: string,
sourceRanges: [number, number][]
): Promise<MemoryItem> => {
if (programMemory.root[key]) {
return programMemory.root[key]
}
if (programMemory.pendingMemory[key]) {
return programMemory.pendingMemory[key] as Promise<MemoryItem>
}
throw new KCLUndefinedValueError(`Memory item ${key} not found`, sourceRanges)
return?: ProgramReturn
}
export const executor = async (
node: Program,
programMemory: ProgramMemory = { root: {}, pendingMemory: {} },
programMemory: ProgramMemory = { root: {} },
engineCommandManager: EngineCommandManager,
options: { bodyType: 'root' | 'sketch' | 'block' } = { bodyType: 'root' },
previousPathToNode: PathToNode = [],
// work around while the gemotry is still be stored on the frontend
// will be removed when the stream UI is added.
tempMapCallback: (a: {
@ -192,9 +120,7 @@ export const executor = async (
const _programMemory = await _executor(
node,
programMemory,
engineCommandManager,
options,
previousPathToNode
engineCommandManager
)
const { artifactMap, sourceRangeMap } =
await engineCommandManager.waitForAllCommands()
@ -206,840 +132,25 @@ export const executor = async (
export const _executor = async (
node: Program,
programMemory: ProgramMemory = { root: {}, pendingMemory: {} },
engineCommandManager: EngineCommandManager,
options: { bodyType: 'root' | 'sketch' | 'block' } = { bodyType: 'root' },
previousPathToNode: PathToNode = []
programMemory: ProgramMemory = { root: {} },
engineCommandManager: EngineCommandManager
): Promise<ProgramMemory> => {
let _programMemory: ProgramMemory = {
root: {
...programMemory.root,
},
pendingMemory: {
...programMemory.pendingMemory,
},
return: programMemory.return,
}
const { body } = node
const proms: Promise<any>[] = []
for (let bodyIndex = 0; bodyIndex < body.length; bodyIndex++) {
const statement = body[bodyIndex]
if (statement.type === 'VariableDeclaration') {
for (let index = 0; index < statement.declarations.length; index++) {
const declaration = statement.declarations[index]
const variableName = declaration.id.name
const pathToNode: PathToNode = [
...previousPathToNode,
['body', ''],
[bodyIndex, 'index'],
['declarations', 'VariableDeclaration'],
[index, 'index'],
['init', 'VariableDeclaration'],
]
const sourceRange: SourceRange = [
declaration.init.start,
declaration.init.end,
]
const __meta: Metadata[] = [
{
pathToNode,
sourceRange,
},
]
if (declaration.init.type === 'PipeExpression') {
const prom = getPipeExpressionResult(
declaration.init,
_programMemory,
engineCommandManager,
pathToNode
)
proms.push(prom)
const value = await prom
if (value?.type === 'sketchGroup' || value?.type === 'extrudeGroup') {
_programMemory = addItemToMemory(
_programMemory,
variableName,
[sourceRange],
value
)
} else {
_programMemory = addItemToMemory(
_programMemory,
variableName,
[sourceRange],
{
type: 'userVal',
value,
__meta,
}
)
}
} else if (declaration.init.type === 'Identifier') {
_programMemory = addItemToMemory(
_programMemory,
variableName,
[sourceRange],
{
type: 'userVal',
value: _programMemory.root[declaration.init.name].value,
__meta,
}
)
} else if (declaration.init.type === 'Literal') {
_programMemory = addItemToMemory(
_programMemory,
variableName,
[sourceRange],
{
type: 'userVal',
value: declaration.init.value,
__meta,
}
)
} else if (declaration.init.type === 'BinaryExpression') {
const prom = getBinaryExpressionResult(
declaration.init,
_programMemory,
engineCommandManager
)
proms.push(prom)
_programMemory = addItemToMemory(
_programMemory,
variableName,
[sourceRange],
promisifyMemoryItem({
type: 'userVal',
value: prom,
__meta,
})
)
} else if (declaration.init.type === 'UnaryExpression') {
const prom = getUnaryExpressionResult(
declaration.init,
_programMemory,
engineCommandManager
)
proms.push(prom)
_programMemory = addItemToMemory(
_programMemory,
variableName,
[sourceRange],
promisifyMemoryItem({
type: 'userVal',
value: prom,
__meta,
})
)
} else if (declaration.init.type === 'ArrayExpression') {
const valueInfo: Promise<{ value: any; __meta?: Metadata }>[] =
declaration.init.elements.map(
async (element): Promise<{ value: any; __meta?: Metadata }> => {
if (element.type === 'Literal') {
return {
value: element.value,
}
} else if (element.type === 'BinaryExpression') {
const prom = getBinaryExpressionResult(
element,
_programMemory,
engineCommandManager
)
proms.push(prom)
return {
value: await prom,
}
} else if (element.type === 'PipeExpression') {
const prom = getPipeExpressionResult(
element,
_programMemory,
engineCommandManager,
pathToNode
)
proms.push(prom)
return {
value: await prom,
}
} else if (element.type === 'Identifier') {
const node = await getMemoryItem(
_programMemory,
element.name,
[[element.start, element.end]]
)
return {
value: node.value,
__meta: node.__meta[node.__meta.length - 1],
}
} else if (element.type === 'UnaryExpression') {
const prom = getUnaryExpressionResult(
element,
_programMemory,
engineCommandManager
)
proms.push(prom)
return {
value: await prom,
}
} else {
throw new KCLSyntaxError(
`Unexpected element type ${element.type} in array expression`,
// TODO: Refactor this whole block into a `switch` so that we have a specific
// type here and can put a sourceRange.
[]
)
}
}
)
const awaitedValueInfo = await Promise.all(valueInfo)
const meta = awaitedValueInfo
.filter(({ __meta }) => __meta)
.map(({ __meta }) => __meta) as Metadata[]
_programMemory = addItemToMemory(
_programMemory,
variableName,
[sourceRange],
{
type: 'userVal',
value: awaitedValueInfo.map(({ value }) => value),
__meta: [...__meta, ...meta],
}
)
} else if (declaration.init.type === 'ObjectExpression') {
const prom = executeObjectExpression(
_programMemory,
declaration.init,
engineCommandManager
)
proms.push(prom)
_programMemory = addItemToMemory(
_programMemory,
variableName,
[sourceRange],
promisifyMemoryItem({
type: 'userVal',
value: prom,
__meta,
})
)
} else if (declaration.init.type === 'FunctionExpression') {
const fnInit = declaration.init
_programMemory = addItemToMemory(
_programMemory,
declaration.id.name,
[sourceRange],
{
type: 'userVal',
value: async (...args: any[]) => {
let fnMemory: ProgramMemory = {
root: {
..._programMemory.root,
},
pendingMemory: {
..._programMemory.pendingMemory,
},
}
if (args.length > fnInit.params.length) {
throw new KCLSyntaxError(
`Too many arguments passed to function ${declaration.id.name}`,
[[declaration.start, declaration.end]]
)
} else if (args.length < fnInit.params.length) {
throw new KCLSyntaxError(
`Too few arguments passed to function ${declaration.id.name}`,
[[declaration.start, declaration.end]]
)
}
fnInit.params.forEach((param, index) => {
fnMemory = addItemToMemory(
fnMemory,
param.name,
[sourceRange],
{
type: 'userVal',
value: args[index],
__meta,
}
)
})
const prom = _executor(
fnInit.body,
fnMemory,
engineCommandManager,
{
bodyType: 'block',
}
)
proms.push(prom)
const result = (await prom).return
return result
},
__meta,
}
)
} else if (declaration.init.type === 'MemberExpression') {
await Promise.all([...proms]) // TODO wait for previous promises, does that makes sense?
const prom = getMemberExpressionResult(
declaration.init,
_programMemory
)
proms.push(prom)
_programMemory = addItemToMemory(
_programMemory,
variableName,
[sourceRange],
promisifyMemoryItem({
type: 'userVal',
value: prom,
__meta,
})
)
} else if (declaration.init.type === 'CallExpression') {
const prom = executeCallExpression(
_programMemory,
declaration.init,
engineCommandManager,
previousPathToNode
)
proms.push(prom)
_programMemory = addItemToMemory(
_programMemory,
variableName,
[sourceRange],
prom.then((a) => {
return a?.type === 'sketchGroup' || a?.type === 'extrudeGroup'
? a
: {
type: 'userVal',
value: a,
__meta,
}
})
)
} else {
throw new KCLSyntaxError(
'Unsupported declaration type: ' + declaration.init.type,
[[declaration.start, declaration.end]]
)
}
}
} else if (statement.type === 'ExpressionStatement') {
const expression = statement.expression
if (expression.type === 'CallExpression') {
const functionName = expression.callee.name
const args = expression.arguments.map((arg) => {
if (arg.type === 'Literal') {
return arg.value
} else if (arg.type === 'Identifier') {
return _programMemory.root[arg.name]?.value
}
})
if ('show' === functionName) {
if (options.bodyType !== 'root') {
throw new KCLSemanticError(
`Cannot call ${functionName} outside of a root`,
[[statement.start, statement.end]]
)
}
_programMemory.return = expression.arguments as any // todo memory redo
} else {
if (_programMemory.root[functionName] === undefined) {
throw new KCLSemanticError(`No such name ${functionName} defined`, [
[statement.start, statement.end],
])
}
_programMemory.root[functionName].value(...args)
}
}
} else if (statement.type === 'ReturnStatement') {
if (statement.argument.type === 'BinaryExpression') {
const prom = getBinaryExpressionResult(
statement.argument,
_programMemory,
engineCommandManager
)
proms.push(prom)
_programMemory.return = await prom
}
}
}
await Promise.all(proms)
return _programMemory
}
function getMemberExpressionResult(
expression: MemberExpression,
programMemory: ProgramMemory
) {
const propertyName = (
expression.property.type === 'Identifier'
? expression.property.name
: expression.property.value
) as any
const object: any =
expression.object.type === 'MemberExpression'
? getMemberExpressionResult(expression.object, programMemory)
: programMemory.root[expression.object.name]?.value
return object?.[propertyName]
}
async function getBinaryExpressionResult(
expression: BinaryExpression,
programMemory: ProgramMemory,
engineCommandManager: EngineCommandManager,
pipeInfo: {
isInPipe: boolean
previousResults: any[]
expressionIndex: number
body: PipeExpression['body']
sourceRangeOverride?: SourceRange
} = {
isInPipe: false,
previousResults: [],
expressionIndex: 0,
body: [],
}
) {
const _pipeInfo = {
...pipeInfo,
isInPipe: false,
}
const left = await getBinaryPartResult(
expression.left,
programMemory,
engineCommandManager,
_pipeInfo
)
const right = await getBinaryPartResult(
expression.right,
programMemory,
engineCommandManager,
_pipeInfo
)
if (expression.operator === '+') return left + right
if (expression.operator === '-') return left - right
if (expression.operator === '*') return left * right
if (expression.operator === '/') return left / right
if (expression.operator === '%') return left % right
}
async function getBinaryPartResult(
part: BinaryPart,
programMemory: ProgramMemory,
engineCommandManager: EngineCommandManager,
pipeInfo: {
isInPipe: boolean
previousResults: any[]
expressionIndex: number
body: PipeExpression['body']
sourceRangeOverride?: SourceRange
} = {
isInPipe: false,
previousResults: [],
expressionIndex: 0,
body: [],
}
): Promise<any> {
const _pipeInfo = {
...pipeInfo,
isInPipe: false,
}
if (part.type === 'Literal') {
return part.value
} else if (part.type === 'Identifier') {
return programMemory.root[part.name].value
} else if (part.type === 'BinaryExpression') {
const prom = getBinaryExpressionResult(
part,
programMemory,
engineCommandManager,
_pipeInfo
)
const result = await prom
return result
} else if (part.type === 'CallExpression') {
const result = await executeCallExpression(
programMemory,
part,
engineCommandManager,
[],
_pipeInfo
)
return result
}
}
async function getUnaryExpressionResult(
expression: UnaryExpression,
programMemory: ProgramMemory,
engineCommandManager: EngineCommandManager,
pipeInfo: {
isInPipe: boolean
previousResults: any[]
expressionIndex: number
body: PipeExpression['body']
sourceRangeOverride?: SourceRange
} = {
isInPipe: false,
previousResults: [],
expressionIndex: 0,
body: [],
}
) {
return -(await getBinaryPartResult(
expression.argument,
programMemory,
engineCommandManager,
{
...pipeInfo,
isInPipe: false,
}
))
}
async function getPipeExpressionResult(
expression: PipeExpression,
programMemory: ProgramMemory,
engineCommandManager: EngineCommandManager,
previousPathToNode: PathToNode = []
) {
const executedBody = await executePipeBody(
expression.body,
programMemory,
engineCommandManager,
previousPathToNode
)
const result = executedBody[executedBody.length - 1]
return result
}
async function executePipeBody(
body: PipeExpression['body'],
programMemory: ProgramMemory,
engineCommandManager: EngineCommandManager,
previousPathToNode: PathToNode = [],
expressionIndex = 0,
previousResults: any[] = []
): Promise<any[]> {
if (expressionIndex === body.length) {
return previousResults
}
const expression = body[expressionIndex]
if (expression.type === 'BinaryExpression') {
const result = await getBinaryExpressionResult(
expression,
programMemory,
try {
const memory: ProgramMemory = await execute_wasm(
JSON.stringify(node),
JSON.stringify(programMemory),
engineCommandManager
)
return executePipeBody(
body,
programMemory,
engineCommandManager,
previousPathToNode,
expressionIndex + 1,
[...previousResults, result]
return memory
} catch (e: any) {
const parsed: RustKclError = JSON.parse(e.toString())
const kclError = new KCLError(
parsed.kind,
parsed.msg,
rangeTypeFix(parsed.sourceRanges)
)
} else if (expression.type === 'CallExpression') {
return await executeCallExpression(
programMemory,
expression,
engineCommandManager,
previousPathToNode,
{
isInPipe: true,
previousResults,
expressionIndex,
body,
}
)
}
throw new KCLSyntaxError('Invalid pipe expression', [
[expression.start, expression.end],
])
}
async function executeObjectExpression(
_programMemory: ProgramMemory,
objExp: ObjectExpression,
engineCommandManager: EngineCommandManager,
pipeInfo: {
isInPipe: boolean
previousResults: any[]
expressionIndex: number
body: PipeExpression['body']
sourceRangeOverride?: SourceRange
} = {
isInPipe: false,
previousResults: [],
expressionIndex: 0,
body: [],
}
) {
const _pipeInfo = {
...pipeInfo,
isInPipe: false,
}
const obj: { [key: string]: any } = {}
const proms: Promise<any>[] = []
objExp.properties.forEach(async (property) => {
if (property.type === 'ObjectProperty') {
if (property.value.type === 'Literal') {
obj[property.key.name] = property.value.value
} else if (property.value.type === 'BinaryExpression') {
const prom = getBinaryExpressionResult(
property.value,
_programMemory,
engineCommandManager,
_pipeInfo
)
proms.push(prom)
obj[property.key.name] = await prom
} else if (property.value.type === 'PipeExpression') {
const prom = getPipeExpressionResult(
property.value,
_programMemory,
engineCommandManager
)
proms.push(prom)
obj[property.key.name] = await prom
} else if (property.value.type === 'Identifier') {
obj[property.key.name] = (
await getMemoryItem(_programMemory, property.value.name, [
[property.value.start, property.value.end],
])
).value
} else if (property.value.type === 'ObjectExpression') {
const prom = executeObjectExpression(
_programMemory,
property.value,
engineCommandManager
)
proms.push(prom)
obj[property.key.name] = await prom
} else if (property.value.type === 'ArrayExpression') {
const prom = executeArrayExpression(
_programMemory,
property.value,
engineCommandManager
)
proms.push(prom)
obj[property.key.name] = await prom
} else if (property.value.type === 'CallExpression') {
const prom = executeCallExpression(
_programMemory,
property.value,
engineCommandManager,
[],
_pipeInfo
)
proms.push(prom)
const result = await prom
obj[property.key.name] = result
} else if (property.value.type === 'UnaryExpression') {
const prom = getUnaryExpressionResult(
property.value,
_programMemory,
engineCommandManager
)
proms.push(prom)
obj[property.key.name] = await prom
} else {
throw new KCLSyntaxError(
`Unexpected property type ${property.value.type} in object expression`,
[[property.value.start, property.value.end]]
)
}
} else {
throw new KCLSyntaxError(
`Unexpected property type ${property.type} in object expression`,
[[property.value.start, property.value.end]]
)
}
})
await Promise.all(proms)
return obj
}
async function executeArrayExpression(
_programMemory: ProgramMemory,
arrExp: ArrayExpression,
engineCommandManager: EngineCommandManager,
pipeInfo: {
isInPipe: boolean
previousResults: any[]
expressionIndex: number
body: PipeExpression['body']
sourceRangeOverride?: SourceRange
} = {
isInPipe: false,
previousResults: [],
expressionIndex: 0,
body: [],
}
) {
const _pipeInfo = {
...pipeInfo,
isInPipe: false,
}
return await Promise.all(
arrExp.elements.map((el) => {
if (el.type === 'Literal') {
return el.value
} else if (el.type === 'Identifier') {
return _programMemory.root?.[el.name]?.value
} else if (el.type === 'BinaryExpression') {
return getBinaryExpressionResult(
el,
_programMemory,
engineCommandManager,
_pipeInfo
)
} else if (el.type === 'ObjectExpression') {
return executeObjectExpression(_programMemory, el, engineCommandManager)
} else if (el.type === 'CallExpression') {
const result: any = executeCallExpression(
_programMemory,
el,
engineCommandManager,
[],
_pipeInfo
)
return result
} else if (el.type === 'UnaryExpression') {
return getUnaryExpressionResult(
el,
_programMemory,
engineCommandManager,
{
...pipeInfo,
isInPipe: false,
}
)
}
throw new KCLTypeError('Invalid argument type', [[el.start, el.end]])
})
)
}
async function executeCallExpression(
programMemory: ProgramMemory,
expression: CallExpression,
engineCommandManager: EngineCommandManager,
previousPathToNode: PathToNode = [],
pipeInfo: {
isInPipe: boolean
previousResults: any[]
expressionIndex: number
body: PipeExpression['body']
sourceRangeOverride?: SourceRange
} = {
isInPipe: false,
previousResults: [],
expressionIndex: 0,
body: [],
}
) {
const {
isInPipe,
previousResults,
expressionIndex,
body,
sourceRangeOverride,
} = pipeInfo
const functionName = expression?.callee?.name
const _pipeInfo = {
...pipeInfo,
isInPipe: false,
}
const fnArgs = await Promise.all(
expression?.arguments?.map(async (arg) => {
if (arg.type === 'Literal') {
return arg.value
} else if (arg.type === 'Identifier') {
await new Promise((r) => setTimeout(r)) // push into next even loop, but also probably should fix this
const temp = await getMemoryItem(programMemory, arg.name, [
[arg.start, arg.end],
])
return temp?.type === 'userVal' ? temp.value : temp
} else if (arg.type === 'PipeSubstitution') {
return previousResults[expressionIndex - 1]
} else if (arg.type === 'ArrayExpression') {
return await executeArrayExpression(
programMemory,
arg,
engineCommandManager,
pipeInfo
)
} else if (arg.type === 'CallExpression') {
const result: any = await executeCallExpression(
programMemory,
arg,
engineCommandManager,
previousPathToNode,
_pipeInfo
)
return result
} else if (arg.type === 'ObjectExpression') {
return await executeObjectExpression(
programMemory,
arg,
engineCommandManager,
_pipeInfo
)
} else if (arg.type === 'UnaryExpression') {
return getUnaryExpressionResult(
arg,
programMemory,
engineCommandManager,
_pipeInfo
)
} else if (arg.type === 'BinaryExpression') {
return getBinaryExpressionResult(
arg,
programMemory,
engineCommandManager,
_pipeInfo
)
}
throw new KCLSyntaxError('Invalid argument type in function call', [
[arg.start, arg.end],
])
})
)
if (functionName in internalFns) {
const fnNameWithSketchOrExtrude = functionName as InternalFnNames
const result = await internalFns[fnNameWithSketchOrExtrude](
{
programMemory,
sourceRange: sourceRangeOverride || [expression.start, expression.end],
engineCommandManager,
code: JSON.stringify(expression),
},
fnArgs[0],
fnArgs[1],
fnArgs[2]
)
return isInPipe
? await executePipeBody(
body,
programMemory,
engineCommandManager,
previousPathToNode,
expressionIndex + 1,
[...previousResults, result]
)
: result
}
const result = await programMemory.root[functionName].value(...fnArgs)
return isInPipe
? await executePipeBody(
body,
programMemory,
engineCommandManager,
previousPathToNode,
expressionIndex + 1,
[...previousResults, result]
)
: result
console.log(kclError)
throw kclError
}
}

View File

@ -182,14 +182,14 @@ describe('Testing moveValueIntoNewVariable', () => {
const code = `${fn('def')}${fn('ghi')}${fn('jkl')}${fn('hmm')}
const abc = 3
const identifierGuy = 5
const yo = 5 + 6
const part001 = startSketchAt([-1.2, 4.83])
|> line([2.8, 0], %)
|> angledLine([100 + 100, 3.09], %)
|> angledLine([abc, 3.09], %)
|> angledLine([def('yo'), 3.09], %)
|> angledLine([def(yo), 3.09], %)
|> angledLine([ghi(%), 3.09], %)
|> angledLine([jkl('yo') + 2, 3.09], %)
const yo = 5 + 6
|> angledLine([jkl(yo) + 2, 3.09], %)
const yo2 = hmm([identifierGuy + 5])
show(part001)`
it('should move a binary expression into a new variable', async () => {
@ -231,7 +231,7 @@ show(part001)`
'newVar'
)
const newCode = recast(modifiedAst)
expect(newCode).toContain(`const newVar = def('yo')`)
expect(newCode).toContain(`const newVar = def(yo)`)
expect(newCode).toContain(`angledLine([newVar, 3.09], %)`)
})
it('should move a binary expression with call expression into a new variable', async () => {
@ -245,7 +245,7 @@ show(part001)`
'newVar'
)
const newCode = recast(modifiedAst)
expect(newCode).toContain(`const newVar = jkl('yo') + 2`)
expect(newCode).toContain(`const newVar = jkl(yo) + 2`)
expect(newCode).toContain(`angledLine([newVar, 3.09], %)`)
})
it('should move a identifier into a new variable', async () => {

View File

@ -1,9 +1,9 @@
import { Program } from './abstractSyntaxTreeTypes'
import { recast_js } from '../wasm-lib/pkg/wasm_lib'
import { recast_wasm } from '../wasm-lib/pkg/wasm_lib'
export const recast = (ast: Program): string => {
try {
const s: string = recast_js(JSON.stringify(ast))
const s: string = recast_wasm(JSON.stringify(ast))
return s
} catch (e) {
// TODO: do something real with the error.

View File

@ -1,9 +1,14 @@
import { SourceRange } from '../executor'
import { Selections } from '../../useStore'
import { VITE_KC_API_WS_MODELING_URL } from '../../env'
import { SourceRange } from 'lang/executor'
import { Selections } from 'useStore'
import {
VITE_KC_API_WS_MODELING_URL,
VITE_KC_CONNECTION_TIMEOUT_MS,
VITE_KC_CONNECTION_WEBRTC_REPORT_STATS_MS,
} from 'env'
import { Models } from '@kittycad/lib'
import { exportSave } from '../../lib/exportSave'
import { exportSave } from 'lib/exportSave'
import { v4 as uuidv4 } from 'uuid'
import * as Sentry from '@sentry/react'
interface ResultCommand {
type: 'result'
@ -22,21 +27,12 @@ export interface SourceRangeMap {
[key: string]: SourceRange
}
interface SelectionsArgs {
id: string
type: Selections['codeBasedSelections'][number]['type']
interface NewTrackArgs {
conn: EngineConnection
mediaStream: MediaStream
}
interface CursorSelectionsArgs {
otherSelections: Selections['otherSelections']
idBasedSelections: { type: string; id: string }[]
}
export type EngineCommand = Models['WebSocketMessages_type']
type OkResponse = Models['OkModelingCmdResponse_type']
type WebSocketResponse = Models['WebSocketResponses_type']
type WebSocketResponse = Models['OkWebSocketResponseData_type']
// EngineConnection encapsulates the connection(s) to the Engine
// for the EngineCommandManager; namely, the underlying WebSocket
@ -44,44 +40,121 @@ type WebSocketResponse = Models['WebSocketResponses_type']
export class EngineConnection {
websocket?: WebSocket
pc?: RTCPeerConnection
lossyDataChannel?: RTCDataChannel
unreliableDataChannel?: RTCDataChannel
onConnectionStarted: (conn: EngineConnection) => void = () => {}
waitForReady: Promise<void> = new Promise(() => {})
private resolveReady = () => {}
private ready: boolean
readonly url: string
private readonly token?: string
private onWebsocketOpen: (engineConnection: EngineConnection) => void
private onDataChannelOpen: (engineConnection: EngineConnection) => void
private onEngineConnectionOpen: (engineConnection: EngineConnection) => void
private onConnectionStarted: (engineConnection: EngineConnection) => void
private onClose: (engineConnection: EngineConnection) => void
private onNewTrack: (track: NewTrackArgs) => void
constructor({
url,
token,
onConnectionStarted,
onWebsocketOpen = () => {},
onNewTrack = () => {},
onEngineConnectionOpen = () => {},
onConnectionStarted = () => {},
onClose = () => {},
onDataChannelOpen = () => {},
}: {
url: string
token?: string
onConnectionStarted: (conn: EngineConnection) => void
onWebsocketOpen?: (engineConnection: EngineConnection) => void
onDataChannelOpen?: (engineConnection: EngineConnection) => void
onEngineConnectionOpen?: (engineConnection: EngineConnection) => void
onConnectionStarted?: (engineConnection: EngineConnection) => void
onClose?: (engineConnection: EngineConnection) => void
onNewTrack?: (track: NewTrackArgs) => void
}) {
this.url = url
this.token = token
this.ready = false
this.onWebsocketOpen = onWebsocketOpen
this.onDataChannelOpen = onDataChannelOpen
this.onEngineConnectionOpen = onEngineConnectionOpen
this.onConnectionStarted = onConnectionStarted
this.onClose = onClose
this.onNewTrack = onNewTrack
// TODO(paultag): This isn't right; this should be when the
// connection is in a good place, and tied to the connect() method,
// but this is part of a larger refactor to untangle logic. Once the
// Connection is pulled apart, we can rework how ready is represented.
// This was just the easiest way to ensure some level of parity between
// the CommandManager and the Connection until I send a rework for
// retry logic.
this.waitForReady = new Promise((resolve) => {
this.resolveReady = resolve
})
// TODO(paultag): This ought to be tweakable.
const pingIntervalMs = 10000
setInterval(() => {
if (this.isReady()) {
// When we're online, every 10 seconds, we'll attempt to put a 'ping'
// command through the WebSocket connection. This will help both ends
// of the connection maintain the TCP connection without hitting a
// timeout condition.
this.send({ type: 'ping' })
}
}, pingIntervalMs)
}
// isReady will return true only when the WebRTC *and* WebSocket connection
// are connected. During setup, the WebSocket connection comes online first,
// which is used to establish the WebRTC connection. The EngineConnection
// is not "Ready" until both are connected.
isReady() {
return this.ready
}
// shouldTrace will return true when Sentry should be used to instrument
// the Engine.
shouldTrace() {
return Sentry.getCurrentHub()?.getClient()?.getOptions()?.sendClientReports
}
// connect will attempt to connect to the Engine over a WebSocket, and
// establish the WebRTC connections.
//
// This will attempt the full handshake, and retry if the connection
// did not establish.
connect() {
this.websocket = new WebSocket(this.url, [])
// TODO(paultag): make this safe to call multiple times, and figure out
// when a connection is in progress (state: connecting or something).
// Information on the connect transaction
class SpanPromise {
span: Sentry.Span
promise: Promise<void>
resolve?: (v: void) => void
constructor(span: Sentry.Span) {
this.span = span
this.promise = new Promise((resolve) => {
this.resolve = (v: void) => {
// here we're going to invoke finish before resolving the
// promise so that a `.then()` will order strictly after
// all spans have -- for sure -- been resolved, rather than
// doing a `then` on this promise.
this.span.finish()
resolve(v)
}
})
}
}
let webrtcMediaTransaction: Sentry.Transaction
let websocketSpan: SpanPromise
let mediaTrackSpan: SpanPromise
let dataChannelSpan: SpanPromise
let handshakeSpan: SpanPromise
let iceSpan: SpanPromise
if (this.shouldTrace()) {
webrtcMediaTransaction = Sentry.startTransaction({
name: 'webrtc-media',
})
websocketSpan = new SpanPromise(
webrtcMediaTransaction.startChild({ op: 'websocket' })
)
}
this.websocket = new WebSocket(this.url, [])
this.websocket.binaryType = 'arraybuffer'
this.pc = new RTCPeerConnection()
@ -89,18 +162,53 @@ export class EngineConnection {
this.websocket.addEventListener('open', (event) => {
console.log('Connected to websocket, waiting for ICE servers')
if (this.token) {
this.websocket?.send(
JSON.stringify({ headers: { Authorization: `Bearer ${this.token}` } })
this.send({ headers: { Authorization: `Bearer ${this.token}` } })
}
})
this.websocket.addEventListener('open', (event) => {
if (this.shouldTrace()) {
websocketSpan.resolve?.()
handshakeSpan = new SpanPromise(
webrtcMediaTransaction.startChild({ op: 'handshake' })
)
iceSpan = new SpanPromise(
webrtcMediaTransaction.startChild({ op: 'ice' })
)
dataChannelSpan = new SpanPromise(
webrtcMediaTransaction.startChild({
op: 'data-channel',
})
)
mediaTrackSpan = new SpanPromise(
webrtcMediaTransaction.startChild({
op: 'media-track',
})
)
}
Promise.all([
handshakeSpan.promise,
iceSpan.promise,
dataChannelSpan.promise,
mediaTrackSpan.promise,
]).then(() => {
console.log('All spans finished, reporting')
webrtcMediaTransaction?.finish()
})
this.onWebsocketOpen(this)
})
this.websocket.addEventListener('close', (event) => {
console.log('websocket connection closed', event)
this.close()
})
this.websocket.addEventListener('error', (event) => {
console.log('websocket connection error', event)
this.close()
})
this.websocket.addEventListener('message', (event) => {
@ -115,31 +223,66 @@ export class EngineConnection {
return
}
const message: WebSocketResponse = JSON.parse(event.data)
const message: Models['WebSocketResponse_type'] = JSON.parse(event.data)
if (
message.type === 'sdp_answer' &&
message.answer.type !== 'unspecified'
) {
this.pc?.setRemoteDescription(
new RTCSessionDescription({
type: message.answer.type,
sdp: message.answer.sdp,
})
)
} else if (message.type === 'trickle_ice') {
this.pc?.addIceCandidate(message.candidate as RTCIceCandidateInit)
} else if (message.type === 'ice_server_info' && this.pc) {
if (!message.success) {
if (message.request_id) {
console.error(`Error in response to request ${message.request_id}:`)
} else {
console.error(`Error from server:`)
}
message?.errors?.forEach((error) => {
console.error(` - ${error.error_code}: ${error.message}`)
})
return
}
let resp = message.resp
if (!resp) {
// If there's no body to the response, we can bail here.
return
}
if (resp.type === 'sdp_answer') {
let answer = resp.data?.answer
if (!answer || answer.type === 'unspecified') {
return
}
if (this.pc?.signalingState !== 'stable') {
// If the connection is stable, we shouldn't bother updating the
// SDP, since we have a stable connection to the backend. If we
// need to renegotiate, the whole PeerConnection needs to get
// tore down.
this.pc?.setRemoteDescription(
new RTCSessionDescription({
type: answer.type,
sdp: answer.sdp,
})
)
if (this.shouldTrace()) {
// When both ends have a local and remote SDP, we've been able to
// set up successfully. We'll still need to find the right ICE
// servers, but this is hand-shook.
handshakeSpan.resolve?.()
}
}
} else if (resp.type === 'trickle_ice') {
let candidate = resp.data?.candidate
this.pc?.addIceCandidate(candidate as RTCIceCandidateInit)
} else if (resp.type === 'ice_server_info' && this.pc) {
console.log('received ice_server_info')
let ice_servers = resp.data?.ice_servers
if (message.ice_servers.length > 0) {
if (ice_servers?.length > 0) {
// When we set the Configuration, we want to always force
// iceTransportPolicy to 'relay', since we know the topology
// of the ICE/STUN/TUN server and the engine. We don't wish to
// talk to the engine in any configuration /other/ than relay
// from a infra POV.
this.pc.setConfiguration({
iceServers: message.ice_servers,
iceServers: ice_servers,
iceTransportPolicy: 'relay',
})
} else {
@ -152,29 +295,21 @@ export class EngineConnection {
// until the end of this function is setup of our end of the
// PeerConnection and waiting for events to fire our callbacks.
this.pc.addEventListener('connectionstatechange', (e) =>
console.log(this.pc?.iceConnectionState)
)
this.pc.addEventListener('connectionstatechange', (event) => {
if (this.pc?.iceConnectionState === 'connected') {
iceSpan.resolve?.()
}
})
this.pc.addEventListener('icecandidate', (event) => {
if (!this.pc || !this.websocket) return
if (event.candidate === null) {
console.log('sent sdp_offer')
this.websocket.send(
JSON.stringify({
type: 'sdp_offer',
offer: this.pc.localDescription,
})
)
} else {
if (event.candidate !== null) {
console.log('sending trickle ice candidate')
const { candidate } = event
this.websocket?.send(
JSON.stringify({
type: 'trickle_ice',
candidate: candidate.toJSON(),
})
)
this.send({
type: 'trickle_ice',
candidate: candidate.toJSON(),
})
}
})
@ -189,43 +324,244 @@ export class EngineConnection {
.then(async (descriptionInit) => {
await this?.pc?.setLocalDescription(descriptionInit)
console.log('sent sdp_offer begin')
const msg = JSON.stringify({
this.send({
type: 'sdp_offer',
offer: this.pc?.localDescription,
})
this.websocket?.send(msg)
})
.catch(console.log)
}
// TODO(paultag): This ought to be both controllable, as well as something
// like exponential backoff to have some grace on the backend, as well as
// fix responsiveness for clients that had a weird network hiccup.
const connectionTimeoutMs = VITE_KC_CONNECTION_TIMEOUT_MS
setTimeout(() => {
if (this.isReady()) {
return
}
console.log('engine connection timeout on connection, retrying')
this.close()
this.connect()
}, connectionTimeoutMs)
})
this.pc.addEventListener('track', (event) => {
const mediaStream = event.streams[0]
if (this.shouldTrace()) {
let mediaStreamTrack = mediaStream.getVideoTracks()[0]
mediaStreamTrack.addEventListener('unmute', () => {
// let settings = mediaStreamTrack.getSettings()
// mediaTrackSpan.span.setTag("fps", settings.frameRate)
// mediaTrackSpan.span.setTag("width", settings.width)
// mediaTrackSpan.span.setTag("height", settings.height)
mediaTrackSpan.resolve?.()
})
}
// Set up the background thread to keep an eye on statistical
// information about the WebRTC media stream from the server to
// us. We'll also eventually want more global statistical information,
// but this will give us a baseline.
if (parseInt(VITE_KC_CONNECTION_WEBRTC_REPORT_STATS_MS) !== 0) {
setInterval(() => {
if (this.pc === undefined) {
return
}
if (!this.shouldTrace()) {
return
}
// Use the WebRTC Statistics API to collect statistical information
// about the WebRTC connection we're using to report to Sentry.
mediaStream.getVideoTracks().forEach((videoTrack) => {
let trackStats = new Map<string, any>()
this.pc?.getStats(videoTrack).then((videoTrackStats) => {
// Sentry only allows 10 metrics per transaction. We're going
// to have to pick carefully here, eventually send like a prom
// file or something to the peer.
const transaction = Sentry.startTransaction({
name: 'webrtc-stats',
})
videoTrackStats.forEach((videoTrackReport) => {
if (videoTrackReport.type === 'inbound-rtp') {
// RTC Stream Info
// transaction.setMeasurement(
// 'mediaStreamTrack.framesDecoded',
// videoTrackReport.framesDecoded,
// 'frame'
// )
transaction.setMeasurement(
'rtcFramesDropped',
videoTrackReport.framesDropped,
''
)
// transaction.setMeasurement(
// 'mediaStreamTrack.framesReceived',
// videoTrackReport.framesReceived,
// 'frame'
// )
transaction.setMeasurement(
'rtcFramesPerSecond',
videoTrackReport.framesPerSecond,
'fps'
)
transaction.setMeasurement(
'rtcFreezeCount',
videoTrackReport.freezeCount,
''
)
transaction.setMeasurement(
'rtcJitter',
videoTrackReport.jitter,
'second'
)
// transaction.setMeasurement(
// 'mediaStreamTrack.jitterBufferDelay',
// videoTrackReport.jitterBufferDelay,
// ''
// )
// transaction.setMeasurement(
// 'mediaStreamTrack.jitterBufferEmittedCount',
// videoTrackReport.jitterBufferEmittedCount,
// ''
// )
// transaction.setMeasurement(
// 'mediaStreamTrack.jitterBufferMinimumDelay',
// videoTrackReport.jitterBufferMinimumDelay,
// ''
// )
// transaction.setMeasurement(
// 'mediaStreamTrack.jitterBufferTargetDelay',
// videoTrackReport.jitterBufferTargetDelay,
// ''
// )
transaction.setMeasurement(
'rtcKeyFramesDecoded',
videoTrackReport.keyFramesDecoded,
''
)
transaction.setMeasurement(
'rtcTotalFreezesDuration',
videoTrackReport.totalFreezesDuration,
'second'
)
// transaction.setMeasurement(
// 'mediaStreamTrack.totalInterFrameDelay',
// videoTrackReport.totalInterFrameDelay,
// ''
// )
transaction.setMeasurement(
'rtcTotalPausesDuration',
videoTrackReport.totalPausesDuration,
'second'
)
// transaction.setMeasurement(
// 'mediaStreamTrack.totalProcessingDelay',
// videoTrackReport.totalProcessingDelay,
// 'second'
// )
} else if (videoTrackReport.type === 'transport') {
// // Bytes i/o
// transaction.setMeasurement(
// 'mediaStreamTrack.bytesReceived',
// videoTrackReport.bytesReceived,
// 'byte'
// )
// transaction.setMeasurement(
// 'mediaStreamTrack.bytesSent',
// videoTrackReport.bytesSent,
// 'byte'
// )
}
})
transaction?.finish()
})
})
}, VITE_KC_CONNECTION_WEBRTC_REPORT_STATS_MS)
}
this.onNewTrack({
conn: this,
mediaStream: mediaStream,
})
})
// During startup, we'll track the time from `connect` being called
// until the 'done' event fires.
let connectionStarted = new Date()
this.pc.addEventListener('datachannel', (event) => {
this.lossyDataChannel = event.channel
this.unreliableDataChannel = event.channel
console.log('accepted lossy data channel', event.channel.label)
this.lossyDataChannel.addEventListener('open', (event) => {
this.resolveReady()
console.log('lossy data channel opened', event)
console.log('accepted unreliable data channel', event.channel.label)
this.unreliableDataChannel.addEventListener('open', (event) => {
console.log('unreliable data channel opened', event)
if (this.shouldTrace()) {
dataChannelSpan.resolve?.()
}
this.onDataChannelOpen(this)
this.onEngineConnectionOpen(this)
this.ready = true
})
this.lossyDataChannel.addEventListener('close', (event) => {
console.log('lossy data channel closed')
this.unreliableDataChannel.addEventListener('close', (event) => {
console.log('unreliable data channel closed')
this.close()
})
this.lossyDataChannel.addEventListener('error', (event) => {
console.log('lossy data channel error')
this.unreliableDataChannel.addEventListener('error', (event) => {
console.log('unreliable data channel error')
this.close()
})
})
if (this.onConnectionStarted) this.onConnectionStarted(this)
this.onConnectionStarted(this)
}
send(message: object | string) {
// TODO(paultag): Add in logic to determine the connection state and
// take actions if needed?
this.websocket?.send(
typeof message === 'string' ? message : JSON.stringify(message)
)
}
close() {
this.websocket?.close()
this.pc?.close()
this.lossyDataChannel?.close()
this.unreliableDataChannel?.close()
this.websocket = undefined
this.pc = undefined
this.unreliableDataChannel = undefined
this.onClose(this)
this.ready = false
}
}
export type EngineCommand = Models['WebSocketRequest_type']
type ModelTypes = Models['OkModelingCmdResponse_type']['type']
type UnreliableResponses = Extract<
Models['OkModelingCmdResponse_type'],
{ type: 'highlight_set_entity' }
>
interface UnreliableSubscription<T extends UnreliableResponses['type']> {
event: T
callback: (data: Extract<UnreliableResponses, { type: T }>) => void
}
interface Subscription<T extends ModelTypes> {
event: T
callback: (
data: Extract<Models['OkModelingCmdResponse_type'], { type: T }>
) => void
}
export class EngineCommandManager {
artifactMap: ArtifactMap = {}
sourceRangeMap: SourceRangeMap = {}
@ -234,10 +570,17 @@ export class EngineCommandManager {
engineConnection?: EngineConnection
waitForReady: Promise<void> = new Promise(() => {})
private resolveReady = () => {}
onHoverCallback: (id?: string) => void = () => {}
onClickCallback: (selection?: SelectionsArgs) => void = () => {}
onCursorsSelectedCallback: (selections: CursorSelectionsArgs) => void =
() => {}
subscriptions: {
[event: string]: {
[localUnsubscribeId: string]: (a: any) => void
}
} = {} as any
unreliableSubscriptions: {
[event: string]: {
[localUnsubscribeId: string]: (a: any) => void
}
} = {} as any
constructor({
setMediaStream,
setIsStreamReady,
@ -258,122 +601,161 @@ export class EngineCommandManager {
this.engineConnection = new EngineConnection({
url,
token,
onConnectionStarted: (conn) => {
this.engineConnection?.pc?.addEventListener('track', (event) => {
console.log('received track', event)
const mediaStream = event.streams[0]
setMediaStream(mediaStream)
})
onEngineConnectionOpen: () => {
this.resolveReady()
setIsStreamReady(true)
},
onClose: () => {
setIsStreamReady(false)
},
onConnectionStarted: (engineConnection) => {
engineConnection?.pc?.addEventListener('datachannel', (event) => {
let unreliableDataChannel = event.channel
this.engineConnection?.pc?.addEventListener('datachannel', (event) => {
let lossyDataChannel = event.channel
lossyDataChannel.addEventListener('message', (event) => {
const result: OkResponse = JSON.parse(event.data)
if (
result.type === 'highlight_set_entity' &&
result.sequence &&
result.sequence > this.inSequence
) {
this.onHoverCallback(result.entity_id)
this.inSequence = result.sequence
}
unreliableDataChannel.addEventListener('message', (event) => {
const result: UnreliableResponses = JSON.parse(event.data)
Object.values(
this.unreliableSubscriptions[result.type] || {}
).forEach(
// TODO: There is only one response that uses the unreliable channel atm,
// highlight_set_entity, if there are more it's likely they will all have the same
// sequence logic, but I'm not sure if we use a single global sequence or a sequence
// per unreliable subscription.
(callback) => {
if (
result?.data?.sequence &&
result?.data.sequence > this.inSequence &&
result.type === 'highlight_set_entity'
) {
this.inSequence = result.data.sequence
callback(result)
}
}
)
})
})
// When the EngineConnection starts a connection, we want to register
// callbacks into the WebSocket/PeerConnection.
conn.websocket?.addEventListener('message', (event) => {
engineConnection.websocket?.addEventListener('message', (event) => {
if (event.data instanceof ArrayBuffer) {
// If the data is an ArrayBuffer, it's the result of an export command,
// because in all other cases we send JSON strings. But in the case of
// export we send a binary blob.
// Pass this to our export function.
exportSave(event.data)
} else if (
typeof event.data === 'string' &&
event.data.toLocaleLowerCase().startsWith('error')
) {
console.warn('something went wrong: ', event.data)
} else {
const message: WebSocketResponse = JSON.parse(event.data)
if (message.type === 'modeling') {
const id = message.cmd_id
const command = this.artifactMap[id]
if ('ok' in message.result) {
const result: OkResponse = message.result.ok
if (result.type === 'select_with_point') {
if (result.entity_id) {
this.onClickCallback({
id: result.entity_id,
type: 'default',
})
} else {
this.onClickCallback()
}
}
}
if (command && command.type === 'pending') {
const resolve = command.resolve
this.artifactMap[id] = {
type: 'result',
data: message.result,
}
resolve({
id,
})
} else {
this.artifactMap[id] = {
type: 'result',
data: message.result,
}
}
const message: Models['WebSocketResponse_type'] = JSON.parse(
event.data
)
if (
message.success &&
message.resp.type === 'modeling' &&
message.request_id
) {
this.handleModelingCommand(message.resp, message.request_id)
}
}
})
},
})
onNewTrack: ({ mediaStream }) => {
console.log('received track', mediaStream)
// TODO(paultag): this isn't quite right, and the double promises is
// pretty grim.
this.engineConnection?.waitForReady.then(this.resolveReady)
mediaStream.getVideoTracks()[0].addEventListener('mute', () => {
console.log('peer is not sending video to us')
// this.engineConnection?.close()
// this.engineConnection?.connect()
})
this.waitForReady.then(() => {
setIsStreamReady(true)
setMediaStream(mediaStream)
},
})
this.engineConnection?.connect()
}
handleModelingCommand(message: WebSocketResponse, id: string) {
if (message.type !== 'modeling') {
return
}
const modelingResponse = message.data.modeling_response
Object.values(this.subscriptions[modelingResponse.type] || {}).forEach(
(callback) => callback(modelingResponse)
)
const command = this.artifactMap[id]
if (command && command.type === 'pending') {
const resolve = command.resolve
this.artifactMap[id] = {
type: 'result',
data: modelingResponse,
}
resolve({
id,
data: modelingResponse,
})
} else {
this.artifactMap[id] = {
type: 'result',
data: modelingResponse,
}
}
}
tearDown() {
// close all channels, sockets and WebRTC connections
this.engineConnection?.close()
}
startNewSession() {
this.artifactMap = {}
this.sourceRangeMap = {}
}
subscribeTo<T extends ModelTypes>({
event,
callback,
}: Subscription<T>): () => void {
const localUnsubscribeId = uuidv4()
const otherEventCallbacks = this.subscriptions[event]
if (otherEventCallbacks) {
otherEventCallbacks[localUnsubscribeId] = callback
} else {
this.subscriptions[event] = {
[localUnsubscribeId]: callback,
}
}
return () => this.unSubscribeTo(event, localUnsubscribeId)
}
private unSubscribeTo(event: ModelTypes, id: string) {
delete this.subscriptions[event][id]
}
subscribeToUnreliable<T extends UnreliableResponses['type']>({
event,
callback,
}: UnreliableSubscription<T>): () => void {
const localUnsubscribeId = uuidv4()
const otherEventCallbacks = this.unreliableSubscriptions[event]
if (otherEventCallbacks) {
otherEventCallbacks[localUnsubscribeId] = callback
} else {
this.unreliableSubscriptions[event] = {
[localUnsubscribeId]: callback,
}
}
return () => this.unSubscribeToUnreliable(event, localUnsubscribeId)
}
private unSubscribeToUnreliable(
event: UnreliableResponses['type'],
id: string
) {
delete this.unreliableSubscriptions[event][id]
}
endSession() {
// this.websocket?.close()
// socket.off('command')
}
onHover(callback: (id?: string) => void) {
// It's when the user hovers over a part in the 3d scene, and so the engine should tell the
// frontend about that (with it's id) so that the FE can highlight code associated with that id
this.onHoverCallback = callback
}
onClick(callback: (selection?: SelectionsArgs) => void) {
// It's when the user clicks on a part in the 3d scene, and so the engine should tell the
// frontend about that (with it's id) so that the FE can put the user's cursor on the right
// line of code
this.onClickCallback = callback
}
cusorsSelected(selections: {
otherSelections: Selections['otherSelections']
idBasedSelections: { type: string; id: string }[]
}) {
if (this.engineConnection?.websocket?.readyState === 0) {
console.log('socket not open')
if (!this.engineConnection?.isReady()) {
console.log('engine connection isnt ready')
return
}
this.sendSceneCommand({
@ -392,51 +774,58 @@ export class EngineCommandManager {
cmd_id: uuidv4(),
})
}
sendSceneCommand(command: EngineCommand) {
if (this.engineConnection?.websocket?.readyState === 0) {
sendSceneCommand(command: EngineCommand): Promise<any> {
if (!this.engineConnection?.isReady()) {
console.log('socket not ready')
return
return Promise.resolve()
}
if (command.type !== 'modeling_cmd_req') return
if (command.type !== 'modeling_cmd_req') return Promise.resolve()
const cmd = command.cmd
if (
cmd.type === 'camera_drag_move' &&
this.engineConnection?.lossyDataChannel
this.engineConnection?.unreliableDataChannel
) {
cmd.sequence = this.outSequence
this.outSequence++
this.engineConnection?.lossyDataChannel?.send(JSON.stringify(command))
return
this.engineConnection?.unreliableDataChannel?.send(
JSON.stringify(command)
)
return Promise.resolve()
} else if (
cmd.type === 'highlight_set_entity' &&
this.engineConnection?.lossyDataChannel
this.engineConnection?.unreliableDataChannel
) {
cmd.sequence = this.outSequence
this.outSequence++
this.engineConnection?.lossyDataChannel?.send(JSON.stringify(command))
return
this.engineConnection?.unreliableDataChannel?.send(
JSON.stringify(command)
)
return Promise.resolve()
}
console.log('sending command', command)
this.engineConnection?.websocket?.send(JSON.stringify(command))
// since it's not mouse drag or highlighting send over TCP and keep track of the command
this.engineConnection?.send(command)
return this.handlePendingCommand(command.cmd_id)
}
sendModellingCommand({
sendModelingCommand({
id,
params,
range,
command,
}: {
id: string
params: any
range: SourceRange
command: EngineCommand
command: EngineCommand | string
}): Promise<any> {
this.sourceRangeMap[id] = range
if (this.engineConnection?.websocket?.readyState === 0) {
if (!this.engineConnection?.isReady()) {
console.log('socket not ready')
return new Promise(() => {})
return Promise.resolve()
}
this.engineConnection?.websocket?.send(JSON.stringify(command))
this.engineConnection?.send(command)
return this.handlePendingCommand(id)
}
handlePendingCommand(id: string) {
let resolve: (val: any) => void = () => {}
const promise = new Promise((_resolve, reject) => {
resolve = _resolve
@ -448,6 +837,24 @@ export class EngineCommandManager {
}
return promise
}
sendModelingCommandFromWasm(
id: string,
rangeStr: string,
commandStr: string
): Promise<any> {
if (id === undefined) {
throw new Error('id is undefined')
}
if (rangeStr === undefined) {
throw new Error('rangeStr is undefined')
}
if (commandStr === undefined) {
throw new Error('commandStr is undefined')
}
const range: SourceRange = JSON.parse(rangeStr)
return this.sendModelingCommand({ id, range, command: commandStr })
}
commandResult(id: string): Promise<any> {
const command = this.artifactMap[id]
if (!command) {

View File

@ -1,88 +0,0 @@
import { InternalFn } from './stdTypes'
import {
ExtrudeGroup,
ExtrudeSurface,
SketchGroup,
Position,
Rotation,
} from '../executor'
import { clockwiseSign } from './std'
import { generateUuidFromHashSeed } from '../../lib/uuid'
export const extrude: InternalFn = (
{ sourceRange, engineCommandManager, code },
length: number,
sketchVal: SketchGroup
): ExtrudeGroup => {
const sketch = sketchVal
const { position, rotation } = sketchVal
const id = generateUuidFromHashSeed(
JSON.stringify({
code,
sourceRange,
data: {
length,
sketchVal,
},
})
)
const extrudeSurfaces: ExtrudeSurface[] = []
const extrusionDirection = clockwiseSign(sketch.value.map((line) => line.to))
engineCommandManager.sendModellingCommand({
id,
params: [
{
length,
extrusionDirection: extrusionDirection,
},
],
range: sourceRange,
command: {
type: 'modeling_cmd_req',
cmd: {
type: 'extrude',
target: sketch.id,
distance: length,
cap: true,
},
cmd_id: id,
},
})
return {
type: 'extrudeGroup',
id,
value: extrudeSurfaces, // TODO, this is just an empty array now, should be deleted.
height: length,
position,
rotation,
__meta: [
{
sourceRange,
pathToNode: [], // TODO
},
{
sourceRange: sketchVal.__meta[0].sourceRange,
pathToNode: sketchVal.__meta[0].pathToNode,
},
],
}
}
export const getExtrudeWallTransform: InternalFn = (
_,
pathName: string,
extrudeGroup: ExtrudeGroup
): {
position: Position
quaternion: Rotation
} => {
const path = extrudeGroup?.value.find((path) => path.name === pathName)
if (!path) throw new Error(`Could not find path with name ${pathName}`)
return {
position: path.position,
quaternion: path.rotation,
}
}

View File

@ -23,12 +23,7 @@ import { GuiModes, toolTips, TooTip } from '../../useStore'
import { splitPathAtPipeExpression } from '../modifyAst'
import { generateUuidFromHashSeed } from '../../lib/uuid'
import {
SketchLineHelper,
ModifyAstBase,
InternalFn,
TransformCallback,
} from './stdTypes'
import { SketchLineHelper, ModifyAstBase, TransformCallback } from './stdTypes'
import {
createLiteral,
@ -42,10 +37,7 @@ import {
} from '../modifyAst'
import { roundOff, getLength, getAngle } from '../../lib/utils'
import { getSketchSegmentFromSourceRange } from './sketchConstraints'
import {
intersectionWithParallelLine,
perpendicularDistance,
} from 'sketch-helpers'
import { perpendicularDistance } from 'sketch-helpers'
export type Coords2d = [number, number]
@ -115,45 +107,6 @@ function makeId(seed: string | any) {
}
export const lineTo: SketchLineHelper = {
fn: (
{ sourceRange, code },
data:
| [number, number]
| {
to: [number, number]
tag?: string
},
previousSketch: SketchGroup
): SketchGroup => {
if (!previousSketch)
throw new Error('lineTo must be called after startSketchAt')
const sketchGroup = { ...previousSketch }
const from = getCoordsFromPaths(sketchGroup, sketchGroup.value.length - 1)
const to = 'to' in data ? data.to : data
const id = makeId({
code,
sourceRange,
data,
})
const currentPath: Path = {
type: 'toPoint',
to,
from,
__geoMeta: {
sourceRange,
id,
pathToNode: [], // TODO
},
}
if ('tag' in data) {
currentPath.name = data.tag
}
return {
...sketchGroup,
value: [...sketchGroup.value, currentPath],
}
},
add: ({
node,
pathToNode,
@ -221,77 +174,6 @@ export const lineTo: SketchLineHelper = {
}
export const line: SketchLineHelper = {
fn: (
{ sourceRange, engineCommandManager, code },
data:
| [number, number]
| 'default'
| {
to: [number, number] | 'default'
// name?: string
tag?: string
},
previousSketch: SketchGroup
): SketchGroup => {
if (!previousSketch) throw new Error('lineTo must be called after lineTo')
const sketchGroup = { ...previousSketch }
const from = getCoordsFromPaths(sketchGroup, sketchGroup.value.length - 1)
let args: [number, number] = [0.2, 1]
if (data !== 'default' && 'to' in data && data.to !== 'default') {
args = data.to
} else if (data !== 'default' && !('to' in data)) {
args = data
}
const to: [number, number] = [from[0] + args[0], from[1] + args[1]]
const lineData: LineData = {
from: [...from, 0],
to: [...to, 0],
}
const id = makeId({
code,
sourceRange,
data,
})
engineCommandManager.sendModellingCommand({
id,
params: [lineData, previousSketch],
range: sourceRange,
command: {
type: 'modeling_cmd_req',
cmd: {
type: 'extend_path',
path: sketchGroup.id,
segment: {
type: 'line',
end: {
x: lineData.to[0],
y: lineData.to[1],
z: 0,
},
},
},
cmd_id: id,
},
})
const currentPath: Path = {
type: 'toPoint',
to,
from,
__geoMeta: {
id,
sourceRange,
pathToNode: [], // TODO
},
}
if (data !== 'default' && 'tag' in data) {
currentPath.name = data.tag
}
return {
...sketchGroup,
value: [...sketchGroup.value, currentPath],
}
},
add: ({
node,
previousProgramMemory,
@ -385,25 +267,6 @@ export const line: SketchLineHelper = {
}
export const xLineTo: SketchLineHelper = {
fn: (
meta,
data:
| number
| {
to: number
// name?: string
tag?: string
},
previousSketch: SketchGroup
) => {
if (!previousSketch) throw new Error('bad bad bad')
const from = getCoordsFromPaths(
previousSketch,
previousSketch.value.length - 1
)
const [xVal, tag] = typeof data !== 'number' ? [data.to, data.tag] : [data]
return lineTo.fn(meta, { to: [xVal, from[1]], tag }, previousSketch)
},
add: ({ node, pathToNode, to, replaceExisting, createCallback }) => {
const _node = { ...node }
const getNode = getNodeFromPathCurry(_node, pathToNode)
@ -452,25 +315,6 @@ export const xLineTo: SketchLineHelper = {
}
export const yLineTo: SketchLineHelper = {
fn: (
meta,
data:
| number
| {
to: number
// name?: string
tag?: string
},
previousSketch: SketchGroup
) => {
if (!previousSketch) throw new Error('bad bad bad')
const from = getCoordsFromPaths(
previousSketch,
previousSketch.value.length - 1
)
const [yVal, tag] = typeof data !== 'number' ? [data.to, data.tag] : [data]
return lineTo.fn(meta, { to: [from[0], yVal], tag }, previousSketch)
},
add: ({ node, pathToNode, to, replaceExisting, createCallback }) => {
const _node = { ...node }
const getNode = getNodeFromPathCurry(_node, pathToNode)
@ -519,21 +363,6 @@ export const yLineTo: SketchLineHelper = {
}
export const xLine: SketchLineHelper = {
fn: (
meta,
data:
| number
| {
length: number
tag?: string
},
previousSketch: SketchGroup
) => {
if (!previousSketch) throw new Error('bad bad bad')
const [xVal, tag] =
typeof data !== 'number' ? [data.length, data.tag] : [data]
return line.fn(meta, { to: [xVal, 0], tag }, previousSketch)
},
add: ({ node, pathToNode, to, from, replaceExisting, createCallback }) => {
const _node = { ...node }
const getNode = getNodeFromPathCurry(_node, pathToNode)
@ -584,22 +413,6 @@ export const xLine: SketchLineHelper = {
}
export const yLine: SketchLineHelper = {
fn: (
meta,
data:
| number
| {
length: number
// name?: string
tag?: string
},
previousSketch: SketchGroup
) => {
if (!previousSketch) throw new Error('bad bad bad')
const [yVal, tag] =
typeof data !== 'number' ? [data.length, data.tag] : [data]
return line.fn(meta, { to: [0, yVal], tag }, previousSketch)
},
add: ({ node, pathToNode, to, from, replaceExisting, createCallback }) => {
const _node = { ...node }
const getNode = getNodeFromPathCurry(_node, pathToNode)
@ -644,48 +457,6 @@ export const yLine: SketchLineHelper = {
}
export const angledLine: SketchLineHelper = {
fn: (
{ sourceRange, engineCommandManager, code },
data:
| [number, number]
| {
angle: number
length: number
tag?: string
},
previousSketch: SketchGroup
) => {
if (!previousSketch) throw new Error('lineTo must be called after lineTo')
const sketchGroup = { ...previousSketch }
const from = getCoordsFromPaths(sketchGroup, sketchGroup.value.length - 1)
const [angle, length] = 'angle' in data ? [data.angle, data.length] : data
const to: [number, number] = [
from[0] + length * Math.cos((angle * Math.PI) / 180),
from[1] + length * Math.sin((angle * Math.PI) / 180),
]
const id = makeId({
code,
sourceRange,
data,
})
const currentPath: Path = {
type: 'toPoint',
to,
from,
__geoMeta: {
id,
sourceRange,
pathToNode: [], // TODO
},
}
if ('tag' in data) {
currentPath.name = data.tag
}
return {
...sketchGroup,
value: [...sketchGroup.value, currentPath],
}
},
add: ({
node,
pathToNode,
@ -753,26 +524,6 @@ export const angledLine: SketchLineHelper = {
}
export const angledLineOfXLength: SketchLineHelper = {
fn: (
{ sourceRange, programMemory, engineCommandManager, code },
data:
| [number, number]
| {
angle: number
length: number
tag?: string
},
previousSketch: SketchGroup
) => {
if (!previousSketch) throw new Error('lineTo must be called after lineTo')
const [angle, length, tag] =
'angle' in data ? [data.angle, data.length, data.tag] : data
return line.fn(
{ sourceRange, programMemory, engineCommandManager, code },
{ to: getYComponent(angle, length), tag },
previousSketch
)
},
add: ({
node,
previousProgramMemory,
@ -846,26 +597,6 @@ export const angledLineOfXLength: SketchLineHelper = {
}
export const angledLineOfYLength: SketchLineHelper = {
fn: (
{ sourceRange, programMemory, engineCommandManager, code },
data:
| [number, number]
| {
angle: number
length: number
tag?: string
},
previousSketch: SketchGroup
) => {
if (!previousSketch) throw new Error('lineTo must be called after lineTo')
const [angle, length, tag] =
'angle' in data ? [data.angle, data.length, data.tag] : data
return line.fn(
{ sourceRange, programMemory, engineCommandManager, code },
{ to: getXComponent(angle, length), tag },
previousSketch
)
},
add: ({
node,
previousProgramMemory,
@ -940,33 +671,6 @@ export const angledLineOfYLength: SketchLineHelper = {
}
export const angledLineToX: SketchLineHelper = {
fn: (
{ sourceRange, programMemory, engineCommandManager, code },
data:
| [number, number]
| {
angle: number
to: number
tag?: string
},
previousSketch: SketchGroup
) => {
if (!previousSketch) throw new Error('lineTo must be called after lineTo')
const from = getCoordsFromPaths(
previousSketch,
previousSketch.value.length - 1
)
const [angle, xTo, tag] =
'angle' in data ? [data.angle, data.to, data.tag] : data
const xComponent = xTo - from[0]
const yComponent = xComponent * Math.tan((angle * Math.PI) / 180)
const yTo = from[1] + yComponent
return lineTo.fn(
{ sourceRange, programMemory, engineCommandManager, code },
{ to: [xTo, yTo], tag },
previousSketch
)
},
add: ({
node,
pathToNode,
@ -1036,33 +740,6 @@ export const angledLineToX: SketchLineHelper = {
}
export const angledLineToY: SketchLineHelper = {
fn: (
{ sourceRange, programMemory, engineCommandManager, code },
data:
| [number, number]
| {
angle: number
to: number
tag?: string
},
previousSketch: SketchGroup
) => {
if (!previousSketch) throw new Error('lineTo must be called after lineTo')
const from = getCoordsFromPaths(
previousSketch,
previousSketch.value.length - 1
)
const [angle, yTo, tag] =
'angle' in data ? [data.angle, data.to, data.tag] : data
const yComponent = yTo - from[1]
const xComponent = yComponent / Math.tan((angle * Math.PI) / 180)
const xTo = from[0] + xComponent
return lineTo.fn(
{ sourceRange, programMemory, engineCommandManager, code },
{ to: [xTo, yTo], tag },
previousSketch
)
},
add: ({
node,
pathToNode,
@ -1133,37 +810,6 @@ export const angledLineToY: SketchLineHelper = {
}
export const angledLineThatIntersects: SketchLineHelper = {
fn: (
{ sourceRange, programMemory, engineCommandManager, code },
data: {
angle: number
intersectTag: string
offset?: number
tag?: string
},
previousSketch: SketchGroup
) => {
if (!previousSketch) throw new Error('lineTo must be called after lineTo')
const intersectPath = previousSketch.value.find(
({ name }) => name === data.intersectTag
)
if (!intersectPath) throw new Error('intersectTag must match a line')
const from = getCoordsFromPaths(
previousSketch,
previousSketch.value.length - 1
)
const to = intersectionWithParallelLine({
line1: [intersectPath.from, intersectPath.to],
line1Offset: data.offset || 0,
line2Point: from,
line2Angle: data.angle,
})
return lineTo.fn(
{ sourceRange, programMemory, engineCommandManager, code },
{ to, tag: data.tag },
previousSketch
)
},
add: ({
node,
pathToNode,
@ -1526,142 +1172,6 @@ function addTagWithTo(
}
}
export const close: InternalFn = (
{ sourceRange, engineCommandManager, code },
sketchGroup: SketchGroup
): SketchGroup => {
const from = getCoordsFromPaths(sketchGroup, sketchGroup.value.length - 1)
const to = sketchGroup.start
? sketchGroup.start.from
: getCoordsFromPaths(sketchGroup, 0)
const lineData: LineData = {
from: [...from, 0],
to: [...to, 0],
}
const id = makeId({
code,
sourceRange,
data: sketchGroup,
})
engineCommandManager.sendModellingCommand({
id,
params: [lineData],
range: sourceRange,
command: {
type: 'modeling_cmd_req',
cmd: {
type: 'close_path',
path_id: sketchGroup.id,
},
cmd_id: id,
},
})
const currentPath: Path = {
type: 'toPoint',
to,
from,
__geoMeta: {
id,
sourceRange,
pathToNode: [], // TODO
},
}
const newValue = [...sketchGroup.value]
newValue.push(currentPath)
return {
...sketchGroup,
value: newValue,
}
}
export const startSketchAt: InternalFn = (
{ sourceRange, programMemory, engineCommandManager, code },
data:
| [number, number]
| 'default'
| {
to: [number, number] | 'default'
// name?: string
tag?: string
}
): SketchGroup => {
let to: [number, number] = [0, 0]
if (data !== 'default' && 'to' in data && data.to !== 'default') {
to = data.to
} else if (data !== 'default' && !('to' in data)) {
to = data
}
const lineData: { to: [number, number, number] } = {
to: [...to, 0],
}
const id = makeId({
code,
sourceRange,
data,
})
const pathId = makeId({
code,
sourceRange,
data,
isPath: true,
})
engineCommandManager.sendModellingCommand({
id: pathId,
params: [lineData],
range: sourceRange,
command: {
type: 'modeling_cmd_req',
cmd: {
type: 'start_path',
},
cmd_id: pathId,
},
})
engineCommandManager.sendSceneCommand({
type: 'modeling_cmd_req',
cmd: {
type: 'move_path_pen',
path: pathId,
to: {
x: lineData.to[0],
y: lineData.to[1],
z: 0,
},
},
cmd_id: id,
})
const currentPath: Path = {
type: 'base',
to,
from: to,
__geoMeta: {
id,
sourceRange,
pathToNode: [], // TODO
},
}
if (data !== 'default' && 'tag' in data) {
currentPath.name = data.tag
}
return {
type: 'sketchGroup',
start: currentPath,
value: [],
position: [0, 0, 0],
rotation: [0, 0, 0, 1],
id: pathId,
__meta: [
{
sourceRange,
pathToNode: [], // TODO
},
],
}
}
export function getYComponent(
angleDegree: number,
xComponent: number

View File

@ -391,6 +391,7 @@ show(part001)`
type: 'toPoint',
to: [5.62, 1.79],
from: [3.48, 0.44],
name: '',
})
})
it('verify it works when the segment is in the `start` property', async () => {
@ -400,6 +401,6 @@ show(part001)`
programMemory.root['part001'] as SketchGroup,
[index, index]
).segment
expect(segment).toEqual({ type: 'base', to: [0, 0.04], from: [0, 0.04] })
expect(segment).toEqual({ to: [0, 0.04], from: [0, 0.04], name: '' })
})
})

View File

@ -1,4 +1,3 @@
import { getAngle } from '../../lib/utils'
import { TooTip, toolTips } from '../../useStore'
import {
Program,
@ -6,7 +5,6 @@ import {
CallExpression,
} from '../abstractSyntaxTreeTypes'
import { SketchGroup, SourceRange } from '../executor'
import { InternalFn } from './stdTypes'
export function getSketchSegmentFromSourceRange(
sketchGroup: SketchGroup,
@ -36,79 +34,6 @@ export function getSketchSegmentFromSourceRange(
}
}
export const segLen: InternalFn = (
_,
segName: string,
sketchGroup: SketchGroup
): number => {
const line = sketchGroup?.value.find((seg) => seg?.name === segName)
// maybe this should throw, but the language doesn't have a way to handle errors yet
if (!line) return 0
return Math.sqrt(
(line.from[1] - line.to[1]) ** 2 + (line.from[0] - line.to[0]) ** 2
)
}
export const segAng: InternalFn = (
_,
segName: string,
sketchGroup: SketchGroup
): number => {
const line = sketchGroup?.value.find((seg) => seg.name === segName)
// maybe this should throw, but the language doesn't have a way to handle errors yet
if (!line) return 0
return getAngle(line.from, line.to)
}
function segEndFactory(which: 'x' | 'y'): InternalFn {
return (_, segName: string, sketchGroup: SketchGroup): number => {
const line =
sketchGroup?.start?.name === segName
? sketchGroup?.start
: sketchGroup?.value.find((seg) => seg.name === segName)
// maybe this should throw, but the language doesn't have a way to handle errors yet
if (!line) return 0
return which === 'x' ? line.to[0] : line.to[1]
}
}
export const segEndX: InternalFn = segEndFactory('x')
export const segEndY: InternalFn = segEndFactory('y')
function lastSegFactory(which: 'x' | 'y'): InternalFn {
return (_, sketchGroup: SketchGroup): number => {
const lastLine = sketchGroup?.value[sketchGroup.value.length - 1]
return which === 'x' ? lastLine.to[0] : lastLine.to[1]
}
}
export const lastSegX: InternalFn = lastSegFactory('x')
export const lastSegY: InternalFn = lastSegFactory('y')
function angleToMatchLengthFactory(which: 'x' | 'y'): InternalFn {
return (_, segName: string, to: number, sketchGroup: SketchGroup): number => {
const isX = which === 'x'
const lineToMatch = sketchGroup?.value.find((seg) => seg.name === segName)
// maybe this should throw, but the language doesn't have a way to handle errors yet
if (!lineToMatch) return 0
const lengthToMatch = Math.sqrt(
(lineToMatch.from[1] - lineToMatch.to[1]) ** 2 +
(lineToMatch.from[0] - lineToMatch.to[0]) ** 2
)
const lastLine = sketchGroup?.value[sketchGroup.value.length - 1]
const diff = Math.abs(to - (isX ? lastLine.to[0] : lastLine.to[1]))
const angleR = Math[isX ? 'acos' : 'asin'](diff / lengthToMatch)
return diff > lengthToMatch ? 0 : (angleR * 180) / Math.PI
}
}
export const angleToMatchLengthX: InternalFn = angleToMatchLengthFactory('x')
export const angleToMatchLengthY: InternalFn = angleToMatchLengthFactory('y')
export function isSketchVariablesLinked(
secondaryVarDec: VariableDeclarator,
primaryVarDec: VariableDeclarator,

View File

@ -1,170 +0,0 @@
import {
lineTo,
xLineTo,
yLineTo,
line,
xLine,
yLine,
angledLine,
angledLineOfXLength,
angledLineToX,
angledLineOfYLength,
angledLineToY,
close,
startSketchAt,
angledLineThatIntersects,
} from './sketch'
import {
segLen,
segAng,
angleToMatchLengthX,
angleToMatchLengthY,
segEndX,
segEndY,
lastSegX,
lastSegY,
} from './sketchConstraints'
import { getExtrudeWallTransform, extrude } from './extrude'
import { InternalFn, InternalFnNames } from './stdTypes'
// const transform: InternalFn = <T extends SketchGroup | ExtrudeGroup>(
// { sourceRange }: InternalFirstArg,
// transformInfo: {
// position: Position
// quaternion: Rotation
// },
// sketch: T
// ): T => {
// const quaternionToApply = new Quaternion(...transformInfo?.quaternion)
// const newQuaternion = new Quaternion(...sketch.rotation).multiply(
// quaternionToApply.invert()
// )
// const oldPosition = new Vector3(...sketch?.position)
// const newPosition = oldPosition
// .applyQuaternion(quaternionToApply)
// .add(new Vector3(...transformInfo?.position))
// return {
// ...sketch,
// position: newPosition.toArray(),
// rotation: newQuaternion.toArray(),
// __meta: [
// ...sketch.__meta,
// {
// sourceRange,
// pathToNode: [], // TODO
// },
// ],
// }
// }
// const translate: InternalFn = <T extends SketchGroup | ExtrudeGroup>(
// { sourceRange }: InternalFirstArg,
// vec3: [number, number, number],
// sketch: T
// ): T => {
// const oldPosition = new Vector3(...sketch.position)
// const newPosition = oldPosition.add(new Vector3(...vec3))
// return {
// ...sketch,
// position: newPosition.toArray(),
// __meta: [
// ...sketch.__meta,
// {
// sourceRange,
// pathToNode: [], // TODO
// },
// ],
// }
// }
const min: InternalFn = (_, a: number, b: number): number => Math.min(a, b)
const legLen: InternalFn = (_, hypotenuse: number, leg: number): number =>
Math.sqrt(
hypotenuse ** 2 - Math.min(Math.abs(leg), Math.abs(hypotenuse)) ** 2
)
const legAngX: InternalFn = (_, hypotenuse: number, leg: number): number =>
(Math.acos(Math.min(leg, hypotenuse) / hypotenuse) * 180) / Math.PI
const legAngY: InternalFn = (_, hypotenuse: number, leg: number): number =>
(Math.asin(Math.min(leg, hypotenuse) / hypotenuse) * 180) / Math.PI
export const internalFns: { [key in InternalFnNames]: InternalFn } = {
// TODO - re-enable these
// rx: rotateOnAxis([1, 0, 0]), // Enable rotations #152
// ry: rotateOnAxis([0, 1, 0]),
// rz: rotateOnAxis([0, 0, 1]),
extrude,
// translate,
// transform,
getExtrudeWallTransform,
min,
legLen,
legAngX,
legAngY,
segEndX,
segEndY,
lastSegX,
lastSegY,
segLen,
segAng,
angleToMatchLengthX,
angleToMatchLengthY,
lineTo: lineTo.fn,
xLineTo: xLineTo.fn,
yLineTo: yLineTo.fn,
line: line.fn,
xLine: xLine.fn,
yLine: yLine.fn,
angledLine: angledLine.fn,
angledLineOfXLength: angledLineOfXLength.fn,
angledLineToX: angledLineToX.fn,
angledLineOfYLength: angledLineOfYLength.fn,
angledLineToY: angledLineToY.fn,
angledLineThatIntersects: angledLineThatIntersects.fn,
startSketchAt,
close,
}
// function rotateOnAxis<T extends SketchGroup | ExtrudeGroup>(
// axisMultiplier: [number, number, number]
// ): InternalFn {
// return ({ sourceRange }, rotationD: number, sketch: T): T => {
// const rotationR = rotationD * (Math.PI / 180)
// const rotateVec = new Vector3(...axisMultiplier)
// const quaternion = new Quaternion()
// quaternion.setFromAxisAngle(rotateVec, rotationR)
// const position = new Vector3(...sketch.position)
// .applyQuaternion(quaternion)
// .toArray()
// const existingQuat = new Quaternion(...sketch.rotation)
// const rotation = quaternion.multiply(existingQuat).toArray()
// return {
// ...sketch,
// rotation,
// position,
// __meta: [
// ...sketch.__meta,
// {
// sourceRange,
// pathToNode: [], // TODO
// },
// ],
// }
// }
// }
export function clockwiseSign(points: [number, number][]): number {
let sum = 0
for (let i = 0; i < points.length; i++) {
const currentPoint = points[i]
const nextPoint = points[(i + 1) % points.length]
sum += (nextPoint[0] - currentPoint[0]) * (nextPoint[1] + currentPoint[1])
}
return sum >= 0 ? 1 : -1
}

View File

@ -17,44 +17,6 @@ export interface PathReturn {
currentPath: Path
}
export type InternalFn = (internals: InternalFirstArg, ...args: any[]) => any
export type InternalFnNames =
// TODO re-enable these
// | 'translate'
// | 'transform'
// | 'rx' // Enable rotations #152
// | 'ry'
// | 'rz'
| 'extrude'
| 'getExtrudeWallTransform'
| 'min'
| 'legLen'
| 'legAngX'
| 'legAngY'
| 'segEndX'
| 'segEndY'
| 'lastSegX'
| 'lastSegY'
| 'segLen'
| 'segAng'
| 'angleToMatchLengthX'
| 'angleToMatchLengthY'
| 'lineTo'
| 'yLineTo'
| 'xLineTo'
| 'line'
| 'yLine'
| 'xLine'
| 'angledLine'
| 'angledLineOfXLength'
| 'angledLineToX'
| 'angledLineOfYLength'
| 'angledLineToY'
| 'startSketchAt'
| 'close'
| 'angledLineThatIntersects'
export interface ModifyAstBase {
node: Program
previousProgramMemory: ProgramMemory
@ -87,7 +49,6 @@ export type SketchCallTransfromMap = {
}
export interface SketchLineHelper {
fn: InternalFn
add: (a: addCall) => {
modifiedAst: Program
pathToNode: PathToNode

View File

@ -1,8 +1,8 @@
import { lexer_js } from '../wasm-lib/pkg/wasm_lib'
import { initPromise } from './rust'
import { Token } from '../wasm-lib/bindings/Token'
import { Token } from '../wasm-lib/kcl/bindings/Token'
export type { Token } from '../wasm-lib/bindings/Token'
export type { Token } from '../wasm-lib/kcl/bindings/Token'
export async function asyncLexer(str: string): Promise<Token[]> {
await initPromise

View File

@ -26,3 +26,12 @@ export function updateCursors(
setCursor(newSelections)
}
}
export function isReducedMotion(): boolean {
return (
typeof window !== 'undefined' &&
window.matchMedia &&
// TODO/Note I (Kurt) think '(prefers-reduced-motion: reduce)' and '(prefers-reduced-motion)' are equivalent, but not 100% sure
window.matchMedia('(prefers-reduced-motion)').matches
)
}

124
src/lib/commands.ts Normal file
View File

@ -0,0 +1,124 @@
import { AnyStateMachine, EventFrom, StateFrom } from 'xstate'
import { isTauri } from './isTauri'
type InitialCommandBarMetaArg = {
name: string
type: 'string' | 'select'
description?: string
defaultValue?: string
options: string | Array<{ name: string }>
}
type Platform = 'both' | 'web' | 'desktop'
export type CommandBarMeta = {
[key: string]:
| {
displayValue: (args: string[]) => string
args: InitialCommandBarMetaArg[]
hide?: Platform
}
| {
hide?: Platform
}
}
export type Command = {
owner: string
name: string
callback: Function
meta?: {
displayValue(args: string[]): string | string
args: SubCommand[]
}
}
export type SubCommand = {
name: string
type: 'select' | 'string'
description?: string
options?: Partial<{ name: string }>[]
}
interface CommandBarArgs<T extends AnyStateMachine> {
type: EventFrom<T>['type']
state: StateFrom<T>
commandBarMeta?: CommandBarMeta
send: Function
owner: string
}
export function createMachineCommand<T extends AnyStateMachine>({
type,
state,
commandBarMeta,
send,
owner,
}: CommandBarArgs<T>): Command | null {
const lookedUpMeta = commandBarMeta && commandBarMeta[type]
if (lookedUpMeta && 'hide' in lookedUpMeta) {
const { hide } = lookedUpMeta
if (hide === 'both') return null
else if (hide === 'desktop' && isTauri()) return null
else if (hide === 'web' && !isTauri()) return null
}
let replacedArgs
if (lookedUpMeta && 'args' in lookedUpMeta) {
replacedArgs = lookedUpMeta.args.map((arg) => {
const optionsFromContext = state.context[
arg.options as keyof typeof state.context
] as { name: string }[] | string | undefined
const defaultValueFromContext = state.context[
arg.defaultValue as keyof typeof state.context
] as string | undefined
const options =
arg.options instanceof Array
? arg.options.map((o) => ({
...o,
description:
defaultValueFromContext === o.name ? '(current)' : '',
}))
: !optionsFromContext || typeof optionsFromContext === 'string'
? [
{
name: optionsFromContext,
description: arg.description || '',
},
]
: optionsFromContext.map((o) => ({
name: o.name || '',
description: arg.description || '',
}))
return {
...arg,
options,
}
}) as any[]
}
// We have to recreate this object every time,
// otherwise we'll have stale state in the CommandBar
// after completing our first action
const meta = lookedUpMeta
? {
...lookedUpMeta,
args: replacedArgs,
}
: undefined
return {
name: type,
owner,
callback: (data: EventFrom<T, typeof type>) => {
if (data !== undefined && data !== null) {
send(type, { data })
} else {
send(type)
}
},
meta: meta as any,
}
}

View File

@ -1,16 +1,13 @@
import { useAuthMachine } from '../hooks/useAuthMachine'
export default function fetcher(input: RequestInfo, init: RequestInit = {}) {
const fetcherWithToken = async (token?: string): Promise<JSON> => {
const headers = { ...init.headers } as Record<string, string>
if (token) {
headers.Authorization = `Bearer ${token}`
}
export default async function fetcher<JSON = any>(
input: RequestInfo,
init: RequestInit = {}
): Promise<JSON> {
const [token] = useAuthMachine((s) => s?.context?.token)
const headers = { ...init.headers } as Record<string, string>
if (token) {
headers.Authorization = `Bearer ${token}`
const credentials = 'include' as RequestCredentials
const res = await fetch(input, { ...init, credentials, headers })
return res.json()
}
const credentials = 'include' as RequestCredentials
const res = await fetch(input, { ...init, credentials, headers })
return res.json()
return fetcherWithToken
}

View File

@ -1,9 +0,0 @@
import { Themes } from '../useStore'
export function getSystemTheme(): Exclude<Themes, 'system'> {
return typeof window !== 'undefined' &&
'matchMedia' in window &&
window.matchMedia('(prefers-color-scheme: dark)').matches
? Themes.Dark
: Themes.Light
}

64
src/lib/sorting.ts Normal file
View File

@ -0,0 +1,64 @@
import {
faArrowDown,
faArrowUp,
faCircleDot,
} from '@fortawesome/free-solid-svg-icons'
import { ProjectWithEntryPointMetadata } from '../Router'
const DESC = ':desc'
export function getSortIcon(currentSort: string, newSort: string) {
if (currentSort === newSort) {
return faArrowUp
} else if (currentSort === newSort + DESC) {
return faArrowDown
}
return faCircleDot
}
export function getNextSearchParams(currentSort: string, newSort: string) {
if (currentSort === null || !currentSort)
return { sort_by: newSort + (newSort !== 'modified' ? DESC : '') }
if (currentSort.includes(newSort) && !currentSort.includes(DESC))
return { sort_by: '' }
return {
sort_by: newSort + (currentSort.includes(DESC) ? '' : DESC),
}
}
export function getSortFunction(sortBy: string) {
const sortByName = (
a: ProjectWithEntryPointMetadata,
b: ProjectWithEntryPointMetadata
) => {
if (a.name && b.name) {
return sortBy.includes('desc')
? a.name.localeCompare(b.name)
: b.name.localeCompare(a.name)
}
return 0
}
const sortByModified = (
a: ProjectWithEntryPointMetadata,
b: ProjectWithEntryPointMetadata
) => {
if (
a.entrypoint_metadata?.modifiedAt &&
b.entrypoint_metadata?.modifiedAt
) {
return !sortBy || sortBy.includes('desc')
? b.entrypoint_metadata.modifiedAt.getTime() -
a.entrypoint_metadata.modifiedAt.getTime()
: a.entrypoint_metadata.modifiedAt.getTime() -
b.entrypoint_metadata.modifiedAt.getTime()
}
return 0
}
if (sortBy?.includes('name')) {
return sortByName
} else {
return sortByModified
}
}

View File

@ -1,6 +1,11 @@
import { FileEntry, createDir, exists, writeTextFile } from '@tauri-apps/api/fs'
import {
FileEntry,
createDir,
exists,
readDir,
writeTextFile,
} from '@tauri-apps/api/fs'
import { documentDir } from '@tauri-apps/api/path'
import { useStore } from '../useStore'
import { isTauri } from './isTauri'
import { ProjectWithEntryPointMetadata } from '../Router'
import { metadata } from 'tauri-plugin-fs-extra-api'
@ -12,35 +17,31 @@ const INDEX_IDENTIFIER = '$n' // $nn.. will pad the number with 0s
export const MAX_PADDING = 7
// Initializes the project directory and returns the path
export async function initializeProjectDirectory() {
export async function initializeProjectDirectory(directory: string) {
if (!isTauri()) {
throw new Error(
'initializeProjectDirectory() can only be called from a Tauri app'
)
}
const { defaultDir: projectDir, setDefaultDir } = useStore.getState()
if (projectDir && projectDir.dir.length > 0) {
const dirExists = await exists(projectDir.dir)
if (directory) {
const dirExists = await exists(directory)
if (!dirExists) {
await createDir(projectDir.dir, { recursive: true })
await createDir(directory, { recursive: true })
}
return projectDir
return directory
}
const appData = await documentDir()
const docDirectory = await documentDir()
const INITIAL_DEFAULT_DIR = {
dir: appData + PROJECT_FOLDER,
}
const INITIAL_DEFAULT_DIR = docDirectory + PROJECT_FOLDER
const defaultDirExists = await exists(INITIAL_DEFAULT_DIR.dir)
const defaultDirExists = await exists(INITIAL_DEFAULT_DIR)
if (!defaultDirExists) {
await createDir(INITIAL_DEFAULT_DIR.dir, { recursive: true })
await createDir(INITIAL_DEFAULT_DIR, { recursive: true })
}
setDefaultDir(INITIAL_DEFAULT_DIR)
return INITIAL_DEFAULT_DIR
}
@ -51,6 +52,25 @@ export function isProjectDirectory(fileOrDir: Partial<FileEntry>) {
)
}
// Read the contents of a directory
// and return the valid projects
export async function getProjectsInDir(projectDir: string) {
const readProjects = (
await readDir(projectDir, {
recursive: true,
})
).filter(isProjectDirectory)
const projectsWithMetadata = await Promise.all(
readProjects.map(async (p) => ({
entrypoint_metadata: await metadata(p.path + '/' + PROJECT_ENTRYPOINT),
...p,
}))
)
return projectsWithMetadata
}
// Creates a new file in the default directory with the default project name
// Returns the path to the new file
export async function createNewProject(

View File

@ -1,6 +1,10 @@
import { Program } from '../lang/abstractSyntaxTreeTypes'
import { ProgramMemory, _executor } from '../lang/executor'
import { EngineCommandManager } from '../lang/std/engineConnection'
import {
EngineCommandManager,
EngineCommand,
} from '../lang/std/engineConnection'
import { SourceRange } from 'lang/executor'
class MockEngineCommandManager {
constructor(mockParams: {
@ -10,13 +14,43 @@ class MockEngineCommandManager {
startNewSession() {}
waitForAllCommands() {}
waitForReady = new Promise<void>((resolve) => resolve())
sendModellingCommand() {}
sendModelingCommand({
id,
range,
command,
}: {
id: string
range: SourceRange
command: EngineCommand
}): Promise<any> {
return Promise.resolve()
}
sendModelingCommandFromWasm(
id: string,
rangeStr: string,
commandStr: string
): Promise<any> {
if (id === undefined) {
throw new Error('id is undefined')
}
if (rangeStr === undefined) {
throw new Error('rangeStr is undefined')
}
if (commandStr === undefined) {
throw new Error('commandStr is undefined')
}
console.log('sendModelingCommandFromWasm', id, rangeStr, commandStr)
const command: EngineCommand = JSON.parse(commandStr)
const range: SourceRange = JSON.parse(rangeStr)
return this.sendModelingCommand({ id, range, command })
}
sendSceneCommand() {}
}
export async function enginelessExecutor(
ast: Program,
pm: ProgramMemory = { root: {}, pendingMemory: {} }
pm: ProgramMemory = { root: {} }
): Promise<ProgramMemory> {
const mockEngineCommandManager = new MockEngineCommandManager({
setIsStreamReady: () => {},
@ -31,7 +65,7 @@ export async function enginelessExecutor(
export async function executor(
ast: Program,
pm: ProgramMemory = { root: {}, pendingMemory: {} }
pm: ProgramMemory = { root: {} }
): Promise<ProgramMemory> {
const engineCommandManager = new EngineCommandManager({
setIsStreamReady: () => {},

23
src/lib/theme.ts Normal file
View File

@ -0,0 +1,23 @@
export enum Themes {
Light = 'light',
Dark = 'dark',
System = 'system',
}
// Get the theme from the system settings manually
export function getSystemTheme(): Exclude<Themes, 'system'> {
return typeof window !== 'undefined' && 'matchMedia' in window
? window.matchMedia('(prefers-color-scheme: dark)').matches
? Themes.Dark
: Themes.Light
: Themes.Light
}
// Set the theme class on the body element
export function setThemeClass(theme: Themes) {
if (theme === Themes.Dark) {
document.body.classList.add('dark')
} else {
document.body.classList.remove('dark')
}
}

View File

@ -1,6 +1,24 @@
import { createMachine, assign } from 'xstate'
import { Models } from '@kittycad/lib'
import withBaseURL from '../lib/withBaseURL'
import { CommandBarMeta } from '../lib/commands'
const SKIP_AUTH =
import.meta.env.VITE_KC_SKIP_AUTH === 'true' && import.meta.env.DEV
const LOCAL_USER: Models['User_type'] = {
id: '8675309',
name: 'Test User',
email: 'kittycad.sidebar.test@example.com',
image: 'https://placekitten.com/200/200',
created_at: 'yesteryear',
updated_at: 'today',
company: 'Test Company',
discord: 'Test User#1234',
github: 'testuser',
phone: '555-555-5555',
first_name: 'Test',
last_name: 'User',
}
export interface UserContext {
user?: Models['User_type']
@ -9,16 +27,22 @@ export interface UserContext {
export type Events =
| {
type: 'logout'
type: 'Log out'
}
| {
type: 'tryLogin'
type: 'Log in'
token?: string
}
export const TOKEN_PERSIST_KEY = 'TOKEN_PERSIST_KEY'
const persistedToken = localStorage?.getItem(TOKEN_PERSIST_KEY) || ''
export const authCommandBarMeta: CommandBarMeta = {
'Log in': {
hide: 'both',
},
}
export const authMachine = createMachine<UserContext, Events>(
{
id: 'Auth',
@ -50,7 +74,7 @@ export const authMachine = createMachine<UserContext, Events>(
loggedIn: {
entry: ['goToIndexPage'],
on: {
logout: {
'Log out': {
target: 'loggedOut',
},
},
@ -58,10 +82,10 @@ export const authMachine = createMachine<UserContext, Events>(
loggedOut: {
entry: ['goToSignInPage'],
on: {
tryLogin: {
'Log in': {
target: 'checkIfLoggedIn',
actions: assign({
token: (context, event) => {
token: (_, event) => {
const token = event.token || ''
localStorage.setItem(TOKEN_PERSIST_KEY, token)
return token
@ -71,10 +95,12 @@ export const authMachine = createMachine<UserContext, Events>(
},
},
},
schema: { events: {} as { type: 'logout' } | { type: 'tryLogin' } },
schema: { events: {} as { type: 'Log out' } | { type: 'Log in' } },
predictableActionArguments: true,
preserveActionOrder: true,
context: { token: persistedToken },
context: {
token: persistedToken,
},
},
{
actions: {},
@ -91,12 +117,17 @@ async function getUser(context: UserContext) {
}
if (!context.token && '__TAURI__' in window) throw 'not log in'
if (context.token) headers['Authorization'] = `Bearer ${context.token}`
const response = await fetch(url, {
method: 'GET',
credentials: 'include',
headers,
})
const user = await response.json()
if ('error_code' in user) throw new Error(user.message)
return user
if (SKIP_AUTH) return LOCAL_USER
try {
const response = await fetch(url, {
method: 'GET',
credentials: 'include',
headers,
})
const user = await response.json()
if ('error_code' in user) throw new Error(user.message)
return user
} catch (e) {
console.error(e)
}
}

218
src/machines/homeMachine.ts Normal file
View File

@ -0,0 +1,218 @@
import { assign, createMachine } from 'xstate'
import { ProjectWithEntryPointMetadata } from '../Router'
import { CommandBarMeta } from '../lib/commands'
export const homeCommandMeta: CommandBarMeta = {
'Create project': {
displayValue: (args: string[]) => `Create project "${args[0]}"`,
args: [
{
name: 'name',
type: 'string',
description: '(default)',
options: 'defaultProjectName',
},
],
},
'Open project': {
displayValue: (args: string[]) => `Open project "${args[0]}"`,
args: [
{
name: 'name',
type: 'select',
options: 'projects',
},
],
},
'Delete project': {
displayValue: (args: string[]) => `Delete project "${args[0]}"`,
args: [
{
name: 'name',
type: 'select',
options: 'projects',
},
],
},
'Rename project': {
displayValue: (args: string[]) =>
`Rename project "${args[0]}" to "${args[1]}"`,
args: [
{
name: 'oldName',
type: 'select',
options: 'projects',
},
{
name: 'newName',
type: 'string',
description: '(default)',
options: 'defaultProjectName',
},
],
},
assign: {
hide: 'both',
},
}
export const homeMachine = createMachine(
{
/** @xstate-layout N4IgpgJg5mDOIC5QAkD2BbMACdBDAxgBYCWAdmAHTK6xampYAOATqgFZj4AusAxAMLMwuLthbtOXANoAGALqJQjVLGJdiqUopAAPRAHYAbPooAWABwBGUwE5zAJgeGArM-MAaEAE9EN0wGYKGX97GX1nGVNDS0MbfwBfeM80TBwCEnIqGiZWDm4+ACUwUlxU8TzpeW1lVXVNbT0EcJNg02d-fzt7fU77Tx8EQ0iKCPtnfUsjGRtLGXtE5IxsPCIySmpacsk+QWFRHIluWQUkEBq1DS1TxqN7ChjzOxtXf0t7a37EcwsRibH-ZzRezA8wLEApZbpNZZTa5ba8AAiYAANmB9lsjlVTuc6ldQDdDOYKP5bm0os5TDJDJ8mlEzPpzIZHA4bO9umCIWlVpkNgcKnwAPKMYp8yTHaoqC71a6IEmBUz6BkWZzWDq2Uw0qzOIJAwz+PXWfSmeZJcFLLkZSi7ERkKCi7i8CCaShkABuqAA1pR8EIRGAALQYyonJSS3ENRDA2wUeyvd6dPVhGw0-RhGOp8IA8xGFkc80rS0Ua3qUh2oO8MDMVjMCiMZEiABmqGY6AoPr2AaD4uxYcuEYQoQpQWNNjsMnMgLGKbT3TC7TcOfsNjzqQL0KKJXQtvtXEdzoobs9lCEm87cMxIbOvel+MQqtMQRmS5ks31sZpAUsZkcIX+cQZJIrpC3KUBupTbuWlbVrW9ZcE2LYUCepRnocwYSrUfYyggbzvBQ+jMq49imLYwTUt4iCft+5i-u0-7UfoQEWtCSKoiWZbnruTqZIeXoUBAKJoihFTdqGGE3rod7UdqsQTI8hiGAqrIauRA7RvYeoqhO1jtAqjFrpkLFohBHEVlWzYwY2zatvxrFCWKWKiVKeISdh4yBJE-jGs4fhhA4zg0kRNgxhplhaW0nn4XpUKZEUuAQMZqF8FxLqkO6vG+hAgYcbAIlXmJzmNERdy0RYNiKgpthxDSEU6q8MSTJYjWGFFIEULF8WljuSX7jxx7CJlQY5ZYl44pht4IP61gyPc8njt0lIuH51UKrVVITEyMy2C1hbtQl-KmdBdaWQhGVZYluWjeJjSTf402shMEyuEyljPAFL0UNmMiuN86lWHMiSmvQ-HwKcnL6WA6FOf2k3mESMRDA4RpUm4U4qf6gSEt0QIvvqfjOCaiyrtF6zZPQXWQ+GWFlUEsbmNMf1TV9NLeXDcqRIySnNaaYPEzC5M9vl-b+IyFCjupryPF9jKWP5Kks-cbMWLERHRNt0LFntkgU2NLk4dqsz43YsTK++Kk2C+MbTOOcxzOMrhqzFxTgZ1Qba1dd6BUE1jGsLMxxK9KlDNqm3tMLUQvqYlgO5QhlsTubsFXesTTUuPTfHExshDS0RftRftGgEnTZtHbX9Zr+QJ-2S4Y3qnmTC+4tMyp1EfeOnmeQqdOhyXQrFOXXCV1hCkmLDOnBJYvRRDSsyRzGjiKj0lKdAkANAA */
id: 'Home machine',
initial: 'Reading projects',
context: {
projects: [] as ProjectWithEntryPointMetadata[],
defaultProjectName: '',
defaultDirectory: '',
},
on: {
assign: {
actions: assign((_, event) => ({
...event.data,
})),
target: '.Reading projects',
},
},
states: {
'Has no projects': {
on: {
'Create project': {
target: 'Creating project',
},
},
},
'Has projects': {
on: {
'Rename project': {
target: 'Renaming project',
},
'Create project': {
target: 'Creating project',
},
'Delete project': {
target: 'Deleting project',
},
'Open project': {
target: 'Opening project',
},
},
},
'Creating project': {
invoke: {
id: 'create-project',
src: 'createProject',
onDone: [
{
target: 'Reading projects',
actions: ['toastSuccess'],
},
],
onError: [
{
target: 'Reading projects',
actions: ['toastError'],
},
],
},
},
'Renaming project': {
invoke: {
id: 'rename-project',
src: 'renameProject',
onDone: [
{
target: '#Home machine.Reading projects',
actions: ['toastSuccess'],
},
],
onError: [
{
target: '#Home machine.Reading projects',
actions: ['toastError'],
},
],
},
},
'Deleting project': {
invoke: {
id: 'delete-project',
src: 'deleteProject',
onDone: [
{
actions: ['toastSuccess'],
target: '#Home machine.Reading projects',
},
],
onError: {
actions: ['toastError'],
target: '#Home machine.Has projects',
},
},
},
'Reading projects': {
invoke: {
id: 'read-projects',
src: 'readProjects',
onDone: [
{
cond: 'Has at least 1 project',
target: 'Has projects',
actions: ['setProjects'],
},
{
target: 'Has no projects',
actions: ['setProjects'],
},
],
onError: [
{
target: 'Has no projects',
actions: ['toastError'],
},
],
},
},
'Opening project': {
entry: ['navigateToProject'],
},
},
schema: {
events: {} as
| { type: 'Open project'; data: { name: string } }
| { type: 'Rename project'; data: { oldName: string; newName: string } }
| { type: 'Create project'; data: { name: string } }
| { type: 'Delete project'; data: { name: string } }
| { type: 'navigate'; data: { name: string } }
| {
type: 'done.invoke.read-projects'
data: ProjectWithEntryPointMetadata[]
}
| { type: 'assign'; data: { [key: string]: any } },
},
predictableActionArguments: true,
preserveActionOrder: true,
tsTypes: {} as import('./homeMachine.typegen').Typegen0,
},
{
actions: {
setProjects: assign((_, event) => {
return { projects: event.data as ProjectWithEntryPointMetadata[] }
}),
},
}
)

View File

@ -0,0 +1,99 @@
// This file was automatically generated. Edits will be overwritten
export interface Typegen0 {
'@@xstate/typegen': true
internalEvents: {
'done.invoke.create-project': {
type: 'done.invoke.create-project'
data: unknown
__tip: 'See the XState TS docs to learn how to strongly type this.'
}
'done.invoke.delete-project': {
type: 'done.invoke.delete-project'
data: unknown
__tip: 'See the XState TS docs to learn how to strongly type this.'
}
'done.invoke.read-projects': {
type: 'done.invoke.read-projects'
data: unknown
__tip: 'See the XState TS docs to learn how to strongly type this.'
}
'done.invoke.rename-project': {
type: 'done.invoke.rename-project'
data: unknown
__tip: 'See the XState TS docs to learn how to strongly type this.'
}
'error.platform.create-project': {
type: 'error.platform.create-project'
data: unknown
}
'error.platform.delete-project': {
type: 'error.platform.delete-project'
data: unknown
}
'error.platform.read-projects': {
type: 'error.platform.read-projects'
data: unknown
}
'error.platform.rename-project': {
type: 'error.platform.rename-project'
data: unknown
}
'xstate.init': { type: 'xstate.init' }
}
invokeSrcNameMap: {
createProject: 'done.invoke.create-project'
deleteProject: 'done.invoke.delete-project'
readProjects: 'done.invoke.read-projects'
renameProject: 'done.invoke.rename-project'
}
missingImplementations: {
actions: 'navigateToProject' | 'toastError' | 'toastSuccess'
delays: never
guards: 'Has at least 1 project'
services:
| 'createProject'
| 'deleteProject'
| 'readProjects'
| 'renameProject'
}
eventsCausingActions: {
navigateToProject: 'Open project'
setProjects: 'done.invoke.read-projects'
toastError:
| 'error.platform.create-project'
| 'error.platform.delete-project'
| 'error.platform.read-projects'
| 'error.platform.rename-project'
toastSuccess:
| 'done.invoke.create-project'
| 'done.invoke.delete-project'
| 'done.invoke.rename-project'
}
eventsCausingDelays: {}
eventsCausingGuards: {
'Has at least 1 project': 'done.invoke.read-projects'
}
eventsCausingServices: {
createProject: 'Create project'
deleteProject: 'Delete project'
readProjects:
| 'assign'
| 'done.invoke.create-project'
| 'done.invoke.delete-project'
| 'done.invoke.rename-project'
| 'error.platform.create-project'
| 'error.platform.rename-project'
| 'xstate.init'
renameProject: 'Rename project'
}
matchesStates:
| 'Creating project'
| 'Deleting project'
| 'Has no projects'
| 'Has projects'
| 'Opening project'
| 'Reading projects'
| 'Renaming project'
tags: never
}

View File

@ -0,0 +1,207 @@
import { assign, createMachine } from 'xstate'
import { BaseUnit, baseUnitsUnion } from '../useStore'
import { CommandBarMeta } from '../lib/commands'
import { Themes, getSystemTheme, setThemeClass } from '../lib/theme'
export enum UnitSystem {
Imperial = 'imperial',
Metric = 'metric',
}
export const SETTINGS_PERSIST_KEY = 'SETTINGS_PERSIST_KEY'
export const settingsCommandBarMeta: CommandBarMeta = {
'Set Theme': {
displayValue: (args: string[]) => 'Change the app theme',
args: [
{
name: 'theme',
type: 'select',
defaultValue: 'theme',
options: Object.values(Themes).map((v) => ({ name: v })) as {
name: string
}[],
},
],
},
'Set Default Project Name': {
displayValue: (args: string[]) => 'Set a new default project name',
hide: 'web',
args: [
{
name: 'defaultProjectName',
type: 'string',
description: '(default)',
defaultValue: 'defaultProjectName',
options: 'defaultProjectName',
},
],
},
'Set Default Directory': {
hide: 'both',
},
'Set Unit System': {
displayValue: (args: string[]) => 'Set your default unit system',
args: [
{
name: 'unitSystem',
type: 'select',
defaultValue: 'unitSystem',
options: [{ name: UnitSystem.Imperial }, { name: UnitSystem.Metric }],
},
],
},
'Set Base Unit': {
displayValue: (args: string[]) => 'Set your default base unit',
args: [
{
name: 'baseUnit',
type: 'select',
defaultValue: 'baseUnit',
options: Object.values(baseUnitsUnion).map((v) => ({ name: v })),
},
],
},
'Set Onboarding Status': {
hide: 'both',
},
}
export const settingsMachine = createMachine(
{
/** @xstate-layout N4IgpgJg5mDOIC5QGUwBc0EsB2VYDpMIAbMAYlTQAIAVACzAFswBtABgF1FQAHAe1iYsfbNxAAPRAA42+AEwB2KQFYAzGznKAnADZli1QBoQAT2kBGKfm37lOned3nzqgL6vjlLLgJFSFdCoAETAAMwBDAFdiagAFACc+ACswAGNqADlw5nYuJBB+QWFRfMkEABY5fDYa2rra83LjMwQdLWV8BXLyuxlVLU1Ld090bzxCEnJKYLComODMeLS0PniTXLFCoUwRMTK7fC1zNql7NgUjtnKjU0RlBSqpLVUVPVUda60tYZAvHHG-FNAgBVbBCKjIEywNBMDb5LbFPaILqdfRSORsS4qcxXZqIHqyK6qY4XOxsGTKco-P4+Cb+aYAIXCsDAVFBQjhvAE212pWkskUKnUml0+gUNxaqkU+EccnKF1UCnucnMcjcHl+o3+vkmZBofCgUFIMwARpEoFRYuFsGBiJyCtzEXzWrJlGxlKdVFKvfY1XiEBjyvhVOVzBdzu13pYFNStbTAQFqAB5bAmvjheIQf4QtDhNCRWD2hE7EqgfayHTEh7lHQNSxSf1Scz4cpHHFyFVujTKczuDXYPgQOBiGl4TaOktIhAAWg6X3nC4Xp39050sYw2rpYHHRUnztVhPJqmUlIGbEriv9WhrLZ6uibHcqUr7riAA */
id: 'Settings',
predictableActionArguments: true,
context: {
theme: Themes.System,
defaultProjectName: '',
unitSystem: UnitSystem.Imperial,
baseUnit: 'in' as BaseUnit,
defaultDirectory: '',
showDebugPanel: false,
onboardingStatus: '',
},
initial: 'idle',
states: {
idle: {
entry: ['setThemeClass'],
on: {
'Set Theme': {
actions: [
assign({
theme: (_, event) => event.data.theme,
}),
'persistSettings',
'toastSuccess',
'setThemeClass',
],
target: 'idle',
internal: true,
},
'Set Default Project Name': {
actions: [
assign({
defaultProjectName: (_, event) => event.data.defaultProjectName,
}),
'persistSettings',
'toastSuccess',
],
target: 'idle',
internal: true,
},
'Set Default Directory': {
actions: [
assign({
defaultDirectory: (_, event) => event.data.defaultDirectory,
}),
'persistSettings',
'toastSuccess',
],
target: 'idle',
internal: true,
},
'Set Unit System': {
actions: [
assign({
unitSystem: (_, event) => event.data.unitSystem,
baseUnit: (_, event) =>
event.data.unitSystem === 'imperial' ? 'in' : 'mm',
}),
'persistSettings',
'toastSuccess',
],
target: 'idle',
internal: true,
},
'Set Base Unit': {
actions: [
assign({ baseUnit: (_, event) => event.data.baseUnit }),
'persistSettings',
'toastSuccess',
],
target: 'idle',
internal: true,
},
'Toggle Debug Panel': {
actions: [
assign({
showDebugPanel: (context) => {
return !context.showDebugPanel
},
}),
'persistSettings',
'toastSuccess',
],
target: 'idle',
internal: true,
},
'Set Onboarding Status': {
actions: [
assign({
onboardingStatus: (_, event) => event.data.onboardingStatus,
}),
'persistSettings',
],
target: 'idle',
internal: true,
},
},
},
},
tsTypes: {} as import('./settingsMachine.typegen').Typegen0,
schema: {
events: {} as
| { type: 'Set Theme'; data: { theme: Themes } }
| {
type: 'Set Default Project Name'
data: { defaultProjectName: string }
}
| { type: 'Set Default Directory'; data: { defaultDirectory: string } }
| {
type: 'Set Unit System'
data: { unitSystem: UnitSystem }
}
| { type: 'Set Base Unit'; data: { baseUnit: BaseUnit } }
| { type: 'Set Onboarding Status'; data: { onboardingStatus: string } }
| { type: 'Toggle Debug Panel' },
},
},
{
actions: {
persistSettings: (context) => {
try {
localStorage.setItem(SETTINGS_PERSIST_KEY, JSON.stringify(context))
} catch (e) {
console.error(e)
}
},
setThemeClass: (context, event) => {
const currentTheme =
event.type === 'Set Theme' ? event.data.theme : context.theme
setThemeClass(
currentTheme === Themes.System ? getSystemTheme() : currentTheme
)
},
},
}
)

View File

@ -0,0 +1,46 @@
// This file was automatically generated. Edits will be overwritten
export interface Typegen0 {
'@@xstate/typegen': true
internalEvents: {
'xstate.init': { type: 'xstate.init' }
}
invokeSrcNameMap: {}
missingImplementations: {
actions: 'toastSuccess'
delays: never
guards: never
services: never
}
eventsCausingActions: {
persistSettings:
| 'Set Base Unit'
| 'Set Default Directory'
| 'Set Default Project Name'
| 'Set Onboarding Status'
| 'Set Theme'
| 'Set Unit System'
| 'Toggle Debug Panel'
setThemeClass:
| 'Set Base Unit'
| 'Set Default Directory'
| 'Set Default Project Name'
| 'Set Onboarding Status'
| 'Set Theme'
| 'Set Unit System'
| 'Toggle Debug Panel'
| 'xstate.init'
toastSuccess:
| 'Set Base Unit'
| 'Set Default Directory'
| 'Set Default Project Name'
| 'Set Theme'
| 'Set Unit System'
| 'Toggle Debug Panel'
}
eventsCausingDelays: {}
eventsCausingGuards: {}
eventsCausingServices: {}
matchesStates: 'idle'
tags: never
}

View File

@ -1,93 +1,139 @@
import { FormEvent, useCallback, useEffect, useState } from 'react'
import { readDir, removeDir, renameFile } from '@tauri-apps/api/fs'
import { FormEvent, useEffect } from 'react'
import { removeDir, renameFile } from '@tauri-apps/api/fs'
import {
createNewProject,
getNextProjectIndex,
interpolateProjectNameWithIndex,
doesProjectNameNeedInterpolated,
isProjectDirectory,
PROJECT_ENTRYPOINT,
getProjectsInDir,
} from '../lib/tauriFS'
import { ActionButton } from '../components/ActionButton'
import {
faArrowDown,
faArrowUp,
faCircleDot,
faPlus,
} from '@fortawesome/free-solid-svg-icons'
import { useStore } from '../useStore'
import { faArrowDown, faPlus } from '@fortawesome/free-solid-svg-icons'
import { toast } from 'react-hot-toast'
import { AppHeader } from '../components/AppHeader'
import ProjectCard from '../components/ProjectCard'
import { useLoaderData, useSearchParams } from 'react-router-dom'
import { useLoaderData, useNavigate, useSearchParams } from 'react-router-dom'
import { Link } from 'react-router-dom'
import { ProjectWithEntryPointMetadata, HomeLoaderData } from '../Router'
import Loading from '../components/Loading'
import { metadata } from 'tauri-plugin-fs-extra-api'
const DESC = ':desc'
import { useMachine } from '@xstate/react'
import { homeCommandMeta, homeMachine } from '../machines/homeMachine'
import { ContextFrom, EventFrom } from 'xstate'
import { paths } from '../Router'
import {
getNextSearchParams,
getSortFunction,
getSortIcon,
} from '../lib/sorting'
import useStateMachineCommands from '../hooks/useStateMachineCommands'
import { useGlobalStateContext } from 'hooks/useGlobalStateContext'
import { useCommandsContext } from 'hooks/useCommandsContext'
// This route only opens in the Tauri desktop context for now,
// as defined in Router.tsx, so we can use the Tauri APIs and types.
const Home = () => {
const [searchParams, setSearchParams] = useSearchParams()
const sort = searchParams.get('sort_by') ?? 'modified:desc'
const { commands, setCommandBarOpen } = useCommandsContext()
const navigate = useNavigate()
const { projects: loadedProjects } = useLoaderData() as HomeLoaderData
const [isLoading, setIsLoading] = useState(true)
const [projects, setProjects] = useState(loadedProjects || [])
const { defaultDir, defaultProjectName } = useStore((s) => ({
defaultDir: s.defaultDir,
defaultProjectName: s.defaultProjectName,
}))
const {
settings: {
context: { defaultDirectory, defaultProjectName },
},
} = useGlobalStateContext()
const modifiedSelected = sort?.includes('modified') || !sort || sort === null
const [state, send] = useMachine(homeMachine, {
context: {
projects: loadedProjects,
defaultProjectName,
defaultDirectory,
},
actions: {
navigateToProject: (
context: ContextFrom<typeof homeMachine>,
event: EventFrom<typeof homeMachine>
) => {
if (event.data && 'name' in event.data) {
setCommandBarOpen(false)
navigate(
`${paths.FILE}/${encodeURIComponent(
context.defaultDirectory + '/' + event.data.name
)}`
)
}
},
toastSuccess: (_, event) => toast.success((event.data || '') + ''),
toastError: (_, event) => toast.error((event.data || '') + ''),
},
services: {
readProjects: async (context: ContextFrom<typeof homeMachine>) =>
getProjectsInDir(context.defaultDirectory),
createProject: async (
context: ContextFrom<typeof homeMachine>,
event: EventFrom<typeof homeMachine, 'Create project'>
) => {
let name =
event.data && 'name' in event.data
? event.data.name
: defaultProjectName
if (doesProjectNameNeedInterpolated(name)) {
const nextIndex = await getNextProjectIndex(name, projects)
name = interpolateProjectNameWithIndex(name, nextIndex)
}
const refreshProjects = useCallback(
async (projectDir = defaultDir) => {
const readProjects = (
await readDir(projectDir.dir, {
await createNewProject(context.defaultDirectory + '/' + name)
return `Successfully created "${name}"`
},
renameProject: async (
context: ContextFrom<typeof homeMachine>,
event: EventFrom<typeof homeMachine, 'Rename project'>
) => {
const { oldName, newName } = event.data
let name = newName ? newName : context.defaultProjectName
if (doesProjectNameNeedInterpolated(name)) {
const nextIndex = await getNextProjectIndex(name, projects)
name = interpolateProjectNameWithIndex(name, nextIndex)
}
await renameFile(
context.defaultDirectory + '/' + oldName,
context.defaultDirectory + '/' + name
)
return `Successfully renamed "${oldName}" to "${name}"`
},
deleteProject: async (
context: ContextFrom<typeof homeMachine>,
event: EventFrom<typeof homeMachine, 'Delete project'>
) => {
await removeDir(context.defaultDirectory + '/' + event.data.name, {
recursive: true,
})
).filter(isProjectDirectory)
const projectsWithMetadata = await Promise.all(
readProjects.map(async (p) => ({
entrypoint_metadata: await metadata(
p.path + '/' + PROJECT_ENTRYPOINT
),
...p,
}))
)
setProjects(projectsWithMetadata)
return `Successfully deleted "${event.data.name}"`
},
},
[defaultDir, setProjects]
)
guards: {
'Has at least 1 project': (_, event: EventFrom<typeof homeMachine>) => {
if (event.type !== 'done.invoke.read-projects') return false
return event?.data?.length ? event.data?.length >= 1 : false
},
},
})
const { projects } = state.context
const [searchParams, setSearchParams] = useSearchParams()
const sort = searchParams.get('sort_by') ?? 'modified:desc'
const isSortByModified = sort?.includes('modified') || !sort || sort === null
useStateMachineCommands<typeof homeMachine>({
commands,
send,
state,
commandBarMeta: homeCommandMeta,
owner: 'home',
})
useEffect(() => {
refreshProjects(defaultDir).then(() => {
setIsLoading(false)
})
}, [setIsLoading, refreshProjects, defaultDir])
async function handleNewProject() {
let projectName = defaultProjectName
if (doesProjectNameNeedInterpolated(projectName)) {
const nextIndex = await getNextProjectIndex(defaultProjectName, projects)
projectName = interpolateProjectNameWithIndex(
defaultProjectName,
nextIndex
)
}
await createNewProject(defaultDir.dir + '/' + projectName).catch((err) => {
console.error('Error creating project:', err)
toast.error('Error creating project')
})
await refreshProjects()
toast.success('Project created')
}
send({ type: 'assign', data: { defaultProjectName, defaultDirectory } })
}, [defaultDirectory, defaultProjectName, send])
async function handleRenameProject(
e: FormEvent<HTMLFormElement>,
@ -96,85 +142,14 @@ const Home = () => {
const { newProjectName } = Object.fromEntries(
new FormData(e.target as HTMLFormElement)
)
if (newProjectName && project.name && newProjectName !== project.name) {
const dir = project.path?.slice(0, project.path?.lastIndexOf('/'))
await renameFile(project.path, dir + '/' + newProjectName).catch(
(err) => {
console.error('Error renaming project:', err)
toast.error('Error renaming project')
}
)
await refreshProjects()
toast.success('Project renamed')
}
send('Rename project', {
data: { oldName: project.name, newName: newProjectName },
})
}
async function handleDeleteProject(project: ProjectWithEntryPointMetadata) {
if (project.path) {
await removeDir(project.path, { recursive: true }).catch((err) => {
console.error('Error deleting project:', err)
toast.error('Error deleting project')
})
await refreshProjects()
toast.success('Project deleted')
}
}
function getSortIcon(sortBy: string) {
if (sort === sortBy) {
return faArrowUp
} else if (sort === sortBy + DESC) {
return faArrowDown
}
return faCircleDot
}
function getNextSearchParams(sortBy: string) {
if (sort === null || !sort)
return { sort_by: sortBy + (sortBy !== 'modified' ? DESC : '') }
if (sort.includes(sortBy) && !sort.includes(DESC)) return { sort_by: '' }
return {
sort_by: sortBy + (sort.includes(DESC) ? '' : DESC),
}
}
function getSortFunction(sortBy: string) {
const sortByName = (
a: ProjectWithEntryPointMetadata,
b: ProjectWithEntryPointMetadata
) => {
if (a.name && b.name) {
return sortBy.includes('desc')
? a.name.localeCompare(b.name)
: b.name.localeCompare(a.name)
}
return 0
}
const sortByModified = (
a: ProjectWithEntryPointMetadata,
b: ProjectWithEntryPointMetadata
) => {
if (
a.entrypoint_metadata?.modifiedAt &&
b.entrypoint_metadata?.modifiedAt
) {
return !sortBy || sortBy.includes('desc')
? b.entrypoint_metadata.modifiedAt.getTime() -
a.entrypoint_metadata.modifiedAt.getTime()
: a.entrypoint_metadata.modifiedAt.getTime() -
b.entrypoint_metadata.modifiedAt.getTime()
}
return 0
}
if (sortBy?.includes('name')) {
return sortByName
} else {
return sortByModified
}
send('Delete project', { data: { name: project.name || '' } })
}
return (
@ -191,9 +166,9 @@ const Home = () => {
? 'text-chalkboard-80 dark:text-chalkboard-40'
: ''
}
onClick={() => setSearchParams(getNextSearchParams('name'))}
onClick={() => setSearchParams(getNextSearchParams(sort, 'name'))}
icon={{
icon: getSortIcon('name'),
icon: getSortIcon(sort, 'name'),
bgClassName: !sort?.includes('name')
? 'bg-liquid-50 dark:bg-liquid-70'
: '',
@ -207,17 +182,19 @@ const Home = () => {
<ActionButton
Element="button"
className={
!modifiedSelected
!isSortByModified
? 'text-chalkboard-80 dark:text-chalkboard-40'
: ''
}
onClick={() => setSearchParams(getNextSearchParams('modified'))}
onClick={() =>
setSearchParams(getNextSearchParams(sort, 'modified'))
}
icon={{
icon: sort ? getSortIcon('modified') : faArrowDown,
bgClassName: !modifiedSelected
icon: sort ? getSortIcon(sort, 'modified') : faArrowDown,
bgClassName: !isSortByModified
? 'bg-liquid-50 dark:bg-liquid-70'
: '',
iconClassName: !modifiedSelected
iconClassName: !isSortByModified
? 'text-liquid-80 dark:text-liquid-30'
: '',
}}
@ -230,11 +207,11 @@ const Home = () => {
<p className="my-4 text-sm text-chalkboard-80 dark:text-chalkboard-30">
Are being saved at{' '}
<code className="text-liquid-80 dark:text-liquid-30">
{defaultDir.dir}
{defaultDirectory}
</code>
, which you can change in your <Link to="settings">Settings</Link>.
</p>
{isLoading ? (
{state.matches('Reading projects') ? (
<Loading>Loading your Projects...</Loading>
) : (
<>
@ -256,7 +233,7 @@ const Home = () => {
)}
<ActionButton
Element="button"
onClick={handleNewProject}
onClick={() => send('Create project')}
icon={{ icon: faPlus }}
>
New file

View File

@ -20,9 +20,25 @@ export default function Units() {
>
<h1 className="text-2xl font-bold">Camera</h1>
<p className="mt-6">
Moving the camera is easy. Just click and drag anywhere in the scene
to rotate the camera, or hold down the <kbd>Ctrl</kbd> key and drag to
pan the camera.
Moving the camera is easy! The controls are as you might expect:
</p>
<ul className="list-disc list-outside ms-8 mb-4">
<li>Click and drag anywhere in the scene to rotate the camera</li>
<li>
Hold down the <kbd>Shift</kbd> key while clicking and dragging to
pan the camera
</li>
<li>
Hold down the <kbd>Ctrl</kbd> key while dragging to zoom. You can
also use the scroll wheel to zoom in and out.
</li>
</ul>
<p>
What you're seeing here is just a video, and your interactions are
being sent to our Geometry Engine API, which sends back video frames
in real time. How cool is that? It means that you can use KittyCAD
Modeling App (or whatever you want to build) on any device, even a
cheap laptop with no graphics card!
</p>
<div className="flex justify-between mt-6">
<ActionButton

View File

@ -1,34 +1,21 @@
import { faArrowRight, faXmark } from '@fortawesome/free-solid-svg-icons'
import { baseUnits, useStore } from '../../useStore'
import { BaseUnit, baseUnits } from '../../useStore'
import { ActionButton } from '../../components/ActionButton'
import { SettingsSection } from '../Settings'
import { Toggle } from '../../components/Toggle/Toggle'
import { useState } from 'react'
import { onboardingPaths, useDismiss, useNextClick } from '.'
import { useGlobalStateContext } from 'hooks/useGlobalStateContext'
import { UnitSystem } from 'machines/settingsMachine'
export default function Units() {
const dismiss = useDismiss()
const next = useNextClick(onboardingPaths.CAMERA)
const {
defaultUnitSystem: ogDefaultUnitSystem,
setDefaultUnitSystem: saveDefaultUnitSystem,
defaultBaseUnit: ogDefaultBaseUnit,
setDefaultBaseUnit: saveDefaultBaseUnit,
} = useStore((s) => ({
defaultUnitSystem: s.defaultUnitSystem,
setDefaultUnitSystem: s.setDefaultUnitSystem,
defaultBaseUnit: s.defaultBaseUnit,
setDefaultBaseUnit: s.setDefaultBaseUnit,
}))
const [defaultUnitSystem, setDefaultUnitSystem] =
useState(ogDefaultUnitSystem)
const [defaultBaseUnit, setDefaultBaseUnit] = useState(ogDefaultBaseUnit)
function handleNextClick() {
saveDefaultUnitSystem(defaultUnitSystem)
saveDefaultBaseUnit(defaultBaseUnit)
next()
}
settings: {
send,
context: { unitSystem, baseUnit },
},
} = useGlobalStateContext()
return (
<div className="fixed grid place-content-center inset-0 bg-chalkboard-110/50 z-50">
@ -42,10 +29,16 @@ export default function Units() {
offLabel="Imperial"
onLabel="Metric"
name="settings-units"
checked={defaultUnitSystem === 'metric'}
onChange={(e) =>
setDefaultUnitSystem(e.target.checked ? 'metric' : 'imperial')
}
checked={unitSystem === UnitSystem.Metric}
onChange={(e) => {
const newUnitSystem = e.target.checked
? UnitSystem.Metric
: UnitSystem.Imperial
send({
type: 'Set Unit System',
data: { unitSystem: newUnitSystem },
})
}}
/>
</SettingsSection>
<SettingsSection
@ -55,10 +48,15 @@ export default function Units() {
<select
id="base-unit"
className="block w-full px-3 py-1 border border-chalkboard-30 bg-transparent"
value={defaultBaseUnit}
onChange={(e) => setDefaultBaseUnit(e.target.value)}
value={baseUnit}
onChange={(e) => {
send({
type: 'Set Base Unit',
data: { baseUnit: e.target.value as BaseUnit },
})
}}
>
{baseUnits[defaultUnitSystem].map((unit) => (
{baseUnits[unitSystem].map((unit) => (
<option key={unit} value={unit}>
{unit}
</option>
@ -81,7 +79,7 @@ export default function Units() {
</ActionButton>
<ActionButton
Element="button"
onClick={handleNextClick}
onClick={next}
icon={{ icon: faArrowRight }}
>
Next: Camera

View File

@ -1,13 +1,12 @@
import { useHotkeys } from 'react-hotkeys-hook'
import { Outlet, useNavigate } from 'react-router-dom'
import { useStore } from '../../useStore'
import Introduction from './Introduction'
import Units from './Units'
import Camera from './Camera'
import Sketching from './Sketching'
import { useCallback } from 'react'
import makeUrlPathRelative from '../../lib/makeUrlPathRelative'
import { useGlobalStateContext } from 'hooks/useGlobalStateContext'
export const onboardingPaths = {
INDEX: '/',
@ -36,29 +35,35 @@ export const onboardingRoutes = [
]
export function useNextClick(newStatus: string) {
const { setOnboardingStatus } = useStore((s) => ({
setOnboardingStatus: s.setOnboardingStatus,
}))
const {
settings: { send },
} = useGlobalStateContext()
const navigate = useNavigate()
return useCallback(() => {
setOnboardingStatus(newStatus)
send({
type: 'Set Onboarding Status',
data: { onboardingStatus: newStatus },
})
navigate((newStatus !== onboardingPaths.UNITS ? '..' : '.') + newStatus)
}, [newStatus, setOnboardingStatus, navigate])
}, [newStatus, send, navigate])
}
export function useDismiss() {
const { setOnboardingStatus } = useStore((s) => ({
setOnboardingStatus: s.setOnboardingStatus,
}))
const {
settings: { send },
} = useGlobalStateContext()
const navigate = useNavigate()
return useCallback(
(path: string) => {
setOnboardingStatus('dismissed')
send({
type: 'Set Onboarding Status',
data: { onboardingStatus: 'dismissed' },
})
navigate(path)
},
[setOnboardingStatus, navigate]
[send, navigate]
)
}

View File

@ -6,59 +6,48 @@ import {
import { ActionButton } from '../components/ActionButton'
import { AppHeader } from '../components/AppHeader'
import { open } from '@tauri-apps/api/dialog'
import { Themes, baseUnits, useStore } from '../useStore'
import { useRef } from 'react'
import { toast } from 'react-hot-toast'
import { BaseUnit, baseUnits } from '../useStore'
import { Toggle } from '../components/Toggle/Toggle'
import { useNavigate, useRouteLoaderData } from 'react-router-dom'
import { useLocation, useNavigate, useRouteLoaderData } from 'react-router-dom'
import { useHotkeys } from 'react-hotkeys-hook'
import { IndexLoaderData, paths } from '../Router'
import { Themes } from '../lib/theme'
import { useGlobalStateContext } from 'hooks/useGlobalStateContext'
import { UnitSystem } from 'machines/settingsMachine'
export const Settings = () => {
const loaderData = useRouteLoaderData(paths.FILE) as IndexLoaderData
const navigate = useNavigate()
const location = useLocation()
useHotkeys('esc', () => navigate('../'))
const {
defaultDir,
setDefaultDir,
defaultProjectName,
setDefaultProjectName,
defaultUnitSystem,
setDefaultUnitSystem,
defaultBaseUnit,
setDefaultBaseUnit,
setDebugPanel,
debugPanel,
setOnboardingStatus,
theme,
setTheme,
} = useStore((s) => ({
defaultDir: s.defaultDir,
setDefaultDir: s.setDefaultDir,
defaultProjectName: s.defaultProjectName,
setDefaultProjectName: s.setDefaultProjectName,
defaultUnitSystem: s.defaultUnitSystem,
setDefaultUnitSystem: s.setDefaultUnitSystem,
defaultBaseUnit: s.defaultBaseUnit,
setDefaultBaseUnit: s.setDefaultBaseUnit,
setDebugPanel: s.setDebugPanel,
debugPanel: s.debugPanel,
setOnboardingStatus: s.setOnboardingStatus,
theme: s.theme,
setTheme: s.setTheme,
}))
const ogDefaultDir = useRef(defaultDir)
const ogDefaultProjectName = useRef(defaultProjectName)
settings: {
send,
state: {
context: {
defaultProjectName,
showDebugPanel,
defaultDirectory,
unitSystem,
baseUnit,
theme,
},
},
},
} = useGlobalStateContext()
async function handleDirectorySelection() {
const newDirectory = await open({
directory: true,
defaultPath: (defaultDir.base || '') + (defaultDir.dir || paths.INDEX),
defaultPath: defaultDirectory || paths.INDEX,
title: 'Choose a new default directory',
})
if (newDirectory && newDirectory !== null && !Array.isArray(newDirectory)) {
setDefaultDir({ base: defaultDir.base, dir: newDirectory })
send({
type: 'Set Default Directory',
data: { defaultDirectory: newDirectory },
})
}
}
@ -102,18 +91,8 @@ export const Settings = () => {
<div className="w-full flex gap-4 p-1 rounded border border-chalkboard-30">
<input
className="flex-1 px-2 bg-transparent"
value={defaultDir.dir}
onChange={(e) => {
setDefaultDir({
base: defaultDir.base,
dir: e.target.value,
})
}}
onBlur={() => {
ogDefaultDir.current.dir !== defaultDir.dir &&
toast.success('Default directory updated')
ogDefaultDir.current.dir = defaultDir.dir
}}
value={defaultDirectory}
disabled
/>
<ActionButton
Element="button"
@ -137,15 +116,15 @@ export const Settings = () => {
>
<input
className="block w-full px-3 py-1 border border-chalkboard-30 bg-transparent"
value={defaultProjectName}
onChange={(e) => {
setDefaultProjectName(e.target.value)
}}
onBlur={() => {
ogDefaultProjectName.current !== defaultProjectName &&
toast.success('Default project name updated')
ogDefaultProjectName.current = defaultProjectName
defaultValue={defaultProjectName}
onBlur={(e) => {
send({
type: 'Set Default Project Name',
data: { defaultProjectName: e.target.value },
})
}}
autoCapitalize="off"
autoComplete="off"
/>
</SettingsSection>
</>
@ -158,12 +137,15 @@ export const Settings = () => {
offLabel="Imperial"
onLabel="Metric"
name="settings-units"
checked={defaultUnitSystem === 'metric'}
checked={unitSystem === UnitSystem.Metric}
onChange={(e) => {
const newUnitSystem = e.target.checked ? 'metric' : 'imperial'
setDefaultUnitSystem(newUnitSystem)
setDefaultBaseUnit(baseUnits[newUnitSystem][0])
toast.success('Unit system set to ' + newUnitSystem)
const newUnitSystem = e.target.checked
? UnitSystem.Metric
: UnitSystem.Imperial
send({
type: 'Set Unit System',
data: { unitSystem: newUnitSystem },
})
}}
/>
</SettingsSection>
@ -174,13 +156,15 @@ export const Settings = () => {
<select
id="base-unit"
className="block w-full px-3 py-1 border border-chalkboard-30 bg-transparent"
value={defaultBaseUnit}
value={baseUnit}
onChange={(e) => {
setDefaultBaseUnit(e.target.value)
toast.success('Base unit changed to ' + e.target.value)
send({
type: 'Set Base Unit',
data: { baseUnit: e.target.value as BaseUnit },
})
}}
>
{baseUnits[defaultUnitSystem].map((unit) => (
{baseUnits[unitSystem as keyof typeof baseUnits].map((unit) => (
<option key={unit} value={unit}>
{unit}
</option>
@ -193,12 +177,9 @@ export const Settings = () => {
>
<Toggle
name="settings-debug-panel"
checked={debugPanel}
checked={showDebugPanel}
onChange={(e) => {
setDebugPanel(e.target.checked)
toast.success(
'Debug panel toggled ' + (e.target.checked ? 'on' : 'off')
)
send('Toggle Debug Panel')
}}
/>
</SettingsSection>
@ -211,12 +192,10 @@ export const Settings = () => {
className="block w-full px-3 py-1 border border-chalkboard-30 bg-transparent"
value={theme}
onChange={(e) => {
setTheme(e.target.value as Themes)
toast.success(
'Theme changed to ' +
e.target.value.slice(0, 1).toLocaleUpperCase() +
e.target.value.slice(1)
)
send({
type: 'Set Theme',
data: { theme: e.target.value as Themes },
})
}}
>
{Object.entries(Themes).map(([label, value]) => (
@ -226,21 +205,26 @@ export const Settings = () => {
))}
</select>
</SettingsSection>
<SettingsSection
title="Onboarding"
description="Replay the onboarding process"
>
<ActionButton
Element="button"
onClick={() => {
setOnboardingStatus('')
navigate('..' + paths.ONBOARDING.INDEX)
}}
icon={{ icon: faArrowRotateBack }}
{location.pathname.includes(paths.FILE) && (
<SettingsSection
title="Onboarding"
description="Replay the onboarding process"
>
Replay Onboarding
</ActionButton>
</SettingsSection>
<ActionButton
Element="button"
onClick={() => {
send({
type: 'Set Onboarding Status',
data: { onboardingStatus: '' },
})
navigate('..' + paths.ONBOARDING.INDEX)
}}
icon={{ icon: faArrowRotateBack }}
>
Replay Onboarding
</ActionButton>
</SettingsSection>
)}
</div>
</div>
)

View File

@ -1,20 +1,22 @@
import { faSignInAlt } from '@fortawesome/free-solid-svg-icons'
import { ActionButton } from '../components/ActionButton'
import { isTauri } from '../lib/isTauri'
import { Themes, useStore } from '../useStore'
import { invoke } from '@tauri-apps/api/tauri'
import { useNavigate } from 'react-router-dom'
import { VITE_KC_SITE_BASE_URL, VITE_KC_API_BASE_URL } from '../env'
import { getSystemTheme } from '../lib/getSystemTheme'
import { Themes, getSystemTheme } from '../lib/theme'
import { paths } from '../Router'
import { useAuthMachine } from '../hooks/useAuthMachine'
import { useGlobalStateContext } from 'hooks/useGlobalStateContext'
const SignIn = () => {
const navigate = useNavigate()
const { theme } = useStore((s) => ({
theme: s.theme,
}))
const [_, send] = useAuthMachine()
const {
auth: { send },
settings: {
state: {
context: { theme },
},
},
} = useGlobalStateContext()
const appliedTheme = theme === Themes.System ? getSystemTheme() : theme
const signInTauri = async () => {
// We want to invoke our command to login via device auth.
@ -22,7 +24,7 @@ const SignIn = () => {
const token: string = await invoke('login', {
host: VITE_KC_API_BASE_URL,
})
send({ type: 'tryLogin', token })
send({ type: 'Log in', token })
} catch (error) {
console.error('login button', error)
}

View File

@ -13,7 +13,6 @@ import {
} from './lang/executor'
import { recast } from './lang/recast'
import { EditorSelection } from '@codemirror/state'
import { BaseDirectory } from '@tauri-apps/api/fs'
import {
ArtifactMap,
SourceRangeMap,
@ -95,22 +94,14 @@ export type GuiModes =
position: Position
}
type UnitSystem = 'imperial' | 'metric'
export enum Themes {
Light = 'light',
Dark = 'dark',
System = 'system',
}
export const baseUnits: Record<UnitSystem, string[]> = {
export const baseUnits = {
imperial: ['in', 'ft'],
metric: ['mm', 'cm', 'm'],
}
} as const
interface DefaultDir {
base?: BaseDirectory
dir: string
}
export type BaseUnit = 'in' | 'ft' | 'mm' | 'cm' | 'm'
export const baseUnitsUnion = Object.values(baseUnits).flatMap((v) => v)
export type PaneType = 'code' | 'variables' | 'debug' | 'kclErrors' | 'logs'
@ -181,21 +172,8 @@ export interface StoreState {
streamHeight: number
}) => void
// tauri specific app settings
defaultDir: DefaultDir
setDefaultDir: (dir: DefaultDir) => void
defaultProjectName: string
setDefaultProjectName: (defaultProjectName: string) => void
defaultUnitSystem: UnitSystem
setDefaultUnitSystem: (defaultUnitSystem: UnitSystem) => void
defaultBaseUnit: string
setDefaultBaseUnit: (defaultBaseUnit: string) => void
showHomeMenu: boolean
setHomeShowMenu: (showMenu: boolean) => void
onboardingStatus: string
setOnboardingStatus: (status: string) => void
theme: Themes
setTheme: (theme: Themes) => void
isBannerDismissed: boolean
setBannerDismissed: (isBannerDismissed: boolean) => void
openPanes: PaneType[]
@ -205,8 +183,6 @@ export interface StoreState {
path: string
}[]
setHomeMenuItems: (items: { name: string; path: string }[]) => void
debugPanel: boolean
setDebugPanel: (debugPanel: boolean) => void
}
let pendingAstUpdates: number[] = []
@ -385,18 +361,6 @@ export const useStore = create<StoreState>()(
defaultDir: {
dir: '',
},
setDefaultDir: (dir) => set({ defaultDir: dir }),
defaultProjectName: 'new-project-$nnn',
setDefaultProjectName: (defaultProjectName) =>
set({ defaultProjectName }),
defaultUnitSystem: 'imperial',
setDefaultUnitSystem: (defaultUnitSystem) => set({ defaultUnitSystem }),
defaultBaseUnit: 'in',
setDefaultBaseUnit: (defaultBaseUnit) => set({ defaultBaseUnit }),
onboardingStatus: '',
setOnboardingStatus: (onboardingStatus) => set({ onboardingStatus }),
theme: Themes.System,
setTheme: (theme) => set({ theme }),
isBannerDismissed: false,
setBannerDismissed: (isBannerDismissed) => set({ isBannerDismissed }),
openPanes: ['code'],
@ -405,25 +369,13 @@ export const useStore = create<StoreState>()(
setHomeShowMenu: (showHomeMenu) => set({ showHomeMenu }),
homeMenuItems: [],
setHomeMenuItems: (homeMenuItems) => set({ homeMenuItems }),
debugPanel: false,
setDebugPanel: (debugPanel) => set({ debugPanel }),
}),
{
name: 'store',
partialize: (state) =>
Object.fromEntries(
Object.entries(state).filter(([key]) =>
[
'code',
'defaultDir',
'defaultProjectName',
'defaultUnitSystem',
'defaultBaseUnit',
'debugPanel',
'onboardingStatus',
'theme',
'openPanes',
].includes(key)
['code', 'openPanes'].includes(key)
)
),
}

1553
src/wasm-lib/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -8,23 +8,20 @@ edition = "2021"
crate-type = ["cdylib"]
[dependencies]
anyhow = "1.0.75"
backtrace = "0.3"
bincode = "1.3.3"
bson = { version = "2.7.0", features = ["uuid-1", "chrono"] }
gloo-utils = "0.2.0"
kittycad = { version = "0.2.15", default-features = false, features = ["js"] }
lazy_static = "1.4.0"
regex = "1.7.1"
serde = {version = "1.0.152", features = ["derive"] }
serde-wasm-bindgen = "0.3.0"
kcl-lib = { path = "kcl" }
kittycad = { version = "0.2.23", default-features = false, features = ["js"] }
serde_json = "1.0.93"
thiserror = "1.0.47"
ts-rs = { git = "https://github.com/kittycad/ts-rs.git", branch = "serde_json", features = ["serde-json-impl", "uuid-impl"] }
wasm-bindgen = "0.2.87"
wasm-bindgen-futures = "0.4.37"
[profile.release]
panic = "abort"
debug = true
[dev-dependencies]
pretty_assertions = "1.4.0"
[workspace]
members = [
"derive-docs",
"kcl"
]

View File

@ -0,0 +1,24 @@
[package]
name = "derive-docs"
description = "A tool for generating documentation from Rust derive macros"
version = "0.1.0"
edition = "2021"
license = "MIT"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[lib]
proc-macro = true
[dependencies]
convert_case = "0.6.0"
proc-macro2 = "1"
quote = "1"
serde = { version = "1.0.186", features = ["derive"] }
serde_tokenstream = "0.2"
syn = { version = "2.0.29", features = ["full"] }
[dev-dependencies]
expectorate = "1.0.7"
openapitor = "0.0.5"
pretty_assertions = "1.4.0"

View File

@ -0,0 +1,532 @@
// Copyright 2023 Oxide Computer Company
//! This package defines macro attributes associated with HTTP handlers. These
//! attributes are used both to define an HTTP API and to generate an OpenAPI
//! Spec (OAS) v3 document that describes the API.
// Clippy's style advice is definitely valuable, but not worth the trouble for
// automated enforcement.
#![allow(clippy::style)]
use convert_case::Casing;
use quote::{format_ident, quote, quote_spanned, ToTokens};
use serde::Deserialize;
use serde_tokenstream::{from_tokenstream, Error};
use syn::{
parse::{Parse, ParseStream},
Attribute, Signature, Visibility,
};
#[derive(Deserialize, Debug)]
struct StdlibMetadata {
/// The name of the function in the API.
name: String,
/// Tags for the function.
#[serde(default)]
tags: Vec<String>,
/// Whether the function is unpublished.
/// Then docs will not be generated.
#[serde(default)]
unpublished: bool,
/// Whether the function is deprecated.
/// Then specific docs detailing that this is deprecated will be generated.
#[serde(default)]
deprecated: bool,
}
#[proc_macro_attribute]
pub fn stdlib(attr: proc_macro::TokenStream, item: proc_macro::TokenStream) -> proc_macro::TokenStream {
do_output(do_stdlib(attr.into(), item.into()))
}
fn do_stdlib(
attr: proc_macro2::TokenStream,
item: proc_macro2::TokenStream,
) -> Result<(proc_macro2::TokenStream, Vec<Error>), Error> {
let metadata = from_tokenstream(&attr)?;
do_stdlib_inner(metadata, attr, item)
}
fn do_output(res: Result<(proc_macro2::TokenStream, Vec<Error>), Error>) -> proc_macro::TokenStream {
match res {
Err(err) => err.to_compile_error().into(),
Ok((stdlib_docs, errors)) => {
let compiler_errors = errors.iter().map(|err| err.to_compile_error());
let output = quote! {
#stdlib_docs
#( #compiler_errors )*
};
output.into()
}
}
}
fn do_stdlib_inner(
metadata: StdlibMetadata,
_attr: proc_macro2::TokenStream,
item: proc_macro2::TokenStream,
) -> Result<(proc_macro2::TokenStream, Vec<Error>), Error> {
let ast: ItemFnForSignature = syn::parse2(item.clone())?;
let mut errors = Vec::new();
if ast.sig.constness.is_some() {
errors.push(Error::new_spanned(
&ast.sig.constness,
"stdlib functions may not be const functions",
));
}
if ast.sig.asyncness.is_some() {
errors.push(Error::new_spanned(
&ast.sig.fn_token,
"stdlib functions must not be async",
));
}
if ast.sig.unsafety.is_some() {
errors.push(Error::new_spanned(
&ast.sig.unsafety,
"stdlib functions may not be unsafe",
));
}
if ast.sig.abi.is_some() {
errors.push(Error::new_spanned(
&ast.sig.abi,
"stdlib functions may not use an alternate ABI",
));
}
if !ast.sig.generics.params.is_empty() {
errors.push(Error::new_spanned(
&ast.sig.generics,
"generics are not permitted for stdlib functions",
));
}
if ast.sig.variadic.is_some() {
errors.push(Error::new_spanned(&ast.sig.variadic, "no language C here"));
}
let name = metadata.name;
let name_ident = format_ident!("{}", name.to_case(convert_case::Case::UpperCamel));
let name_str = name.to_string();
let fn_name = &ast.sig.ident;
let fn_name_str = fn_name.to_string().replace("inner_", "");
let fn_name_ident = format_ident!("{}", fn_name_str);
let _visibility = &ast.vis;
let (summary_text, description_text) = extract_doc_from_attrs(&ast.attrs);
let comment_text = {
let mut buf = String::new();
buf.push_str("Std lib function: ");
buf.push_str(&name_str);
if let Some(s) = &summary_text {
buf.push_str("\n");
buf.push_str(&s);
}
if let Some(s) = &description_text {
buf.push_str("\n");
buf.push_str(&s);
}
buf
};
let description_doc_comment = quote! {
#[doc = #comment_text]
};
let summary = if let Some(summary) = summary_text {
quote! { #summary }
} else {
quote! { "" }
};
let description = if let Some(description) = description_text {
quote! { #description }
} else {
quote! { "" }
};
let tags = metadata
.tags
.iter()
.map(|tag| {
quote! { #tag.to_string() }
})
.collect::<Vec<_>>();
let deprecated = if metadata.deprecated {
quote! { true }
} else {
quote! { false }
};
let unpublished = if metadata.unpublished {
quote! { true }
} else {
quote! { false }
};
let docs_crate = get_crate(None);
// When the user attaches this proc macro to a function with the wrong type
// signature, the resulting errors can be deeply inscrutable. To attempt to
// make failures easier to understand, we inject code that asserts the types
// of the various parameters. We do this by calling dummy functions that
// require a type that satisfies SharedExtractor or ExclusiveExtractor.
let mut arg_types = Vec::new();
for arg in ast.sig.inputs.iter() {
// Get the name of the argument.
let arg_name = match arg {
syn::FnArg::Receiver(pat) => {
let span = pat.self_token.span.unwrap();
span.source_text().unwrap().to_string()
}
syn::FnArg::Typed(pat) => match &*pat.pat {
syn::Pat::Ident(ident) => ident.ident.to_string(),
_ => {
errors.push(Error::new_spanned(
&pat.pat,
"stdlib functions may not use destructuring patterns",
));
continue;
}
},
};
let ty = match arg {
syn::FnArg::Receiver(pat) => pat.ty.as_ref().into_token_stream(),
syn::FnArg::Typed(pat) => pat.ty.as_ref().into_token_stream(),
};
let ty_string = ty.to_string().replace('&', "").replace("mut", "").replace(' ', "");
let ty_string = ty_string.trim().to_string();
let ty_ident = if ty_string.starts_with("Vec<") {
let ty_string = ty_string.trim_start_matches("Vec<").trim_end_matches('>');
let ty_ident = format_ident!("{}", ty_string);
quote! {
Vec<#ty_ident>
}
} else {
let ty_ident = format_ident!("{}", ty_string);
quote! {
#ty_ident
}
};
let ty_string = clean_type(&ty_string);
if ty_string != "Args" {
let schema = if ty_ident.to_string().starts_with("Vec < ") {
quote! {
<#ty_ident>::json_schema(&mut generator)
}
} else {
quote! {
#ty_ident::json_schema(&mut generator)
}
};
arg_types.push(quote! {
#docs_crate::StdLibFnArg {
name: #arg_name.to_string(),
type_: #ty_string.to_string(),
schema: #schema,
required: true,
}
});
}
}
let ret_ty = ast.sig.output.clone();
let ret_ty_string = ret_ty
.into_token_stream()
.to_string()
.replace("-> ", "")
.replace("Result < ", "")
.replace(", KclError >", "");
let ret_ty_string = ret_ty_string.trim().to_string();
let ret_ty_ident = format_ident!("{}", ret_ty_string);
let ret_ty_string = clean_type(&ret_ty_string);
let return_type = quote! {
#docs_crate::StdLibFnArg {
name: "".to_string(),
type_: #ret_ty_string.to_string(),
schema: #ret_ty_ident::json_schema(&mut generator),
required: true,
}
};
// For reasons that are not well understood unused constants that use the
// (default) call_site() Span do not trigger the dead_code lint. Because
// defining but not using an endpoint is likely a programming error, we
// want to be sure to have the compiler flag this. We force this by using
// the span from the name of the function to which this macro was applied.
let span = ast.sig.ident.span();
let const_struct = quote_spanned! {span=>
pub(crate) const #name_ident: #name_ident = #name_ident {};
};
// The final TokenStream returned will have a few components that reference
// `#name_ident`, the name of the function to which this macro was applied...
let stream = quote! {
// ... a struct type called `#name_ident` that has no members
#[allow(non_camel_case_types, missing_docs)]
#description_doc_comment
pub(crate) struct #name_ident {}
// ... a constant of type `#name` whose identifier is also #name_ident
#[allow(non_upper_case_globals, missing_docs)]
#description_doc_comment
#const_struct
impl #docs_crate::StdLibFn for #name_ident
{
fn name(&self) -> String {
#name_str.to_string()
}
fn summary(&self) -> String {
#summary.to_string()
}
fn description(&self) -> String {
#description.to_string()
}
fn tags(&self) -> Vec<String> {
vec![#(#tags),*]
}
fn args(&self) -> Vec<#docs_crate::StdLibFnArg> {
let mut settings = schemars::gen::SchemaSettings::openapi3();
settings.inline_subschemas = true;
let mut generator = schemars::gen::SchemaGenerator::new(settings);
vec![#(#arg_types),*]
}
fn return_value(&self) -> #docs_crate::StdLibFnArg {
let mut settings = schemars::gen::SchemaSettings::openapi3();
settings.inline_subschemas = true;
let mut generator = schemars::gen::SchemaGenerator::new(settings);
#return_type
}
fn unpublished(&self) -> bool {
#unpublished
}
fn deprecated(&self) -> bool {
#deprecated
}
fn std_lib_fn(&self) -> crate::std::StdFn {
#fn_name_ident
}
}
#item
};
// Prepend the usage message if any errors were detected.
if !errors.is_empty() {
errors.insert(0, Error::new_spanned(&ast.sig, ""));
}
Ok((stream, errors))
}
#[allow(dead_code)]
fn to_compile_errors(errors: Vec<syn::Error>) -> proc_macro2::TokenStream {
let compile_errors = errors.iter().map(syn::Error::to_compile_error);
quote!(#(#compile_errors)*)
}
fn get_crate(var: Option<String>) -> proc_macro2::TokenStream {
if let Some(s) = var {
if let Ok(ts) = syn::parse_str(s.as_str()) {
return ts;
}
}
quote!(crate::docs)
}
fn extract_doc_from_attrs(attrs: &[syn::Attribute]) -> (Option<String>, Option<String>) {
let doc = syn::Ident::new("doc", proc_macro2::Span::call_site());
let mut lines = attrs.iter().flat_map(|attr| {
if let syn::Meta::NameValue(nv) = &attr.meta {
if nv.path.is_ident(&doc) {
if let syn::Expr::Lit(syn::ExprLit {
lit: syn::Lit::Str(s), ..
}) = &nv.value
{
return normalize_comment_string(s.value());
}
}
}
Vec::new()
});
// Skip initial blank lines; they make for excessively terse summaries.
let summary = loop {
match lines.next() {
Some(s) if s.is_empty() => (),
next => break next,
}
};
// Skip initial blank description lines.
let first = loop {
match lines.next() {
Some(s) if s.is_empty() => (),
next => break next,
}
};
match (summary, first) {
(None, _) => (None, None),
(summary, None) => (summary, None),
(Some(summary), Some(first)) => (
Some(summary),
Some(
lines
.fold(first, |acc, comment| {
if acc.ends_with('-') || acc.ends_with('\n') || acc.is_empty() {
// Continuation lines and newlines.
format!("{}{}", acc, comment)
} else if comment.is_empty() {
// Handle fully blank comments as newlines we keep.
format!("{}\n", acc)
} else {
// Default to space-separating comment fragments.
format!("{} {}", acc, comment)
}
})
.trim_end()
.to_string(),
),
),
}
}
fn normalize_comment_string(s: String) -> Vec<String> {
s.split('\n')
.enumerate()
.map(|(idx, s)| {
// Rust-style comments are intrinsically single-line. We don't want
// to trim away formatting such as an initial '*'.
if idx == 0 {
s.trim_start().trim_end()
} else {
let trimmed = s.trim_start().trim_end();
trimmed
.strip_prefix("* ")
.unwrap_or_else(|| trimmed.strip_prefix('*').unwrap_or(trimmed))
}
})
.map(ToString::to_string)
.collect()
}
/// Represent an item without concern for its body which may (or may not)
/// contain syntax errors.
struct ItemFnForSignature {
pub attrs: Vec<Attribute>,
pub vis: Visibility,
pub sig: Signature,
pub _block: proc_macro2::TokenStream,
}
impl Parse for ItemFnForSignature {
fn parse(input: ParseStream) -> syn::parse::Result<Self> {
let attrs = input.call(Attribute::parse_outer)?;
let vis: Visibility = input.parse()?;
let sig: Signature = input.parse()?;
let block = input.parse()?;
Ok(ItemFnForSignature {
attrs,
vis,
sig,
_block: block,
})
}
}
fn clean_type(t: &str) -> String {
let mut t = t.to_string();
// Turn vecs into arrays.
if t.starts_with("Vec<") {
t = t.replace("Vec<", "[").replace('>', "]");
}
if t == "f64" {
return "number".to_string();
} else if t == "str" {
return "string".to_string();
} else {
return t.replace("f64", "number").to_string();
}
}
#[cfg(test)]
mod tests {
use quote::quote;
use super::*;
#[test]
fn test_stdlib_line_to() {
let (item, errors) = do_stdlib(
quote! {
name = "lineTo",
},
quote! {
fn inner_line_to(
data: LineToData,
sketch_group: SketchGroup,
args: &Args,
) -> Result<SketchGroup, KclError> {
Ok(())
}
},
)
.unwrap();
let _expected = quote! {};
assert!(errors.is_empty());
expectorate::assert_contents("tests/lineTo.gen", &openapitor::types::get_text_fmt(&item).unwrap());
}
#[test]
fn test_stdlib_min() {
let (item, errors) = do_stdlib(
quote! {
name = "min",
},
quote! {
fn inner_min(
/// The args to do shit to.
args: Vec<f64>
) -> f64 {
let mut min = std::f64::MAX;
for arg in args.iter() {
if *arg < min {
min = *arg;
}
}
min
}
},
)
.unwrap();
let _expected = quote! {};
assert!(errors.is_empty());
expectorate::assert_contents("tests/min.gen", &openapitor::types::get_text_fmt(&item).unwrap());
}
}

View File

@ -0,0 +1,76 @@
#[allow(non_camel_case_types, missing_docs)]
#[doc = "Std lib function: lineTo"]
pub(crate) struct LineTo {}
#[allow(non_upper_case_globals, missing_docs)]
#[doc = "Std lib function: lineTo"]
pub(crate) const LineTo: LineTo = LineTo {};
impl crate::docs::StdLibFn for LineTo {
fn name(&self) -> String {
"lineTo".to_string()
}
fn summary(&self) -> String {
"".to_string()
}
fn description(&self) -> String {
"".to_string()
}
fn tags(&self) -> Vec<String> {
vec![]
}
fn args(&self) -> Vec<crate::docs::StdLibFnArg> {
let mut settings = schemars::gen::SchemaSettings::openapi3();
settings.inline_subschemas = true;
let mut generator = schemars::gen::SchemaGenerator::new(settings);
vec![
crate::docs::StdLibFnArg {
name: "data".to_string(),
type_: "LineToData".to_string(),
schema: LineToData::json_schema(&mut generator),
required: true,
},
crate::docs::StdLibFnArg {
name: "sketch_group".to_string(),
type_: "SketchGroup".to_string(),
schema: SketchGroup::json_schema(&mut generator),
required: true,
},
]
}
fn return_value(&self) -> crate::docs::StdLibFnArg {
let mut settings = schemars::gen::SchemaSettings::openapi3();
settings.inline_subschemas = true;
let mut generator = schemars::gen::SchemaGenerator::new(settings);
crate::docs::StdLibFnArg {
name: "".to_string(),
type_: "SketchGroup".to_string(),
schema: SketchGroup::json_schema(&mut generator),
required: true,
}
}
fn unpublished(&self) -> bool {
false
}
fn deprecated(&self) -> bool {
false
}
fn std_lib_fn(&self) -> crate::std::StdFn {
line_to
}
}
fn inner_line_to(
data: LineToData,
sketch_group: SketchGroup,
args: &Args,
) -> Result<SketchGroup, KclError> {
Ok(())
}

View File

@ -0,0 +1,71 @@
#[allow(non_camel_case_types, missing_docs)]
#[doc = "Std lib function: min"]
pub(crate) struct Min {}
#[allow(non_upper_case_globals, missing_docs)]
#[doc = "Std lib function: min"]
pub(crate) const Min: Min = Min {};
impl crate::docs::StdLibFn for Min {
fn name(&self) -> String {
"min".to_string()
}
fn summary(&self) -> String {
"".to_string()
}
fn description(&self) -> String {
"".to_string()
}
fn tags(&self) -> Vec<String> {
vec![]
}
fn args(&self) -> Vec<crate::docs::StdLibFnArg> {
let mut settings = schemars::gen::SchemaSettings::openapi3();
settings.inline_subschemas = true;
let mut generator = schemars::gen::SchemaGenerator::new(settings);
vec![crate::docs::StdLibFnArg {
name: "args".to_string(),
type_: "[number]".to_string(),
schema: <Vec<f64>>::json_schema(&mut generator),
required: true,
}]
}
fn return_value(&self) -> crate::docs::StdLibFnArg {
let mut settings = schemars::gen::SchemaSettings::openapi3();
settings.inline_subschemas = true;
let mut generator = schemars::gen::SchemaGenerator::new(settings);
crate::docs::StdLibFnArg {
name: "".to_string(),
type_: "number".to_string(),
schema: f64::json_schema(&mut generator),
required: true,
}
}
fn unpublished(&self) -> bool {
false
}
fn deprecated(&self) -> bool {
false
}
fn std_lib_fn(&self) -> crate::std::StdFn {
min
}
}
fn inner_min(#[doc = r" The args to do shit to."] args: Vec<f64>) -> f64 {
let mut min = std::f64::MAX;
for arg in args.iter() {
if *arg < min {
min = *arg;
}
}
min
}

View File

@ -0,0 +1,47 @@
[package]
name = "kcl-lib"
description = "KittyCAD Language"
version = "0.1.10"
edition = "2021"
license = "MIT"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
anyhow = "1.0.75"
derive-docs = { version = "0.1.0" }
kittycad = { version = "0.2.23", default-features = false, features = ["js"] }
lazy_static = "1.4.0"
parse-display = "0.8.2"
regex = "1.7.1"
schemars = { version = "0.8", features = ["url", "uuid1"] }
serde = {version = "1.0.152", features = ["derive"] }
serde_json = "1.0.93"
thiserror = "1.0.47"
ts-rs = { version = "7", package = "ts-rs-json-value", features = ["serde-json-impl", "uuid-impl"] }
uuid = { version = "1.4.1", features = ["v4", "js", "serde"] }
wasm-bindgen = "0.2.87"
wasm-bindgen-futures = "0.4.37"
[target.'cfg(target_arch = "wasm32")'.dependencies]
js-sys = { version = "0.3.64" }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
bson = { version = "2.7.0", features = ["uuid-1", "chrono"] }
futures = { version = "0.3.28" }
reqwest = { version = "0.11.20", default-features = false }
tokio = { version = "1.32.0", features = ["full"] }
tokio-tungstenite = { version = "0.20.0", features = ["rustls-tls-native-roots"] }
[features]
default = ["engine"]
engine = []
[profile.release]
panic = "abort"
debug = true
[dev-dependencies]
expectorate = "1.0.7"
pretty_assertions = "1.4.0"
tokio = { version = "1.32.0", features = ["rt-multi-thread", "macros", "time"] }

View File

@ -0,0 +1,964 @@
//! Data types for the AST.
use std::collections::HashMap;
use parse_display::{Display, FromStr};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde_json::Map;
use crate::{
engine::EngineConnection,
errors::{KclError, KclErrorDetails},
executor::{MemoryItem, Metadata, PipeInfo, ProgramMemory, SourceRange},
};
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, ts_rs::TS, JsonSchema)]
#[ts(export)]
#[serde(rename_all = "camelCase")]
pub struct Program {
pub start: usize,
pub end: usize,
pub body: Vec<BodyItem>,
pub non_code_meta: NoneCodeMeta,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, ts_rs::TS, JsonSchema)]
#[ts(export)]
#[serde(tag = "type")]
pub enum BodyItem {
ExpressionStatement(ExpressionStatement),
VariableDeclaration(VariableDeclaration),
ReturnStatement(ReturnStatement),
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, ts_rs::TS, JsonSchema)]
#[ts(export)]
#[serde(tag = "type")]
pub enum Value {
Literal(Box<Literal>),
Identifier(Box<Identifier>),
BinaryExpression(Box<BinaryExpression>),
FunctionExpression(Box<FunctionExpression>),
CallExpression(Box<CallExpression>),
PipeExpression(Box<PipeExpression>),
PipeSubstitution(Box<PipeSubstitution>),
ArrayExpression(Box<ArrayExpression>),
ObjectExpression(Box<ObjectExpression>),
MemberExpression(Box<MemberExpression>),
UnaryExpression(Box<UnaryExpression>),
}
pub trait ValueMeta {
fn start(&self) -> usize;
fn end(&self) -> usize;
}
macro_rules! impl_value_meta {
{$name:ident} => {
impl crate::abstract_syntax_tree_types::ValueMeta for $name {
fn start(&self) -> usize {
self.start
}
fn end(&self) -> usize {
self.end
}
}
impl From<$name> for crate::executor::SourceRange {
fn from(v: $name) -> Self {
Self([v.start, v.end])
}
}
impl From<&$name> for crate::executor::SourceRange {
fn from(v: &$name) -> Self {
Self([v.start, v.end])
}
}
impl From<&Box<$name>> for crate::executor::SourceRange {
fn from(v: &Box<$name>) -> Self {
Self([v.start, v.end])
}
}
};
}
pub(crate) use impl_value_meta;
impl Value {
pub fn start(&self) -> usize {
match self {
Value::Literal(literal) => literal.start(),
Value::Identifier(identifier) => identifier.start(),
Value::BinaryExpression(binary_expression) => binary_expression.start(),
Value::FunctionExpression(function_expression) => function_expression.start(),
Value::CallExpression(call_expression) => call_expression.start(),
Value::PipeExpression(pipe_expression) => pipe_expression.start(),
Value::PipeSubstitution(pipe_substitution) => pipe_substitution.start(),
Value::ArrayExpression(array_expression) => array_expression.start(),
Value::ObjectExpression(object_expression) => object_expression.start(),
Value::MemberExpression(member_expression) => member_expression.start(),
Value::UnaryExpression(unary_expression) => unary_expression.start(),
}
}
pub fn end(&self) -> usize {
match self {
Value::Literal(literal) => literal.end(),
Value::Identifier(identifier) => identifier.end(),
Value::BinaryExpression(binary_expression) => binary_expression.end(),
Value::FunctionExpression(function_expression) => function_expression.end(),
Value::CallExpression(call_expression) => call_expression.end(),
Value::PipeExpression(pipe_expression) => pipe_expression.end(),
Value::PipeSubstitution(pipe_substitution) => pipe_substitution.end(),
Value::ArrayExpression(array_expression) => array_expression.end(),
Value::ObjectExpression(object_expression) => object_expression.end(),
Value::MemberExpression(member_expression) => member_expression.end(),
Value::UnaryExpression(unary_expression) => unary_expression.end(),
}
}
}
impl From<Value> for crate::executor::SourceRange {
fn from(value: Value) -> Self {
Self([value.start(), value.end()])
}
}
impl From<&Value> for crate::executor::SourceRange {
fn from(value: &Value) -> Self {
Self([value.start(), value.end()])
}
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, ts_rs::TS, JsonSchema)]
#[ts(export)]
#[serde(tag = "type")]
pub enum BinaryPart {
Literal(Box<Literal>),
Identifier(Box<Identifier>),
BinaryExpression(Box<BinaryExpression>),
CallExpression(Box<CallExpression>),
UnaryExpression(Box<UnaryExpression>),
}
impl From<BinaryPart> for crate::executor::SourceRange {
fn from(value: BinaryPart) -> Self {
Self([value.start(), value.end()])
}
}
impl From<&BinaryPart> for crate::executor::SourceRange {
fn from(value: &BinaryPart) -> Self {
Self([value.start(), value.end()])
}
}
impl BinaryPart {
pub fn start(&self) -> usize {
match self {
BinaryPart::Literal(literal) => literal.start(),
BinaryPart::Identifier(identifier) => identifier.start(),
BinaryPart::BinaryExpression(binary_expression) => binary_expression.start(),
BinaryPart::CallExpression(call_expression) => call_expression.start(),
BinaryPart::UnaryExpression(unary_expression) => unary_expression.start(),
}
}
pub fn end(&self) -> usize {
match self {
BinaryPart::Literal(literal) => literal.end(),
BinaryPart::Identifier(identifier) => identifier.end(),
BinaryPart::BinaryExpression(binary_expression) => binary_expression.end(),
BinaryPart::CallExpression(call_expression) => call_expression.end(),
BinaryPart::UnaryExpression(unary_expression) => unary_expression.end(),
}
}
pub fn get_result(
&self,
memory: &mut ProgramMemory,
pipe_info: &mut PipeInfo,
stdlib: &crate::std::StdLib,
engine: &mut EngineConnection,
) -> Result<MemoryItem, KclError> {
pipe_info.is_in_pipe = false;
match self {
BinaryPart::Literal(literal) => Ok(literal.into()),
BinaryPart::Identifier(identifier) => {
let value = memory.get(&identifier.name, identifier.into())?;
Ok(value.clone())
}
BinaryPart::BinaryExpression(binary_expression) => {
binary_expression.get_result(memory, pipe_info, stdlib, engine)
}
BinaryPart::CallExpression(call_expression) => call_expression.execute(memory, pipe_info, stdlib, engine),
BinaryPart::UnaryExpression(unary_expression) => {
// Return an error this should not happen.
Err(KclError::Semantic(KclErrorDetails {
message: format!("UnaryExpression should not be a BinaryPart: {:?}", unary_expression),
source_ranges: vec![unary_expression.into()],
}))
}
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, ts_rs::TS, JsonSchema)]
#[ts(export)]
#[serde(tag = "type")]
pub struct NoneCodeNode {
pub start: usize,
pub end: usize,
pub value: String,
}
#[derive(Debug, Clone, Serialize, PartialEq, ts_rs::TS, JsonSchema)]
#[ts(export)]
#[serde(rename_all = "camelCase")]
pub struct NoneCodeMeta {
pub none_code_nodes: HashMap<usize, NoneCodeNode>,
pub start: Option<NoneCodeNode>,
}
// implement Deserialize manually because we to force the keys of none_code_nodes to be usize
// and by default the ts type { [statementIndex: number]: NoneCodeNode } serializes to a string i.e. "0", "1", etc.
impl<'de> Deserialize<'de> for NoneCodeMeta {
fn deserialize<D>(deserializer: D) -> Result<NoneCodeMeta, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct NoneCodeMetaHelper {
none_code_nodes: HashMap<String, NoneCodeNode>,
start: Option<NoneCodeNode>,
}
let helper = NoneCodeMetaHelper::deserialize(deserializer)?;
let mut none_code_nodes = HashMap::new();
for (key, value) in helper.none_code_nodes {
none_code_nodes.insert(key.parse().map_err(serde::de::Error::custom)?, value);
}
Ok(NoneCodeMeta {
none_code_nodes,
start: helper.start,
})
}
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, ts_rs::TS, JsonSchema)]
#[ts(export)]
#[serde(tag = "type")]
pub struct ExpressionStatement {
pub start: usize,
pub end: usize,
pub expression: Value,
}
impl_value_meta!(ExpressionStatement);
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, ts_rs::TS, JsonSchema)]
#[ts(export)]
#[serde(tag = "type")]
pub struct CallExpression {
pub start: usize,
pub end: usize,
pub callee: Identifier,
pub arguments: Vec<Value>,
pub optional: bool,
}
impl_value_meta!(CallExpression);
impl CallExpression {
pub fn execute(
&self,
memory: &mut ProgramMemory,
pipe_info: &mut PipeInfo,
stdlib: &crate::std::StdLib,
engine: &mut EngineConnection,
) -> Result<MemoryItem, KclError> {
let fn_name = self.callee.name.clone();
let mut fn_args: Vec<MemoryItem> = Vec::with_capacity(self.arguments.len());
for arg in &self.arguments {
let result: MemoryItem = match arg {
Value::Literal(literal) => literal.into(),
Value::Identifier(identifier) => {
let value = memory.get(&identifier.name, identifier.into())?;
value.clone()
}
Value::BinaryExpression(binary_expression) => {
binary_expression.get_result(memory, pipe_info, stdlib, engine)?
}
Value::CallExpression(call_expression) => {
pipe_info.is_in_pipe = false;
call_expression.execute(memory, pipe_info, stdlib, engine)?
}
Value::UnaryExpression(unary_expression) => {
unary_expression.get_result(memory, pipe_info, stdlib, engine)?
}
Value::ObjectExpression(object_expression) => {
object_expression.execute(memory, pipe_info, stdlib, engine)?
}
Value::ArrayExpression(array_expression) => {
array_expression.execute(memory, pipe_info, stdlib, engine)?
}
Value::PipeExpression(pipe_expression) => {
return Err(KclError::Semantic(KclErrorDetails {
message: format!("PipeExpression not implemented here: {:?}", pipe_expression),
source_ranges: vec![pipe_expression.into()],
}));
}
Value::PipeSubstitution(pipe_substitution) => pipe_info
.previous_results
.get(&pipe_info.index - 1)
.ok_or_else(|| {
KclError::Semantic(KclErrorDetails {
message: format!("PipeSubstitution index out of bounds: {:?}", pipe_info),
source_ranges: vec![pipe_substitution.into()],
})
})?
.clone(),
Value::MemberExpression(member_expression) => {
return Err(KclError::Semantic(KclErrorDetails {
message: format!("MemberExpression not implemented here: {:?}", member_expression),
source_ranges: vec![member_expression.into()],
}));
}
Value::FunctionExpression(function_expression) => {
return Err(KclError::Semantic(KclErrorDetails {
message: format!("FunctionExpression not implemented here: {:?}", function_expression),
source_ranges: vec![function_expression.into()],
}));
}
};
fn_args.push(result);
}
if let Some(func) = stdlib.fns.get(&fn_name) {
// Attempt to call the function.
let mut args = crate::std::Args::new(fn_args, self.into(), engine);
let result = func(&mut args)?;
if pipe_info.is_in_pipe {
pipe_info.index += 1;
pipe_info.previous_results.push(result);
execute_pipe_body(memory, &pipe_info.body.clone(), pipe_info, self.into(), stdlib, engine)
} else {
Ok(result)
}
} else {
let mem = memory.clone();
let func = mem.get(&fn_name, self.into())?;
let result = func.call_fn(&fn_args, memory, engine)?.ok_or_else(|| {
KclError::UndefinedValue(KclErrorDetails {
message: format!("Result of function {} is undefined", fn_name),
source_ranges: vec![self.into()],
})
})?;
let result = result.get_value()?;
if pipe_info.is_in_pipe {
pipe_info.index += 1;
pipe_info.previous_results.push(result);
execute_pipe_body(memory, &pipe_info.body.clone(), pipe_info, self.into(), stdlib, engine)
} else {
Ok(result)
}
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, ts_rs::TS, JsonSchema)]
#[ts(export)]
#[serde(tag = "type")]
pub struct VariableDeclaration {
pub start: usize,
pub end: usize,
pub declarations: Vec<VariableDeclarator>,
pub kind: VariableKind, // Change to enum if there are specific values
}
impl_value_meta!(VariableDeclaration);
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, ts_rs::TS, JsonSchema, FromStr, Display)]
#[ts(export)]
#[serde(rename_all = "snake_case")]
#[display(style = "snake_case")]
pub enum VariableKind {
Let,
Const,
Fn,
Var,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, ts_rs::TS, JsonSchema)]
#[ts(export)]
#[serde(tag = "type")]
pub struct VariableDeclarator {
pub start: usize,
pub end: usize,
pub id: Identifier,
pub init: Value,
}
impl_value_meta!(VariableDeclarator);
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, ts_rs::TS, JsonSchema)]
#[ts(export)]
#[serde(tag = "type")]
pub struct Literal {
pub start: usize,
pub end: usize,
pub value: serde_json::Value,
pub raw: String,
}
impl_value_meta!(Literal);
impl From<Literal> for MemoryItem {
fn from(literal: Literal) -> Self {
MemoryItem::UserVal {
value: literal.value.clone(),
meta: vec![Metadata {
source_range: literal.into(),
}],
}
}
}
impl From<&Box<Literal>> for MemoryItem {
fn from(literal: &Box<Literal>) -> Self {
MemoryItem::UserVal {
value: literal.value.clone(),
meta: vec![Metadata {
source_range: literal.into(),
}],
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, ts_rs::TS, JsonSchema)]
#[ts(export)]
#[serde(tag = "type")]
pub struct Identifier {
pub start: usize,
pub end: usize,
pub name: String,
}
impl_value_meta!(Identifier);
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, ts_rs::TS, JsonSchema)]
#[ts(export)]
#[serde(tag = "type")]
pub struct PipeSubstitution {
pub start: usize,
pub end: usize,
}
impl_value_meta!(PipeSubstitution);
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, ts_rs::TS, JsonSchema)]
#[ts(export)]
#[serde(tag = "type")]
pub struct ArrayExpression {
pub start: usize,
pub end: usize,
pub elements: Vec<Value>,
}
impl_value_meta!(ArrayExpression);
impl ArrayExpression {
pub fn execute(
&self,
memory: &mut ProgramMemory,
pipe_info: &mut PipeInfo,
stdlib: &crate::std::StdLib,
engine: &mut EngineConnection,
) -> Result<MemoryItem, KclError> {
let mut results = Vec::with_capacity(self.elements.len());
for element in &self.elements {
let result = match element {
Value::Literal(literal) => literal.into(),
Value::Identifier(identifier) => {
let value = memory.get(&identifier.name, identifier.into())?;
value.clone()
}
Value::BinaryExpression(binary_expression) => {
binary_expression.get_result(memory, pipe_info, stdlib, engine)?
}
Value::CallExpression(call_expression) => {
pipe_info.is_in_pipe = false;
call_expression.execute(memory, pipe_info, stdlib, engine)?
}
Value::UnaryExpression(unary_expression) => {
unary_expression.get_result(memory, pipe_info, stdlib, engine)?
}
Value::ObjectExpression(object_expression) => {
object_expression.execute(memory, pipe_info, stdlib, engine)?
}
Value::ArrayExpression(array_expression) => {
array_expression.execute(memory, pipe_info, stdlib, engine)?
}
Value::PipeExpression(pipe_expression) => {
pipe_expression.get_result(memory, pipe_info, stdlib, engine)?
}
Value::PipeSubstitution(pipe_substitution) => {
return Err(KclError::Semantic(KclErrorDetails {
message: format!("PipeSubstitution not implemented here: {:?}", pipe_substitution),
source_ranges: vec![pipe_substitution.into()],
}));
}
Value::MemberExpression(member_expression) => {
return Err(KclError::Semantic(KclErrorDetails {
message: format!("MemberExpression not implemented here: {:?}", member_expression),
source_ranges: vec![member_expression.into()],
}));
}
Value::FunctionExpression(function_expression) => {
return Err(KclError::Semantic(KclErrorDetails {
message: format!("FunctionExpression not implemented here: {:?}", function_expression),
source_ranges: vec![function_expression.into()],
}));
}
}
.get_json_value()?;
results.push(result);
}
Ok(MemoryItem::UserVal {
value: results.into(),
meta: vec![Metadata {
source_range: self.into(),
}],
})
}
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, ts_rs::TS, JsonSchema)]
#[ts(export)]
#[serde(tag = "type")]
pub struct ObjectExpression {
pub start: usize,
pub end: usize,
pub properties: Vec<ObjectProperty>,
}
impl ObjectExpression {
pub fn execute(
&self,
memory: &mut ProgramMemory,
pipe_info: &mut PipeInfo,
stdlib: &crate::std::StdLib,
engine: &mut EngineConnection,
) -> Result<MemoryItem, KclError> {
let mut object = Map::new();
for property in &self.properties {
let result = match &property.value {
Value::Literal(literal) => literal.into(),
Value::Identifier(identifier) => {
let value = memory.get(&identifier.name, identifier.into())?;
value.clone()
}
Value::BinaryExpression(binary_expression) => {
binary_expression.get_result(memory, pipe_info, stdlib, engine)?
}
Value::CallExpression(call_expression) => {
pipe_info.is_in_pipe = false;
call_expression.execute(memory, pipe_info, stdlib, engine)?
}
Value::UnaryExpression(unary_expression) => {
unary_expression.get_result(memory, pipe_info, stdlib, engine)?
}
Value::ObjectExpression(object_expression) => {
object_expression.execute(memory, pipe_info, stdlib, engine)?
}
Value::ArrayExpression(array_expression) => {
array_expression.execute(memory, pipe_info, stdlib, engine)?
}
Value::PipeExpression(pipe_expression) => {
pipe_expression.get_result(memory, pipe_info, stdlib, engine)?
}
Value::PipeSubstitution(pipe_substitution) => {
return Err(KclError::Semantic(KclErrorDetails {
message: format!("PipeSubstitution not implemented here: {:?}", pipe_substitution),
source_ranges: vec![pipe_substitution.into()],
}));
}
Value::MemberExpression(member_expression) => {
return Err(KclError::Semantic(KclErrorDetails {
message: format!("MemberExpression not implemented here: {:?}", member_expression),
source_ranges: vec![member_expression.into()],
}));
}
Value::FunctionExpression(function_expression) => {
return Err(KclError::Semantic(KclErrorDetails {
message: format!("FunctionExpression not implemented here: {:?}", function_expression),
source_ranges: vec![function_expression.into()],
}));
}
};
object.insert(property.key.name.clone(), result.get_json_value()?);
}
Ok(MemoryItem::UserVal {
value: object.into(),
meta: vec![Metadata {
source_range: self.into(),
}],
})
}
}
impl_value_meta!(ObjectExpression);
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, ts_rs::TS, JsonSchema)]
#[ts(export)]
#[serde(tag = "type")]
pub struct ObjectProperty {
pub start: usize,
pub end: usize,
pub key: Identifier,
pub value: Value,
}
impl_value_meta!(ObjectProperty);
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, ts_rs::TS, JsonSchema)]
#[ts(export)]
#[serde(tag = "type")]
pub enum MemberObject {
MemberExpression(Box<MemberExpression>),
Identifier(Box<Identifier>),
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, ts_rs::TS, JsonSchema)]
#[ts(export)]
#[serde(tag = "type")]
pub enum LiteralIdentifier {
Identifier(Box<Identifier>),
Literal(Box<Literal>),
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, ts_rs::TS, JsonSchema)]
#[ts(export)]
#[serde(tag = "type")]
pub struct MemberExpression {
pub start: usize,
pub end: usize,
pub object: MemberObject,
pub property: LiteralIdentifier,
pub computed: bool,
}
impl_value_meta!(MemberExpression);
impl MemberExpression {
pub fn get_result(&self, memory: &mut ProgramMemory) -> Result<MemoryItem, KclError> {
let property_name = match &self.property {
LiteralIdentifier::Identifier(identifier) => identifier.name.to_string(),
LiteralIdentifier::Literal(literal) => {
let value = literal.value.clone();
// Parse this as a string.
if let serde_json::Value::String(string) = value {
string
} else {
return Err(KclError::Semantic(KclErrorDetails {
message: format!("Expected string literal for property name, found {:?}", value),
source_ranges: vec![literal.into()],
}));
}
}
};
let object = match &self.object {
MemberObject::MemberExpression(member_expr) => member_expr.get_result(memory)?,
MemberObject::Identifier(identifier) => {
let value = memory.get(&identifier.name, identifier.into())?;
value.clone()
}
}
.get_json_value()?;
if let serde_json::Value::Object(map) = object {
if let Some(value) = map.get(&property_name) {
Ok(MemoryItem::UserVal {
value: value.clone(),
meta: vec![Metadata {
source_range: self.into(),
}],
})
} else {
Err(KclError::UndefinedValue(KclErrorDetails {
message: format!("Property {} not found in object", property_name),
source_ranges: vec![self.clone().into()],
}))
}
} else {
Err(KclError::Semantic(KclErrorDetails {
message: format!("MemberExpression object is not an object: {:?}", object),
source_ranges: vec![self.clone().into()],
}))
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, ts_rs::TS, JsonSchema)]
#[ts(export)]
pub struct ObjectKeyInfo {
pub key: LiteralIdentifier,
pub index: usize,
pub computed: bool,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, ts_rs::TS, JsonSchema)]
#[ts(export)]
#[serde(tag = "type")]
pub struct BinaryExpression {
pub start: usize,
pub end: usize,
// TODO: operator should be a type not a string.
pub operator: String,
pub left: BinaryPart,
pub right: BinaryPart,
}
impl_value_meta!(BinaryExpression);
impl BinaryExpression {
pub fn get_result(
&self,
memory: &mut ProgramMemory,
pipe_info: &mut PipeInfo,
stdlib: &crate::std::StdLib,
engine: &mut EngineConnection,
) -> Result<MemoryItem, KclError> {
pipe_info.is_in_pipe = false;
let left_json_value = self
.left
.get_result(memory, pipe_info, stdlib, engine)?
.get_json_value()?;
let right_json_value = self
.right
.get_result(memory, pipe_info, stdlib, engine)?
.get_json_value()?;
// First check if we are doing string concatenation.
if self.operator == "+" {
if let (Some(left), Some(right)) = (
parse_json_value_as_string(&left_json_value),
parse_json_value_as_string(&right_json_value),
) {
let value = serde_json::Value::String(format!("{}{}", left, right));
return Ok(MemoryItem::UserVal {
value,
meta: vec![Metadata {
source_range: self.into(),
}],
});
}
}
let left = parse_json_number_as_f64(&left_json_value, self.left.clone().into())?;
let right = parse_json_number_as_f64(&right_json_value, self.right.clone().into())?;
let value: serde_json::Value = match self.operator.as_str() {
"+" => (left + right).into(),
"-" => (left - right).into(),
"*" => (left * right).into(),
"/" => (left / right).into(),
"%" => (left % right).into(),
_ => {
return Err(KclError::Syntax(KclErrorDetails {
source_ranges: vec![self.into()],
message: format!("Invalid operator: {}", self.operator),
}))
}
};
Ok(MemoryItem::UserVal {
value,
meta: vec![Metadata {
source_range: self.into(),
}],
})
}
}
pub fn parse_json_number_as_f64(j: &serde_json::Value, source_range: SourceRange) -> Result<f64, KclError> {
if let serde_json::Value::Number(n) = &j {
n.as_f64().ok_or_else(|| {
KclError::Syntax(KclErrorDetails {
source_ranges: vec![source_range],
message: format!("Invalid number: {}", j),
})
})
} else {
Err(KclError::Syntax(KclErrorDetails {
source_ranges: vec![source_range],
message: format!("Invalid number: {}", j),
}))
}
}
pub fn parse_json_value_as_string(j: &serde_json::Value) -> Option<String> {
if let serde_json::Value::String(n) = &j {
Some(n.clone())
} else {
None
}
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, ts_rs::TS, JsonSchema)]
#[ts(export)]
#[serde(tag = "type")]
pub struct UnaryExpression {
pub start: usize,
pub end: usize,
pub operator: String,
pub argument: BinaryPart,
}
impl_value_meta!(UnaryExpression);
impl UnaryExpression {
pub fn get_result(
&self,
memory: &mut ProgramMemory,
pipe_info: &mut PipeInfo,
stdlib: &crate::std::StdLib,
engine: &mut EngineConnection,
) -> Result<MemoryItem, KclError> {
pipe_info.is_in_pipe = false;
let num = parse_json_number_as_f64(
&self
.argument
.get_result(memory, pipe_info, stdlib, engine)?
.get_json_value()?,
self.into(),
)?;
Ok(MemoryItem::UserVal {
value: (-(num)).into(),
meta: vec![Metadata {
source_range: self.into(),
}],
})
}
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, ts_rs::TS, JsonSchema)]
#[ts(export)]
#[serde(rename_all = "camelCase", tag = "type")]
pub struct PipeExpression {
pub start: usize,
pub end: usize,
pub body: Vec<Value>,
pub non_code_meta: NoneCodeMeta,
}
impl_value_meta!(PipeExpression);
impl PipeExpression {
pub fn get_result(
&self,
memory: &mut ProgramMemory,
pipe_info: &mut PipeInfo,
stdlib: &crate::std::StdLib,
engine: &mut EngineConnection,
) -> Result<MemoryItem, KclError> {
// Reset the previous results.
pipe_info.previous_results = vec![];
pipe_info.index = 0;
execute_pipe_body(memory, &self.body, pipe_info, self.into(), stdlib, engine)
}
}
fn execute_pipe_body(
memory: &mut ProgramMemory,
body: &[Value],
pipe_info: &mut PipeInfo,
source_range: SourceRange,
stdlib: &crate::std::StdLib,
engine: &mut EngineConnection,
) -> Result<MemoryItem, KclError> {
if pipe_info.index == body.len() {
pipe_info.is_in_pipe = false;
return Ok(pipe_info
.previous_results
.last()
.ok_or_else(|| {
KclError::Semantic(KclErrorDetails {
message: "Pipe body results should have at least one expression".to_string(),
source_ranges: vec![source_range],
})
})?
.clone());
}
let expression = body.get(pipe_info.index).ok_or_else(|| {
KclError::Semantic(KclErrorDetails {
message: format!("Invalid index for pipe: {}", pipe_info.index),
source_ranges: vec![source_range],
})
})?;
match expression {
Value::BinaryExpression(binary_expression) => {
let result = binary_expression.get_result(memory, pipe_info, stdlib, engine)?;
pipe_info.previous_results.push(result);
pipe_info.index += 1;
execute_pipe_body(memory, body, pipe_info, source_range, stdlib, engine)
}
Value::CallExpression(call_expression) => {
pipe_info.is_in_pipe = true;
pipe_info.body = body.to_vec();
call_expression.execute(memory, pipe_info, stdlib, engine)
}
_ => {
// Return an error this should not happen.
Err(KclError::Semantic(KclErrorDetails {
message: format!("PipeExpression not implemented here: {:?}", expression),
source_ranges: vec![expression.into()],
}))
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, ts_rs::TS, JsonSchema)]
#[ts(export)]
#[serde(tag = "type")]
pub struct FunctionExpression {
pub start: usize,
pub end: usize,
pub id: Option<Identifier>,
pub params: Vec<Identifier>,
pub body: Program,
}
impl_value_meta!(FunctionExpression);
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, ts_rs::TS, JsonSchema)]
#[ts(export)]
#[serde(tag = "type")]
pub struct ReturnStatement {
pub start: usize,
pub end: usize,
pub argument: Value,
}
impl_value_meta!(ReturnStatement);

View File

@ -0,0 +1,284 @@
//! Functions for generating docs for our stdlib functions.
use anyhow::Result;
use serde::{Deserialize, Serialize};
use crate::std::Primitive;
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
pub struct StdLibFnData {
/// The name of the function.
pub name: String,
/// The summary of the function.
pub summary: String,
/// The description of the function.
pub description: String,
/// The tags of the function.
pub tags: Vec<String>,
/// The args of the function.
pub args: Vec<StdLibFnArg>,
/// The return value of the function.
pub return_value: StdLibFnArg,
/// If the function is unpublished.
pub unpublished: bool,
/// If the function is deprecated.
pub deprecated: bool,
}
/// This struct defines a single argument to a stdlib function.
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
pub struct StdLibFnArg {
/// The name of the argument.
pub name: String,
/// The type of the argument.
pub type_: String,
/// The schema of the argument.
pub schema: schemars::schema::Schema,
/// If the argument is required.
pub required: bool,
}
impl StdLibFnArg {
#[allow(dead_code)]
pub fn get_type_string(&self) -> Result<(String, bool)> {
get_type_string_from_schema(&self.schema)
}
#[allow(dead_code)]
pub fn get_autocomplete_string(&self) -> Result<String> {
get_autocomplete_string_from_schema(&self.schema)
}
#[allow(dead_code)]
pub fn description(&self) -> Option<String> {
get_description_string_from_schema(&self.schema)
}
}
/// This trait defines functions called upon stdlib functions to generate
/// documentation for them.
pub trait StdLibFn {
/// The name of the function.
fn name(&self) -> String;
/// The summary of the function.
fn summary(&self) -> String;
/// The description of the function.
fn description(&self) -> String;
/// The tags of the function.
fn tags(&self) -> Vec<String>;
/// The args of the function.
fn args(&self) -> Vec<StdLibFnArg>;
/// The return value of the function.
fn return_value(&self) -> StdLibFnArg;
/// If the function is unpublished.
fn unpublished(&self) -> bool;
/// If the function is deprecated.
fn deprecated(&self) -> bool;
/// The function itself.
fn std_lib_fn(&self) -> crate::std::StdFn;
/// Return a JSON struct representing the function.
fn to_json(&self) -> Result<StdLibFnData> {
Ok(StdLibFnData {
name: self.name(),
summary: self.summary(),
description: self.description(),
tags: self.tags(),
args: self.args(),
return_value: self.return_value(),
unpublished: self.unpublished(),
deprecated: self.deprecated(),
})
}
fn fn_signature(&self) -> String {
let mut signature = String::new();
signature.push_str(&format!("{}(", self.name()));
for (i, arg) in self.args().iter().enumerate() {
if i > 0 {
signature.push_str(", ");
}
signature.push_str(&format!("{}: {}", arg.name, arg.type_));
}
signature.push_str(") -> ");
signature.push_str(&self.return_value().type_);
signature
}
}
pub fn get_description_string_from_schema(schema: &schemars::schema::Schema) -> Option<String> {
if let schemars::schema::Schema::Object(o) = schema {
if let Some(metadata) = &o.metadata {
if let Some(description) = &metadata.description {
return Some(description.to_string());
}
}
}
None
}
pub fn get_type_string_from_schema(schema: &schemars::schema::Schema) -> Result<(String, bool)> {
match schema {
schemars::schema::Schema::Object(o) => {
if let Some(format) = &o.format {
if format == "uuid" {
return Ok((Primitive::Uuid.to_string(), false));
} else if format == "double" || format == "uint" {
return Ok((Primitive::Number.to_string(), false));
} else {
anyhow::bail!("unknown format: {}", format);
}
}
if let Some(obj_val) = &o.object {
let mut fn_docs = String::new();
fn_docs.push_str("{\n");
// Let's print out the object's properties.
for (prop_name, prop) in obj_val.properties.iter() {
if prop_name.starts_with('_') {
continue;
}
if let Some(description) = get_description_string_from_schema(prop) {
fn_docs.push_str(&format!("\t// {}\n", description));
}
fn_docs.push_str(&format!(
"\t\"{}\": {},\n",
prop_name,
get_type_string_from_schema(prop)?.0,
));
}
fn_docs.push('}');
return Ok((fn_docs, true));
}
if let Some(array_val) = &o.array {
if let Some(schemars::schema::SingleOrVec::Single(items)) = &array_val.items {
// Let's print out the object's properties.
return Ok((format!("[{}]", get_type_string_from_schema(items)?.0), false));
} else if let Some(items) = &array_val.contains {
return Ok((format!("[{}]", get_type_string_from_schema(items)?.0), false));
}
}
if let Some(subschemas) = &o.subschemas {
let mut fn_docs = String::new();
if let Some(items) = &subschemas.one_of {
for (i, item) in items.iter().enumerate() {
// Let's print out the object's properties.
fn_docs.push_str(&get_type_string_from_schema(item)?.0.to_string());
if i < items.len() - 1 {
fn_docs.push_str(" |\n");
}
}
} else if let Some(items) = &subschemas.any_of {
for (i, item) in items.iter().enumerate() {
// Let's print out the object's properties.
fn_docs.push_str(&get_type_string_from_schema(item)?.0.to_string());
if i < items.len() - 1 {
fn_docs.push_str(" |\n");
}
}
} else {
anyhow::bail!("unknown subschemas: {:#?}", subschemas);
}
return Ok((fn_docs, true));
}
if let Some(schemars::schema::SingleOrVec::Single(_string)) = &o.instance_type {
return Ok((Primitive::String.to_string(), false));
}
anyhow::bail!("unknown type: {:#?}", o)
}
schemars::schema::Schema::Bool(_) => Ok((Primitive::Bool.to_string(), false)),
}
}
pub fn get_autocomplete_string_from_schema(schema: &schemars::schema::Schema) -> Result<String> {
match schema {
schemars::schema::Schema::Object(o) => {
if let Some(format) = &o.format {
if format == "uuid" {
return Ok(Primitive::Uuid.to_string());
} else if format == "double" || format == "uint" {
return Ok(Primitive::Number.to_string());
} else {
anyhow::bail!("unknown format: {}", format);
}
}
if let Some(obj_val) = &o.object {
let mut fn_docs = String::new();
fn_docs.push_str("{\n");
// Let's print out the object's properties.
for (prop_name, prop) in obj_val.properties.iter() {
if prop_name.starts_with('_') {
continue;
}
if let Some(description) = get_description_string_from_schema(prop) {
fn_docs.push_str(&format!("\t// {}\n", description));
}
fn_docs.push_str(&format!(
"\t\"{}\": {},\n",
prop_name,
get_autocomplete_string_from_schema(prop)?,
));
}
fn_docs.push('}');
return Ok(fn_docs);
}
if let Some(array_val) = &o.array {
if let Some(schemars::schema::SingleOrVec::Single(items)) = &array_val.items {
// Let's print out the object's properties.
return Ok(format!("[{}]", get_autocomplete_string_from_schema(items)?));
} else if let Some(items) = &array_val.contains {
return Ok(format!("[{}]", get_autocomplete_string_from_schema(items)?));
}
}
if let Some(subschemas) = &o.subschemas {
let mut fn_docs = String::new();
if let Some(items) = &subschemas.one_of {
if let Some(item) = items.iter().next() {
// Let's print out the object's properties.
fn_docs.push_str(&get_autocomplete_string_from_schema(item)?);
}
} else if let Some(items) = &subschemas.any_of {
if let Some(item) = items.iter().next() {
// Let's print out the object's properties.
fn_docs.push_str(&get_autocomplete_string_from_schema(item)?);
}
} else {
anyhow::bail!("unknown subschemas: {:#?}", subschemas);
}
return Ok(fn_docs);
}
if let Some(schemars::schema::SingleOrVec::Single(_string)) = &o.instance_type {
return Ok(Primitive::String.to_string());
}
anyhow::bail!("unknown type: {:#?}", o)
}
schemars::schema::Schema::Bool(_) => Ok(Primitive::Bool.to_string()),
}
}

View File

@ -0,0 +1,175 @@
//! Functions for setting up our WebSocket and WebRTC connections for communications with the
//! engine.
use std::sync::Arc;
use anyhow::Result;
use futures::{SinkExt, StreamExt};
use kittycad::types::{OkWebSocketResponseData, WebSocketRequest, WebSocketResponse};
use tokio_tungstenite::tungstenite::Message as WsMsg;
use crate::errors::{KclError, KclErrorDetails};
#[derive(Debug)]
pub struct EngineConnection {
tcp_write: futures::stream::SplitSink<tokio_tungstenite::WebSocketStream<reqwest::Upgraded>, WsMsg>,
tcp_read_handle: tokio::task::JoinHandle<Result<()>>,
export_notifier: Arc<tokio::sync::Notify>,
snapshot_notifier: Arc<tokio::sync::Notify>,
}
impl Drop for EngineConnection {
fn drop(&mut self) {
// Drop the read handle.
self.tcp_read_handle.abort();
}
}
pub struct TcpRead {
stream: futures::stream::SplitStream<tokio_tungstenite::WebSocketStream<reqwest::Upgraded>>,
}
impl TcpRead {
pub async fn read(&mut self) -> Result<WebSocketResponse> {
let msg = self.stream.next().await.unwrap()?;
let msg: WebSocketResponse = match msg {
WsMsg::Text(text) => serde_json::from_str(&text)?,
WsMsg::Binary(bin) => bson::from_slice(&bin)?,
other => anyhow::bail!("Unexpected websocket message from server: {}", other),
};
Ok(msg)
}
}
impl EngineConnection {
pub async fn new(ws: reqwest::Upgraded, export_dir: &str, snapshot_file: &str) -> Result<EngineConnection> {
// Make sure the export directory exists and that it is a directory.
let export_dir = std::path::Path::new(export_dir).to_owned();
if !export_dir.exists() {
anyhow::bail!("Export directory does not exist: {}", export_dir.display());
}
// Make sure it is a directory.
if !export_dir.is_dir() {
anyhow::bail!("Export directory is not a directory: {}", export_dir.display());
}
let ws_stream = tokio_tungstenite::WebSocketStream::from_raw_socket(
ws,
tokio_tungstenite::tungstenite::protocol::Role::Client,
None,
)
.await;
let (tcp_write, tcp_read) = ws_stream.split();
let mut tcp_read = TcpRead { stream: tcp_read };
let export_notifier = Arc::new(tokio::sync::Notify::new());
let export_notifier_clone = export_notifier.clone();
let snapshot_notifier = Arc::new(tokio::sync::Notify::new());
let snapshot_notifier_clone = snapshot_notifier.clone();
let snapshot_file = snapshot_file.to_owned();
let tcp_read_handle = tokio::spawn(async move {
// Get Websocket messages from API server
loop {
match tcp_read.read().await {
Ok(ws_resp) => {
if let Some(success) = ws_resp.success {
if !success {
println!("got ws errors: {:?}", ws_resp.errors);
export_notifier.notify_one();
continue;
}
}
if let Some(msg) = ws_resp.resp {
match msg {
OkWebSocketResponseData::IceServerInfo { ice_servers } => {
println!("got ice server info: {:?}", ice_servers);
}
OkWebSocketResponseData::SdpAnswer { answer } => {
println!("got sdp answer: {:?}", answer);
}
OkWebSocketResponseData::TrickleIce { candidate } => {
println!("got trickle ice: {:?}", candidate);
}
OkWebSocketResponseData::Modeling { modeling_response } => {
if let kittycad::types::OkModelingCmdResponse::TakeSnapshot { data } =
modeling_response
{
if snapshot_file.is_empty() {
println!("Got snapshot, but no snapshot file specified.");
continue;
}
// Save the snapshot locally.
std::fs::write(&snapshot_file, data.contents)?;
snapshot_notifier.notify_one();
}
}
OkWebSocketResponseData::Export { files } => {
// Save the files to our export directory.
for file in files {
let path = export_dir.join(file.name);
std::fs::write(&path, file.contents)?;
println!("Wrote file: {}", path.display());
}
// Tell the export notifier that we have new files.
export_notifier.notify_one();
}
}
}
}
Err(e) => {
println!("got ws error: {:?}", e);
export_notifier.notify_one();
continue;
}
}
}
});
Ok(EngineConnection {
tcp_write,
tcp_read_handle,
export_notifier: export_notifier_clone,
snapshot_notifier: snapshot_notifier_clone,
})
}
pub async fn wait_for_export(&self) {
self.export_notifier.notified().await;
}
pub async fn wait_for_snapshot(&self) {
self.snapshot_notifier.notified().await;
}
pub async fn tcp_send(&mut self, msg: WebSocketRequest) -> Result<()> {
let msg = serde_json::to_string(&msg)?;
self.tcp_write.send(WsMsg::Text(msg)).await?;
Ok(())
}
pub fn send_modeling_cmd(
&mut self,
id: uuid::Uuid,
source_range: crate::executor::SourceRange,
cmd: kittycad::types::ModelingCmd,
) -> Result<(), KclError> {
futures::executor::block_on(self.tcp_send(WebSocketRequest::ModelingCmdReq { cmd, cmd_id: id })).map_err(
|e| {
KclError::Engine(KclErrorDetails {
message: format!("Failed to send modeling command: {}", e),
source_ranges: vec![source_range],
})
},
)?;
Ok(())
}
}

View File

@ -0,0 +1,24 @@
//! Functions for setting up our WebSocket and WebRTC connections for communications with the
//! engine.
use anyhow::Result;
use crate::errors::KclError;
#[derive(Debug)]
pub struct EngineConnection {}
impl EngineConnection {
pub async fn new() -> Result<EngineConnection> {
Ok(EngineConnection {})
}
pub fn send_modeling_cmd(
&mut self,
_id: uuid::Uuid,
_source_range: crate::executor::SourceRange,
_cmd: kittycad::types::ModelingCmd,
) -> Result<(), KclError> {
Ok(())
}
}

View File

@ -0,0 +1,58 @@
//! Functions for setting up our WebSocket and WebRTC connections for communications with the
//! engine.
use anyhow::Result;
use kittycad::types::WebSocketRequest;
use wasm_bindgen::prelude::*;
use crate::errors::{KclError, KclErrorDetails};
#[wasm_bindgen(module = "/../../lang/std/engineConnection.ts")]
extern "C" {
#[derive(Debug, Clone)]
pub type EngineCommandManager;
#[wasm_bindgen(method)]
fn sendModelingCommandFromWasm(
this: &EngineCommandManager,
id: String,
rangeStr: String,
cmdStr: String,
) -> js_sys::Promise;
}
#[derive(Debug, Clone)]
pub struct EngineConnection {
manager: EngineCommandManager,
}
impl EngineConnection {
pub async fn new(manager: EngineCommandManager) -> Result<EngineConnection, JsValue> {
Ok(EngineConnection { manager })
}
pub fn send_modeling_cmd(
&mut self,
id: uuid::Uuid,
source_range: crate::executor::SourceRange,
cmd: kittycad::types::ModelingCmd,
) -> Result<(), KclError> {
let source_range_str = serde_json::to_string(&source_range).map_err(|e| {
KclError::Engine(KclErrorDetails {
message: format!("Failed to serialize source range: {:?}", e),
source_ranges: vec![source_range],
})
})?;
let ws_msg = WebSocketRequest::ModelingCmdReq { cmd, cmd_id: id };
let cmd_str = serde_json::to_string(&ws_msg).map_err(|e| {
KclError::Engine(KclErrorDetails {
message: format!("Failed to serialize modeling command: {:?}", e),
source_ranges: vec![source_range],
})
})?;
let _ = self
.manager
.sendModelingCommandFromWasm(id.to_string(), source_range_str, cmd_str);
Ok(())
}
}

View File

@ -0,0 +1,65 @@
//! Functions for managing engine communications.
use wasm_bindgen::prelude::*;
#[cfg(not(target_arch = "wasm32"))]
#[cfg(not(test))]
#[cfg(feature = "engine")]
pub mod conn;
#[cfg(not(target_arch = "wasm32"))]
#[cfg(not(test))]
#[cfg(feature = "engine")]
pub use conn::EngineConnection;
#[cfg(target_arch = "wasm32")]
#[cfg(not(test))]
#[cfg(feature = "engine")]
pub mod conn_wasm;
#[cfg(target_arch = "wasm32")]
#[cfg(not(test))]
#[cfg(feature = "engine")]
pub use conn_wasm::EngineConnection;
#[cfg(test)]
pub mod conn_mock;
#[cfg(test)]
pub use conn_mock::EngineConnection;
#[cfg(not(feature = "engine"))]
#[cfg(not(test))]
pub mod conn_mock;
#[cfg(not(feature = "engine"))]
#[cfg(not(test))]
pub use conn_mock::EngineConnection;
use crate::executor::SourceRange;
#[derive(Debug)]
#[wasm_bindgen]
pub struct EngineManager {
connection: EngineConnection,
}
#[wasm_bindgen]
impl EngineManager {
#[cfg(target_arch = "wasm32")]
#[cfg(not(test))]
#[cfg(feature = "engine")]
#[wasm_bindgen(constructor)]
pub async fn new(manager: conn_wasm::EngineCommandManager) -> EngineManager {
EngineManager {
// This unwrap is safe because the connection is always created.
connection: EngineConnection::new(manager).await.unwrap(),
}
}
pub fn send_modeling_cmd(&mut self, id_str: &str, cmd_str: &str) -> Result<(), String> {
let id = uuid::Uuid::parse_str(id_str).map_err(|e| e.to_string())?;
let cmd = serde_json::from_str(cmd_str).map_err(|e| e.to_string())?;
self.connection
.send_modeling_cmd(id, SourceRange::default(), cmd)
.map_err(String::from)?;
Ok(())
}
}

View File

@ -0,0 +1,78 @@
use serde::{Deserialize, Serialize};
use thiserror::Error;
#[derive(Error, Debug, Serialize, Deserialize, ts_rs::TS)]
#[ts(export)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum KclError {
#[error("syntax: {0:?}")]
Syntax(KclErrorDetails),
#[error("semantic: {0:?}")]
Semantic(KclErrorDetails),
#[error("type: {0:?}")]
Type(KclErrorDetails),
#[error("unimplemented: {0:?}")]
Unimplemented(KclErrorDetails),
#[error("unexpected: {0:?}")]
Unexpected(KclErrorDetails),
#[error("value already defined: {0:?}")]
ValueAlreadyDefined(KclErrorDetails),
#[error("undefined value: {0:?}")]
UndefinedValue(KclErrorDetails),
#[error("invalid expression: {0:?}")]
InvalidExpression(KclErrorDetails),
#[error("engine: {0:?}")]
Engine(KclErrorDetails),
}
#[derive(Debug, Serialize, Deserialize, ts_rs::TS)]
#[ts(export)]
pub struct KclErrorDetails {
#[serde(rename = "sourceRanges")]
pub source_ranges: Vec<crate::executor::SourceRange>,
#[serde(rename = "msg")]
pub message: String,
}
impl KclError {
/// Get the error message, line and column from the error and input code.
pub fn get_message_line_column(&self, input: &str) -> (String, Option<usize>, Option<usize>) {
let (type_, source_range, message) = match &self {
KclError::Syntax(e) => ("syntax", e.source_ranges.clone(), e.message.clone()),
KclError::Semantic(e) => ("semantic", e.source_ranges.clone(), e.message.clone()),
KclError::Type(e) => ("type", e.source_ranges.clone(), e.message.clone()),
KclError::Unimplemented(e) => ("unimplemented", e.source_ranges.clone(), e.message.clone()),
KclError::Unexpected(e) => ("unexpected", e.source_ranges.clone(), e.message.clone()),
KclError::ValueAlreadyDefined(e) => ("value already defined", e.source_ranges.clone(), e.message.clone()),
KclError::UndefinedValue(e) => ("undefined value", e.source_ranges.clone(), e.message.clone()),
KclError::InvalidExpression(e) => ("invalid expression", e.source_ranges.clone(), e.message.clone()),
KclError::Engine(e) => ("engine", e.source_ranges.clone(), e.message.clone()),
};
// Calculate the line and column of the error from the source range.
let (line, column) = if let Some(range) = source_range.first() {
let line = input[..range.0[0]].lines().count();
let column = input[..range.0[0]].lines().last().map(|l| l.len()).unwrap_or_default();
(Some(line), Some(column))
} else {
(None, None)
};
(format!("{}: {}", type_, message), line, column)
}
}
/// This is different than to_string() in that it will serialize the Error
/// the struct as JSON so we can deserialize it on the js side.
impl From<KclError> for String {
fn from(error: KclError) -> Self {
serde_json::to_string(&error).unwrap()
}
}
impl From<String> for KclError {
fn from(error: String) -> Self {
serde_json::from_str(&error).unwrap()
}
}

View File

@ -0,0 +1,780 @@
//! The executor for the AST.
use std::collections::HashMap;
use anyhow::Result;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::{
abstract_syntax_tree_types::{BodyItem, FunctionExpression, Value},
engine::EngineConnection,
errors::{KclError, KclErrorDetails},
};
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, ts_rs::TS, JsonSchema)]
#[ts(export)]
#[serde(rename_all = "camelCase")]
pub struct ProgramMemory {
pub root: HashMap<String, MemoryItem>,
#[serde(rename = "return")]
pub return_: Option<ProgramReturn>,
}
impl ProgramMemory {
pub fn new() -> Self {
Self {
root: HashMap::new(),
return_: None,
}
}
/// Add to the program memory.
pub fn add(&mut self, key: &str, value: MemoryItem, source_range: SourceRange) -> Result<(), KclError> {
if self.root.get(key).is_some() {
return Err(KclError::ValueAlreadyDefined(KclErrorDetails {
message: format!("Cannot redefine {}", key),
source_ranges: vec![source_range],
}));
}
self.root.insert(key.to_string(), value);
Ok(())
}
/// Get a value from the program memory.
pub fn get(&self, key: &str, source_range: SourceRange) -> Result<&MemoryItem, KclError> {
self.root.get(key).ok_or_else(|| {
KclError::UndefinedValue(KclErrorDetails {
message: format!("memory item key `{}` is not defined", key),
source_ranges: vec![source_range],
})
})
}
}
impl Default for ProgramMemory {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, ts_rs::TS, JsonSchema)]
#[ts(export)]
#[serde(rename_all = "camelCase", untagged)]
pub enum ProgramReturn {
Arguments(Vec<Value>),
Value(MemoryItem),
}
impl From<ProgramReturn> for Vec<SourceRange> {
fn from(item: ProgramReturn) -> Self {
match item {
ProgramReturn::Arguments(args) => args
.iter()
.map(|arg| {
let r: SourceRange = arg.into();
r
})
.collect(),
ProgramReturn::Value(v) => v.into(),
}
}
}
impl ProgramReturn {
pub fn get_value(&self) -> Result<MemoryItem, KclError> {
match self {
ProgramReturn::Value(v) => Ok(v.clone()),
ProgramReturn::Arguments(args) => Err(KclError::Semantic(KclErrorDetails {
message: format!("Cannot get value from arguments: {:?}", args),
source_ranges: self.clone().into(),
})),
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, ts_rs::TS, JsonSchema)]
#[ts(export)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum MemoryItem {
UserVal {
value: serde_json::Value,
#[serde(rename = "__meta")]
meta: Vec<Metadata>,
},
SketchGroup(SketchGroup),
ExtrudeGroup(ExtrudeGroup),
ExtrudeTransform(ExtrudeTransform),
Function {
#[serde(skip)]
func: Option<MemoryFunction>,
expression: Box<FunctionExpression>,
#[serde(rename = "__meta")]
meta: Vec<Metadata>,
},
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, ts_rs::TS, JsonSchema)]
#[ts(export)]
#[serde(rename_all = "camelCase")]
pub struct ExtrudeTransform {
pub position: Position,
pub rotation: Rotation,
#[serde(rename = "__meta")]
pub meta: Vec<Metadata>,
}
pub type MemoryFunction = fn(
s: &[MemoryItem],
memory: &ProgramMemory,
expression: &FunctionExpression,
metadata: &[Metadata],
engine: &mut EngineConnection,
) -> Result<Option<ProgramReturn>, KclError>;
impl From<MemoryItem> for Vec<SourceRange> {
fn from(item: MemoryItem) -> Self {
match item {
MemoryItem::UserVal { meta, .. } => meta.iter().map(|m| m.source_range).collect(),
MemoryItem::SketchGroup(s) => s.meta.iter().map(|m| m.source_range).collect(),
MemoryItem::ExtrudeGroup(e) => e.meta.iter().map(|m| m.source_range).collect(),
MemoryItem::ExtrudeTransform(e) => e.meta.iter().map(|m| m.source_range).collect(),
MemoryItem::Function { meta, .. } => meta.iter().map(|m| m.source_range).collect(),
}
}
}
impl MemoryItem {
pub fn get_json_value(&self) -> Result<serde_json::Value, KclError> {
if let MemoryItem::UserVal { value, .. } = self {
Ok(value.clone())
} else {
Err(KclError::Semantic(KclErrorDetails {
message: format!("Not a user value: {:?}", self),
source_ranges: self.clone().into(),
}))
}
}
pub fn call_fn(
&self,
args: &[MemoryItem],
memory: &ProgramMemory,
engine: &mut EngineConnection,
) -> Result<Option<ProgramReturn>, KclError> {
if let MemoryItem::Function { func, expression, meta } = self {
if let Some(func) = func {
func(args, memory, expression, meta, engine)
} else {
Err(KclError::Semantic(KclErrorDetails {
message: format!("Not a function: {:?}", self),
source_ranges: vec![],
}))
}
} else {
Err(KclError::Semantic(KclErrorDetails {
message: format!("not a function: {:?}", self),
source_ranges: vec![],
}))
}
}
}
/// A sketch group is a collection of paths.
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, ts_rs::TS, JsonSchema)]
#[ts(export)]
#[serde(rename_all = "camelCase")]
pub struct SketchGroup {
/// The id of the sketch group.
pub id: uuid::Uuid,
/// The paths in the sketch group.
pub value: Vec<Path>,
/// The starting path.
pub start: BasePath,
/// The position of the sketch group.
pub position: Position,
/// The rotation of the sketch group.
pub rotation: Rotation,
/// Metadata.
#[serde(rename = "__meta")]
pub meta: Vec<Metadata>,
}
impl SketchGroup {
pub fn get_path_by_id(&self, id: &uuid::Uuid) -> Option<&Path> {
self.value.iter().find(|p| p.get_id() == *id)
}
pub fn get_path_by_name(&self, name: &str) -> Option<&Path> {
self.value.iter().find(|p| p.get_name() == name)
}
pub fn get_base_by_name_or_start(&self, name: &str) -> Option<&BasePath> {
if self.start.name == name {
Some(&self.start)
} else {
self.value.iter().find(|p| p.get_name() == name).map(|p| p.get_base())
}
}
pub fn get_coords_from_paths(&self) -> Result<Point2d, KclError> {
if self.value.is_empty() {
return Ok(self.start.to.into());
}
let index = self.value.len() - 1;
if let Some(path) = self.value.get(index) {
let base = path.get_base();
Ok(base.to.into())
} else {
Ok(self.start.to.into())
}
}
}
/// An extrude group is a collection of extrude surfaces.
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, ts_rs::TS, JsonSchema)]
#[ts(export)]
#[serde(rename_all = "camelCase")]
pub struct ExtrudeGroup {
/// The id of the extrude group.
pub id: uuid::Uuid,
/// The extrude surfaces.
pub value: Vec<ExtrudeSurface>,
/// The height of the extrude group.
pub height: f64,
/// The position of the extrude group.
pub position: Position,
/// The rotation of the extrude group.
pub rotation: Rotation,
/// Metadata.
#[serde(rename = "__meta")]
pub meta: Vec<Metadata>,
}
impl ExtrudeGroup {
pub fn get_path_by_id(&self, id: &uuid::Uuid) -> Option<&ExtrudeSurface> {
self.value.iter().find(|p| p.get_id() == *id)
}
pub fn get_path_by_name(&self, name: &str) -> Option<&ExtrudeSurface> {
self.value.iter().find(|p| p.get_name() == name)
}
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, ts_rs::TS, JsonSchema)]
#[ts(export)]
#[serde(rename_all = "camelCase")]
pub enum BodyType {
Root,
Sketch,
Block,
}
#[derive(Debug, Deserialize, Serialize, PartialEq, Copy, Clone, ts_rs::TS, JsonSchema)]
#[ts(export)]
pub struct Position(pub [f64; 3]);
#[derive(Debug, Deserialize, Serialize, PartialEq, Copy, Clone, ts_rs::TS, JsonSchema)]
#[ts(export)]
pub struct Rotation(pub [f64; 4]);
#[derive(Debug, Default, Deserialize, Serialize, PartialEq, Copy, Clone, ts_rs::TS, JsonSchema)]
#[ts(export)]
pub struct SourceRange(pub [usize; 2]);
#[derive(Debug, Deserialize, Serialize, PartialEq, Clone, ts_rs::TS, JsonSchema)]
#[ts(export)]
pub struct Point2d {
pub x: f64,
pub y: f64,
}
impl From<[f64; 2]> for Point2d {
fn from(p: [f64; 2]) -> Self {
Self { x: p[0], y: p[1] }
}
}
impl From<&[f64; 2]> for Point2d {
fn from(p: &[f64; 2]) -> Self {
Self { x: p[0], y: p[1] }
}
}
impl From<Point2d> for [f64; 2] {
fn from(p: Point2d) -> Self {
[p.x, p.y]
}
}
impl From<Point2d> for kittycad::types::Point2D {
fn from(p: Point2d) -> Self {
Self { x: p.x, y: p.y }
}
}
#[derive(Debug, Deserialize, Serialize, PartialEq, Clone, ts_rs::TS, JsonSchema)]
#[ts(export)]
pub struct Point3d {
pub x: f64,
pub y: f64,
pub z: f64,
}
/// Metadata.
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, ts_rs::TS, JsonSchema)]
#[ts(export)]
#[serde(rename_all = "camelCase")]
pub struct Metadata {
/// The source range.
pub source_range: SourceRange,
}
impl From<SourceRange> for Metadata {
fn from(source_range: SourceRange) -> Self {
Self { source_range }
}
}
/// A base path.
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, ts_rs::TS, JsonSchema)]
#[ts(export)]
#[serde(rename_all = "camelCase")]
pub struct BasePath {
/// The from point.
pub from: [f64; 2],
/// The to point.
pub to: [f64; 2],
/// The name of the path.
pub name: String,
/// Metadata.
#[serde(rename = "__geoMeta")]
pub geo_meta: GeoMeta,
}
/// Geometry metadata.
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, ts_rs::TS, JsonSchema)]
#[ts(export)]
#[serde(rename_all = "camelCase")]
pub struct GeoMeta {
/// The id of the geometry.
pub id: uuid::Uuid,
/// Metadata.
#[serde(flatten)]
pub metadata: Metadata,
}
/// A path.
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, ts_rs::TS, JsonSchema)]
#[ts(export)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum Path {
/// A path that goes to a point.
ToPoint {
#[serde(flatten)]
base: BasePath,
},
/// A path that is horizontal.
Horizontal {
#[serde(flatten)]
base: BasePath,
/// The x coordinate.
x: f64,
},
/// An angled line to.
AngledLineTo {
#[serde(flatten)]
base: BasePath,
/// The x coordinate.
x: Option<f64>,
/// The y coordinate.
y: Option<f64>,
},
/// A base path.
Base {
#[serde(flatten)]
base: BasePath,
},
}
impl Path {
pub fn get_id(&self) -> uuid::Uuid {
match self {
Path::ToPoint { base } => base.geo_meta.id,
Path::Horizontal { base, .. } => base.geo_meta.id,
Path::AngledLineTo { base, .. } => base.geo_meta.id,
Path::Base { base } => base.geo_meta.id,
}
}
pub fn get_name(&self) -> String {
match self {
Path::ToPoint { base } => base.name.clone(),
Path::Horizontal { base, .. } => base.name.clone(),
Path::AngledLineTo { base, .. } => base.name.clone(),
Path::Base { base } => base.name.clone(),
}
}
pub fn get_base(&self) -> &BasePath {
match self {
Path::ToPoint { base } => base,
Path::Horizontal { base, .. } => base,
Path::AngledLineTo { base, .. } => base,
Path::Base { base } => base,
}
}
}
/// An extrude surface.
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, ts_rs::TS, JsonSchema)]
#[ts(export)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum ExtrudeSurface {
/// An extrude plane.
ExtrudePlane {
/// The position.
position: Position,
/// The rotation.
rotation: Rotation,
/// The name.
name: String,
/// Metadata.
#[serde(flatten)]
geo_meta: GeoMeta,
},
}
impl ExtrudeSurface {
pub fn get_id(&self) -> uuid::Uuid {
match self {
ExtrudeSurface::ExtrudePlane { geo_meta, .. } => geo_meta.id,
}
}
pub fn get_name(&self) -> String {
match self {
ExtrudeSurface::ExtrudePlane { name, .. } => name.clone(),
}
}
pub fn get_position(&self) -> Position {
match self {
ExtrudeSurface::ExtrudePlane { position, .. } => *position,
}
}
pub fn get_rotation(&self) -> Rotation {
match self {
ExtrudeSurface::ExtrudePlane { rotation, .. } => *rotation,
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, ts_rs::TS, JsonSchema)]
#[ts(export)]
#[serde(rename_all = "camelCase")]
pub struct PipeInfo {
pub previous_results: Vec<MemoryItem>,
pub is_in_pipe: bool,
pub index: usize,
pub body: Vec<Value>,
}
impl PipeInfo {
pub fn new() -> Self {
Self {
previous_results: Vec::new(),
is_in_pipe: false,
index: 0,
body: Vec::new(),
}
}
}
impl Default for PipeInfo {
fn default() -> Self {
Self::new()
}
}
/// Execute a AST's program.
pub fn execute(
program: crate::abstract_syntax_tree_types::Program,
memory: &mut ProgramMemory,
options: BodyType,
engine: &mut EngineConnection,
) -> Result<ProgramMemory, KclError> {
let mut pipe_info = PipeInfo::default();
let stdlib = crate::std::StdLib::new();
// Iterate over the body of the program.
for statement in &program.body {
match statement {
BodyItem::ExpressionStatement(expression_statement) => {
if let Value::CallExpression(call_expr) = &expression_statement.expression {
let fn_name = call_expr.callee.name.to_string();
let mut args: Vec<MemoryItem> = Vec::new();
for arg in &call_expr.arguments {
match arg {
Value::Literal(literal) => args.push(literal.into()),
Value::Identifier(identifier) => {
let memory_item = memory.get(&identifier.name, identifier.into())?;
args.push(memory_item.clone());
}
// We do nothing for the rest.
_ => (),
}
}
if fn_name == "show" {
if options != BodyType::Root {
return Err(KclError::Semantic(KclErrorDetails {
message: "Cannot call show outside of a root".to_string(),
source_ranges: vec![call_expr.into()],
}));
}
memory.return_ = Some(ProgramReturn::Arguments(call_expr.arguments.clone()));
} else if let Some(func) = memory.clone().root.get(&fn_name) {
func.call_fn(&args, memory, engine)?;
} else {
return Err(KclError::Semantic(KclErrorDetails {
message: format!("No such name {} defined", fn_name),
source_ranges: vec![call_expr.into()],
}));
}
}
}
BodyItem::VariableDeclaration(variable_declaration) => {
for declaration in &variable_declaration.declarations {
let var_name = declaration.id.name.to_string();
let source_range: SourceRange = declaration.init.clone().into();
let metadata = Metadata { source_range };
match &declaration.init {
Value::Literal(literal) => {
memory.add(&var_name, literal.into(), source_range)?;
}
Value::Identifier(identifier) => {
let value = memory.get(&identifier.name, identifier.into())?;
memory.add(&var_name, value.clone(), source_range)?;
}
Value::BinaryExpression(binary_expression) => {
let result = binary_expression.get_result(memory, &mut pipe_info, &stdlib, engine)?;
memory.add(&var_name, result, source_range)?;
}
Value::FunctionExpression(function_expression) => {
memory.add(
&var_name,
MemoryItem::Function{
expression: function_expression.clone(),
meta: vec![metadata],
func: Some(|args: &[MemoryItem], memory: &ProgramMemory, function_expression: &FunctionExpression, _metadata: &[Metadata], engine: &mut EngineConnection| -> Result<Option<ProgramReturn>, KclError> {
let mut fn_memory = memory.clone();
if args.len() != function_expression.params.len() {
return Err(KclError::Semantic(KclErrorDetails {
message: format!("Expected {} arguments, got {}", function_expression.params.len(), args.len()),
source_ranges: vec![function_expression.into()],
}));
}
// Add the arguments to the memory.
for (index, param) in function_expression.params.iter().enumerate() {
fn_memory.add(
&param.name,
args.clone().get(index).unwrap().clone(),
param.into(),
)?;
}
let result = execute(function_expression.body.clone(), &mut fn_memory, BodyType::Block, engine)?;
Ok(result.return_)
})
},
source_range,
)?;
}
Value::CallExpression(call_expression) => {
let result = call_expression.execute(memory, &mut pipe_info, &stdlib, engine)?;
memory.add(&var_name, result, source_range)?;
}
Value::PipeExpression(pipe_expression) => {
let result = pipe_expression.get_result(memory, &mut pipe_info, &stdlib, engine)?;
memory.add(&var_name, result, source_range)?;
}
Value::PipeSubstitution(pipe_substitution) => {
return Err(KclError::Semantic(KclErrorDetails {
message: format!(
"pipe substitution not implemented for declaration of variable {}",
var_name
),
source_ranges: vec![pipe_substitution.into()],
}));
}
Value::ArrayExpression(array_expression) => {
let result = array_expression.execute(memory, &mut pipe_info, &stdlib, engine)?;
memory.add(&var_name, result, source_range)?;
}
Value::ObjectExpression(object_expression) => {
let result = object_expression.execute(memory, &mut pipe_info, &stdlib, engine)?;
memory.add(&var_name, result, source_range)?;
}
Value::MemberExpression(member_expression) => {
let result = member_expression.get_result(memory)?;
memory.add(&var_name, result, source_range)?;
}
Value::UnaryExpression(unary_expression) => {
let result = unary_expression.get_result(memory, &mut pipe_info, &stdlib, engine)?;
memory.add(&var_name, result, source_range)?;
}
}
}
}
BodyItem::ReturnStatement(return_statement) => match &return_statement.argument {
Value::BinaryExpression(bin_expr) => {
let result = bin_expr.get_result(memory, &mut pipe_info, &stdlib, engine)?;
memory.return_ = Some(ProgramReturn::Value(result));
}
Value::Identifier(identifier) => {
let value = memory.get(&identifier.name, identifier.into())?.clone();
memory.return_ = Some(ProgramReturn::Value(value));
}
_ => (),
},
}
}
Ok(memory.clone())
}
#[cfg(test)]
mod tests {
use pretty_assertions::assert_eq;
use super::*;
pub async fn parse_execute(code: &str) -> Result<ProgramMemory> {
let tokens = crate::tokeniser::lexer(code);
let program = crate::parser::abstract_syntax_tree(&tokens)?;
let mut mem: ProgramMemory = Default::default();
let mut engine = EngineConnection::new().await?;
let memory = execute(program, &mut mem, BodyType::Root, &mut engine)?;
Ok(memory)
}
#[tokio::test(flavor = "multi_thread")]
async fn test_execute_assign_two_variables() {
let ast = r#"const myVar = 5
const newVar = myVar + 1"#;
let memory = parse_execute(ast).await.unwrap();
assert_eq!(
serde_json::json!(5),
memory.root.get("myVar").unwrap().get_json_value().unwrap()
);
assert_eq!(
serde_json::json!(6.0),
memory.root.get("newVar").unwrap().get_json_value().unwrap()
);
}
#[tokio::test(flavor = "multi_thread")]
async fn test_execute_angled_line_that_intersects() {
let ast_fn = |offset: &str| -> String {
format!(
r#"const part001 = startSketchAt([0, 0])
|> lineTo({{to:[2, 2], tag: "yo"}}, %)
|> lineTo([3, 1], %)
|> angledLineThatIntersects({{
angle: 180,
intersectTag: 'yo',
offset: {},
tag: "yo2"
}}, %)
const intersect = segEndX('yo2', part001)
show(part001)"#,
offset
)
};
let memory = parse_execute(&ast_fn("-1")).await.unwrap();
assert_eq!(
serde_json::json!(1.0 + 2.0f64.sqrt()),
memory.root.get("intersect").unwrap().get_json_value().unwrap()
);
let memory = parse_execute(&ast_fn("0")).await.unwrap();
assert_eq!(
serde_json::json!(1.0000000000000002),
memory.root.get("intersect").unwrap().get_json_value().unwrap()
);
}
#[tokio::test(flavor = "multi_thread")]
async fn test_execute_fn_definitions() {
let ast = r#"const def = (x) => {
return x
}
const ghi = (x) => {
return x
}
const jkl = (x) => {
return x
}
const hmm = (x) => {
return x
}
const yo = 5 + 6
const abc = 3
const identifierGuy = 5
const part001 = startSketchAt([-1.2, 4.83])
|> line([2.8, 0], %)
|> angledLine([100 + 100, 3.01], %)
|> angledLine([abc, 3.02], %)
|> angledLine([def(yo), 3.03], %)
|> angledLine([ghi(2), 3.04], %)
|> angledLine([jkl(yo) + 2, 3.05], %)
|> close(%)
const yo2 = hmm([identifierGuy + 5])
show(part001)"#;
parse_execute(ast).await.unwrap();
}
#[tokio::test(flavor = "multi_thread")]
async fn test_execute_with_pipe_substitutions_unary() {
let ast = r#"const myVar = 3
const part001 = startSketchAt([0, 0])
|> line({ to: [3, 4], tag: 'seg01' }, %)
|> line([
min(segLen('seg01', %), myVar),
-legLen(segLen('seg01', %), myVar)
], %)
show(part001)"#;
parse_execute(ast).await.unwrap();
}
#[tokio::test(flavor = "multi_thread")]
async fn test_execute_with_pipe_substitutions() {
let ast = r#"const myVar = 3
const part001 = startSketchAt([0, 0])
|> line({ to: [3, 4], tag: 'seg01' }, %)
|> line([
min(segLen('seg01', %), myVar),
legLen(segLen('seg01', %), myVar)
], %)
show(part001)"#;
parse_execute(ast).await.unwrap();
}
}

View File

@ -0,0 +1,10 @@
pub mod abstract_syntax_tree_types;
pub mod docs;
pub mod engine;
pub mod errors;
pub mod executor;
pub mod math_parser;
pub mod parser;
pub mod recast;
pub mod std;
pub mod tokeniser;

View File

@ -1,12 +1,13 @@
use anyhow::Result;
use serde::{Deserialize, Serialize};
use crate::abstract_syntax_tree_types::{
BinaryExpression, BinaryPart, CallExpression, Identifier, Literal,
use crate::{
abstract_syntax_tree_types::{BinaryExpression, BinaryPart, CallExpression, Identifier, Literal, ValueMeta},
errors::{KclError, KclErrorDetails},
executor::SourceRange,
parser::{find_closing_brace, is_not_code_token, make_call_expression},
tokeniser::{Token, TokenType},
};
use crate::errors::{KclError, KclErrorDetails};
use crate::parser::{find_closing_brace, is_not_code_token, make_call_expression};
use crate::tokeniser::{Token, TokenType};
pub fn precedence(operator: &str) -> u8 {
// might be useful for reference to make it match
@ -147,7 +148,7 @@ pub fn reverse_polish_notation(
}
Err(KclError::Syntax(KclErrorDetails {
source_ranges: vec![[current_token.start as i32, current_token.end as i32]],
source_ranges: vec![current_token.into()],
message: format!(
"Unexpected token: {} {:?}",
current_token.value, current_token.token_type
@ -182,6 +183,8 @@ pub struct ParenthesisToken {
pub end: usize,
}
crate::abstract_syntax_tree_types::impl_value_meta!(ParenthesisToken);
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, ts_rs::TS)]
#[ts(export)]
#[serde(tag = "type")]
@ -195,10 +198,12 @@ pub struct ExtendedBinaryExpression {
pub end_extended: Option<usize>,
}
crate::abstract_syntax_tree_types::impl_value_meta!(ExtendedBinaryExpression);
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, ts_rs::TS)]
#[ts(export)]
#[serde(tag = "type")]
pub struct ExntendedLiteral {
pub struct ExtendedLiteral {
pub start: usize,
pub end: usize,
pub value: serde_json::Value,
@ -207,11 +212,13 @@ pub struct ExntendedLiteral {
pub end_extended: Option<usize>,
}
crate::abstract_syntax_tree_types::impl_value_meta!(ExtendedLiteral);
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, ts_rs::TS)]
#[ts(export)]
#[serde(tag = "type")]
pub enum MathExpression {
ExntendedLiteral(Box<ExntendedLiteral>),
ExtendedLiteral(Box<ExtendedLiteral>),
Identifier(Box<Identifier>),
CallExpression(Box<CallExpression>),
BinaryExpression(Box<BinaryExpression>),
@ -219,6 +226,30 @@ pub enum MathExpression {
ParenthesisToken(Box<ParenthesisToken>),
}
impl MathExpression {
pub fn start(&self) -> usize {
match self {
MathExpression::ExtendedLiteral(literal) => literal.start(),
MathExpression::Identifier(identifier) => identifier.start(),
MathExpression::CallExpression(call_expression) => call_expression.start(),
MathExpression::BinaryExpression(binary_expression) => binary_expression.start(),
MathExpression::ExtendedBinaryExpression(extended_binary_expression) => extended_binary_expression.start(),
MathExpression::ParenthesisToken(parenthesis_token) => parenthesis_token.start(),
}
}
pub fn end(&self) -> usize {
match self {
MathExpression::ExtendedLiteral(literal) => literal.end(),
MathExpression::Identifier(identifier) => identifier.end(),
MathExpression::CallExpression(call_expression) => call_expression.end(),
MathExpression::BinaryExpression(binary_expression) => binary_expression.end(),
MathExpression::ExtendedBinaryExpression(extended_binary_expression) => extended_binary_expression.end(),
MathExpression::ParenthesisToken(parenthesis_token) => parenthesis_token.end(),
}
}
}
fn build_tree(
reverse_polish_notation_tokens: &[Token],
stack: Vec<MathExpression>,
@ -241,86 +272,76 @@ fn build_tree(
}),
a => {
return Err(KclError::InvalidExpression(a.clone()));
return Err(KclError::InvalidExpression(KclErrorDetails {
source_ranges: vec![SourceRange([a.start(), a.end()])],
message: format!("{:?}", a),
}))
}
};
}
let current_token = &reverse_polish_notation_tokens[0];
if current_token.token_type == TokenType::Number
|| current_token.token_type == TokenType::String
{
if current_token.token_type == TokenType::Number || current_token.token_type == TokenType::String {
let mut new_stack = stack;
new_stack.push(MathExpression::ExntendedLiteral(Box::new(
ExntendedLiteral {
value: if current_token.token_type == TokenType::Number {
if let Ok(value) = current_token.value.parse::<i64>() {
serde_json::Value::Number(value.into())
} else if let Ok(value) = current_token.value.parse::<f64>() {
if let Some(n) = serde_json::Number::from_f64(value) {
serde_json::Value::Number(n)
} else {
return Err(KclError::Syntax(KclErrorDetails {
source_ranges: vec![[
current_token.start as i32,
current_token.end as i32,
]],
message: format!("Invalid float: {}", current_token.value),
}));
}
new_stack.push(MathExpression::ExtendedLiteral(Box::new(ExtendedLiteral {
value: if current_token.token_type == TokenType::Number {
if let Ok(value) = current_token.value.parse::<i64>() {
serde_json::Value::Number(value.into())
} else if let Ok(value) = current_token.value.parse::<f64>() {
if let Some(n) = serde_json::Number::from_f64(value) {
serde_json::Value::Number(n)
} else {
return Err(KclError::Syntax(KclErrorDetails {
source_ranges: vec![[
current_token.start as i32,
current_token.end as i32,
]],
message: format!("Invalid integer: {}", current_token.value),
source_ranges: vec![current_token.into()],
message: format!("Invalid float: {}", current_token.value),
}));
}
} else {
let mut str_val = current_token.value.clone();
str_val.remove(0);
str_val.pop();
serde_json::Value::String(str_val)
},
start: current_token.start,
end: current_token.end,
raw: current_token.value.clone(),
end_extended: None,
start_extended: None,
return Err(KclError::Syntax(KclErrorDetails {
source_ranges: vec![current_token.into()],
message: format!("Invalid integer: {}", current_token.value),
}));
}
} else {
let mut str_val = current_token.value.clone();
str_val.remove(0);
str_val.pop();
serde_json::Value::String(str_val)
},
)));
return build_tree(&reverse_polish_notation_tokens[1..], new_stack);
} else if current_token.token_type == TokenType::Word {
if reverse_polish_notation_tokens[1].token_type == TokenType::Brace
&& reverse_polish_notation_tokens[1].value == "("
{
let closing_brace = find_closing_brace(reverse_polish_notation_tokens, 1, 0, "")?;
let mut new_stack = stack;
new_stack.push(MathExpression::CallExpression(Box::new(
make_call_expression(reverse_polish_notation_tokens, 0)?.expression,
)));
return build_tree(
&reverse_polish_notation_tokens[closing_brace + 1..],
new_stack,
);
}
let mut new_stack = stack;
new_stack.push(MathExpression::Identifier(Box::new(Identifier {
name: current_token.value.clone(),
start: current_token.start,
end: current_token.end,
raw: current_token.value.clone(),
end_extended: None,
start_extended: None,
})));
return build_tree(&reverse_polish_notation_tokens[1..], new_stack);
} else if current_token.token_type == TokenType::Brace && current_token.value == "(" {
let mut new_stack = stack;
new_stack.push(MathExpression::ParenthesisToken(Box::new(
ParenthesisToken {
value: "(".to_string(),
} else if current_token.token_type == TokenType::Word {
if reverse_polish_notation_tokens.len() > 1 {
if reverse_polish_notation_tokens[1].token_type == TokenType::Brace
&& reverse_polish_notation_tokens[1].value == "("
{
let closing_brace = find_closing_brace(reverse_polish_notation_tokens, 1, 0, "")?;
let mut new_stack = stack;
new_stack.push(MathExpression::CallExpression(Box::new(
make_call_expression(reverse_polish_notation_tokens, 0)?.expression,
)));
return build_tree(&reverse_polish_notation_tokens[closing_brace + 1..], new_stack);
}
let mut new_stack = stack;
new_stack.push(MathExpression::Identifier(Box::new(Identifier {
name: current_token.value.clone(),
start: current_token.start,
end: current_token.end,
token_type: MathTokenType::Parenthesis,
},
)));
})));
return build_tree(&reverse_polish_notation_tokens[1..], new_stack);
}
} else if current_token.token_type == TokenType::Brace && current_token.value == "(" {
let mut new_stack = stack;
new_stack.push(MathExpression::ParenthesisToken(Box::new(ParenthesisToken {
value: "(".to_string(),
start: current_token.start,
end: current_token.end,
token_type: MathTokenType::Parenthesis,
})));
return build_tree(&reverse_polish_notation_tokens[1..], new_stack);
} else if current_token.token_type == TokenType::Brace && current_token.value == ")" {
let inner_node: MathExpression = match &stack[stack.len() - 1] {
@ -346,14 +367,22 @@ fn build_tree(
end_extended: None,
}))
}
MathExpression::ExntendedLiteral(literal) => {
MathExpression::ExntendedLiteral(literal.clone())
MathExpression::ExtendedLiteral(literal) => MathExpression::ExtendedLiteral(literal.clone()),
a => {
return Err(KclError::InvalidExpression(KclErrorDetails {
source_ranges: vec![current_token.into()],
message: format!("{:?}", a),
}))
}
a => return Err(KclError::InvalidExpression(a.clone())),
};
let paran = match &stack[stack.len() - 2] {
MathExpression::ParenthesisToken(paran) => paran.clone(),
a => return Err(KclError::InvalidExpression(a.clone())),
a => {
return Err(KclError::InvalidExpression(KclErrorDetails {
source_ranges: vec![current_token.into()],
message: format!("{:?}", a),
}))
}
};
let expression = match inner_node {
MathExpression::ExtendedBinaryExpression(bin_exp) => {
@ -378,22 +407,33 @@ fn build_tree(
end_extended: Some(current_token.end),
}))
}
MathExpression::ExntendedLiteral(literal) => {
MathExpression::ExntendedLiteral(Box::new(ExntendedLiteral {
value: literal.value.clone(),
start: literal.start,
end: literal.end,
raw: literal.raw.clone(),
end_extended: Some(current_token.end),
start_extended: Some(paran.start),
MathExpression::ExtendedLiteral(literal) => MathExpression::ExtendedLiteral(Box::new(ExtendedLiteral {
value: literal.value.clone(),
start: literal.start,
end: literal.end,
raw: literal.raw.clone(),
end_extended: Some(current_token.end),
start_extended: Some(paran.start),
})),
a => {
return Err(KclError::InvalidExpression(KclErrorDetails {
source_ranges: vec![current_token.into()],
message: format!("{:?}", a),
}))
}
a => return Err(KclError::InvalidExpression(a.clone())),
};
let mut new_stack = stack[0..stack.len() - 2].to_vec();
new_stack.push(expression);
return build_tree(&reverse_polish_notation_tokens[1..], new_stack);
}
if stack.len() < 2 {
return Err(KclError::Syntax(KclErrorDetails {
source_ranges: vec![current_token.into()],
message: "unexpected end of expression".to_string(),
}));
}
let left: (BinaryPart, usize) = match &stack[stack.len() - 2] {
MathExpression::ExtendedBinaryExpression(bin_exp) => (
BinaryPart::BinaryExpression(Box::new(BinaryExpression {
@ -405,7 +445,7 @@ fn build_tree(
})),
bin_exp.start_extended.unwrap_or(bin_exp.start),
),
MathExpression::ExntendedLiteral(lit) => (
MathExpression::ExtendedLiteral(lit) => (
BinaryPart::Literal(Box::new(Literal {
value: lit.value.clone(),
start: lit.start,
@ -415,13 +455,14 @@ fn build_tree(
lit.start_extended.unwrap_or(lit.start),
),
MathExpression::Identifier(ident) => (BinaryPart::Identifier(ident.clone()), ident.start),
MathExpression::CallExpression(call) => {
(BinaryPart::CallExpression(call.clone()), call.start)
MathExpression::CallExpression(call) => (BinaryPart::CallExpression(call.clone()), call.start),
MathExpression::BinaryExpression(bin_exp) => (BinaryPart::BinaryExpression(bin_exp.clone()), bin_exp.start),
a => {
return Err(KclError::InvalidExpression(KclErrorDetails {
source_ranges: vec![current_token.into()],
message: format!("{:?}", a),
}))
}
MathExpression::BinaryExpression(bin_exp) => {
(BinaryPart::BinaryExpression(bin_exp.clone()), bin_exp.start)
}
a => return Err(KclError::InvalidExpression(a.clone())),
};
let right = match &stack[stack.len() - 1] {
MathExpression::ExtendedBinaryExpression(bin_exp) => (
@ -434,7 +475,7 @@ fn build_tree(
})),
bin_exp.end_extended.unwrap_or(bin_exp.end),
),
MathExpression::ExntendedLiteral(lit) => (
MathExpression::ExtendedLiteral(lit) => (
BinaryPart::Literal(Box::new(Literal {
value: lit.value.clone(),
start: lit.start,
@ -444,13 +485,14 @@ fn build_tree(
lit.end_extended.unwrap_or(lit.end),
),
MathExpression::Identifier(ident) => (BinaryPart::Identifier(ident.clone()), ident.end),
MathExpression::CallExpression(call) => {
(BinaryPart::CallExpression(call.clone()), call.end)
MathExpression::CallExpression(call) => (BinaryPart::CallExpression(call.clone()), call.end),
MathExpression::BinaryExpression(bin_exp) => (BinaryPart::BinaryExpression(bin_exp.clone()), bin_exp.end),
a => {
return Err(KclError::InvalidExpression(KclErrorDetails {
source_ranges: vec![current_token.into()],
message: format!("{:?}", a),
}))
}
MathExpression::BinaryExpression(bin_exp) => {
(BinaryPart::BinaryExpression(bin_exp.clone()), bin_exp.end)
}
a => return Err(KclError::InvalidExpression(a.clone())),
};
let right_end = match right.0.clone() {
@ -464,11 +506,7 @@ fn build_tree(
let tree = BinaryExpression {
operator: current_token.value.clone(),
start: left.1,
end: if right.1 > right_end {
right.1
} else {
right_end
},
end: if right.1 > right_end { right.1 } else { right_end },
left: left.0,
right: right.0,
};
@ -516,9 +554,10 @@ pub fn parse_expression(tokens: &[Token]) -> Result<BinaryExpression, KclError>
#[cfg(test)]
mod test {
use super::*;
use pretty_assertions::assert_eq;
use super::*;
#[test]
fn test_parse_expression() {
let tokens = crate::tokeniser::lexer("1 + 2");
@ -839,8 +878,7 @@ mod test {
#[test]
fn test_reverse_polish_notation_complex() {
let result =
reverse_polish_notation(&crate::tokeniser::lexer("1 + 2 * 3"), &[], &[]).unwrap();
let result = reverse_polish_notation(&crate::tokeniser::lexer("1 + 2 * 3"), &[], &[]).unwrap();
assert_eq!(
result,
vec![
@ -880,8 +918,7 @@ mod test {
#[test]
fn test_reverse_polish_notation_complex_with_parentheses() {
let result =
reverse_polish_notation(&crate::tokeniser::lexer("1 * ( 2 + 3 )"), &[], &[]).unwrap();
let result = reverse_polish_notation(&crate::tokeniser::lexer("1 * ( 2 + 3 )"), &[], &[]).unwrap();
assert_eq!(
result,
vec![

File diff suppressed because it is too large Load Diff

View File

@ -1,20 +1,15 @@
//! Generates source code from the AST.
//! The inverse of parsing (which generates an AST from the source code)
use wasm_bindgen::prelude::*;
use crate::abstract_syntax_tree_types::{
ArrayExpression, BinaryExpression, BinaryPart, BodyItem, CallExpression, FunctionExpression,
Literal, LiteralIdentifier, MemberExpression, MemberObject, ObjectExpression, PipeExpression,
Program, UnaryExpression, Value,
ArrayExpression, BinaryExpression, BinaryPart, BodyItem, CallExpression, FunctionExpression, Literal,
LiteralIdentifier, MemberExpression, MemberObject, ObjectExpression, PipeExpression, Program, UnaryExpression,
Value,
};
fn recast_literal(literal: Literal) -> String {
if let serde_json::Value::String(value) = literal.value {
let quote = if literal.raw.trim().starts_with('"') {
'"'
} else {
'\''
};
let quote = if literal.raw.trim().starts_with('"') { '"' } else { '\'' };
format!("{}{}{}", quote, value, quote)
} else {
literal.value.to_string()
@ -40,16 +35,13 @@ fn recast_binary_expression(expression: BinaryExpression) -> String {
let should_wrap_right = match expression.right.clone() {
BinaryPart::BinaryExpression(bin_exp) => {
precedence(&expression.operator) > precedence(&bin_exp.operator)
|| expression.operator == "-"
precedence(&expression.operator) > precedence(&bin_exp.operator) || expression.operator == "-"
}
_ => false,
};
let should_wrap_left = match expression.left.clone() {
BinaryPart::BinaryExpression(bin_exp) => {
precedence(&expression.operator) > precedence(&bin_exp.operator)
}
BinaryPart::BinaryExpression(bin_exp) => precedence(&expression.operator) > precedence(&bin_exp.operator),
_ => false,
};
@ -65,12 +57,8 @@ fn recast_binary_part(part: BinaryPart) -> String {
match part {
BinaryPart::Literal(literal) => recast_literal(*literal),
BinaryPart::Identifier(identifier) => identifier.name,
BinaryPart::BinaryExpression(binary_expression) => {
recast_binary_expression(*binary_expression)
}
BinaryPart::CallExpression(call_expression) => {
recast_call_expression(&call_expression, "", false)
}
BinaryPart::BinaryExpression(binary_expression) => recast_binary_expression(*binary_expression),
BinaryPart::CallExpression(call_expression) => recast_call_expression(&call_expression, "", false),
_ => String::new(),
}
}
@ -80,15 +68,11 @@ fn recast_value(node: Value, _indentation: String, is_in_pipe_expression: bool)
match node {
Value::BinaryExpression(bin_exp) => recast_binary_expression(*bin_exp),
Value::ArrayExpression(array_exp) => recast_array_expression(&array_exp, &indentation),
Value::ObjectExpression(ref obj_exp) => {
recast_object_expression(obj_exp, &indentation, is_in_pipe_expression)
}
Value::ObjectExpression(ref obj_exp) => recast_object_expression(obj_exp, &indentation, is_in_pipe_expression),
Value::MemberExpression(mem_exp) => recast_member_expression(*mem_exp),
Value::Literal(literal) => recast_literal(*literal),
Value::FunctionExpression(func_exp) => recast_function(*func_exp),
Value::CallExpression(call_exp) => {
recast_call_expression(&call_exp, &indentation, is_in_pipe_expression)
}
Value::CallExpression(call_exp) => recast_call_expression(&call_exp, &indentation, is_in_pipe_expression),
Value::Identifier(ident) => ident.name,
Value::PipeExpression(pipe_exp) => recast_pipe_expression(&pipe_exp),
Value::UnaryExpression(unary_exp) => recast_unary_expression(*unary_exp),
@ -125,11 +109,7 @@ fn recast_array_expression(expression: &ArrayExpression, indentation: &str) -> S
}
}
fn recast_object_expression(
expression: &ObjectExpression,
indentation: &str,
is_in_pipe_expression: bool,
) -> String {
fn recast_object_expression(expression: &ObjectExpression, indentation: &str, is_in_pipe_expression: bool) -> String {
let flat_recast = format!(
"{{ {} }}",
expression
@ -158,11 +138,7 @@ fn recast_object_expression(
format!(
"{}: {}",
prop.key.name,
recast_value(
prop.value.clone(),
_indentation.clone(),
is_in_pipe_expression
)
recast_value(prop.value.clone(), _indentation.clone(), is_in_pipe_expression)
)
})
.collect::<Vec<String>>()
@ -174,11 +150,7 @@ fn recast_object_expression(
}
}
fn recast_call_expression(
expression: &CallExpression,
indentation: &str,
is_in_pipe_expression: bool,
) -> String {
fn recast_call_expression(expression: &CallExpression, indentation: &str, is_in_pipe_expression: bool) -> String {
format!(
"{}({})",
expression.callee.name,
@ -200,9 +172,7 @@ fn recast_argument(argument: Value, indentation: &str, is_in_pipe_expression: bo
Value::ObjectExpression(object_exp) => {
recast_object_expression(&object_exp, indentation, is_in_pipe_expression)
}
Value::CallExpression(call_exp) => {
recast_call_expression(&call_exp, indentation, is_in_pipe_expression)
}
Value::CallExpression(call_exp) => recast_call_expression(&call_exp, indentation, is_in_pipe_expression),
Value::FunctionExpression(function_exp) => recast_function(*function_exp),
Value::PipeSubstitution(_) => "%".to_string(),
Value::UnaryExpression(unary_exp) => recast_unary_expression(*unary_exp),
@ -223,9 +193,7 @@ fn recast_member_expression(expression: MemberExpression) -> String {
};
match expression.object {
MemberObject::MemberExpression(member_exp) => {
recast_member_expression(*member_exp) + key_str.as_str()
}
MemberObject::MemberExpression(member_exp) => recast_member_expression(*member_exp) + key_str.as_str(),
MemberObject::Identifier(identifier) => identifier.name + key_str.as_str(),
}
}
@ -262,9 +230,7 @@ fn recast_unary_expression(expression: UnaryExpression) -> String {
let bin_part_val = match expression.argument {
BinaryPart::Literal(literal) => Value::Literal(literal),
BinaryPart::Identifier(identifier) => Value::Identifier(identifier),
BinaryPart::BinaryExpression(binary_expression) => {
Value::BinaryExpression(binary_expression)
}
BinaryPart::BinaryExpression(binary_expression) => Value::BinaryExpression(binary_expression),
BinaryPart::CallExpression(call_expression) => Value::CallExpression(call_expression),
BinaryPart::UnaryExpression(unary_expression) => Value::UnaryExpression(unary_expression),
};
@ -279,23 +245,13 @@ pub fn recast(ast: &Program, indentation: &str, is_with_block: bool) -> String {
ast.body
.iter()
.map(|statement| match statement.clone() {
BodyItem::ExpressionStatement(expression_statement) => {
match expression_statement.expression {
Value::BinaryExpression(binary_expression) => {
recast_binary_expression(*binary_expression)
}
Value::ArrayExpression(array_expression) => {
recast_array_expression(&array_expression, "")
}
Value::ObjectExpression(object_expression) => {
recast_object_expression(&object_expression, "", false)
}
Value::CallExpression(call_expression) => {
recast_call_expression(&call_expression, "", false)
}
_ => "Expression".to_string(),
}
}
BodyItem::ExpressionStatement(expression_statement) => match expression_statement.expression {
Value::BinaryExpression(binary_expression) => recast_binary_expression(*binary_expression),
Value::ArrayExpression(array_expression) => recast_array_expression(&array_expression, ""),
Value::ObjectExpression(object_expression) => recast_object_expression(&object_expression, "", false),
Value::CallExpression(call_expression) => recast_call_expression(&call_expression, "", false),
_ => "Expression".to_string(),
},
BodyItem::VariableDeclaration(variable_declaration) => variable_declaration
.declarations
.iter()
@ -309,22 +265,16 @@ pub fn recast(ast: &Program, indentation: &str, is_with_block: bool) -> String {
})
.collect::<String>(),
BodyItem::ReturnStatement(return_statement) => {
format!(
"return {}",
recast_argument(return_statement.argument, "", false)
)
format!("return {}", recast_argument(return_statement.argument, "", false))
}
})
.enumerate()
.map(|(index, recast_str)| {
let is_legit_custom_whitespace_or_comment =
|str: String| str != " " && str != "\n" && str != " ";
let is_legit_custom_whitespace_or_comment = |str: String| str != " " && str != "\n" && str != " ";
// determine the value of startString
let last_white_space_or_comment = if index > 0 {
let tmp = if let Some(non_code_node) =
ast.non_code_meta.none_code_nodes.get(&(index - 1))
{
let tmp = if let Some(non_code_node) = ast.non_code_meta.none_code_nodes.get(&(index - 1)) {
non_code_node.value.clone()
} else {
" ".to_string()
@ -334,12 +284,11 @@ pub fn recast(ast: &Program, indentation: &str, is_with_block: bool) -> String {
" ".to_string()
};
// indentation of this line will be covered by the previous if we're using a custom whitespace or comment
let mut start_string =
if is_legit_custom_whitespace_or_comment(last_white_space_or_comment) {
String::new()
} else {
indentation.to_owned()
};
let mut start_string = if is_legit_custom_whitespace_or_comment(last_white_space_or_comment) {
String::new()
} else {
indentation.to_owned()
};
if index == 0 {
if let Some(start) = ast.non_code_meta.start.clone() {
start_string = start.value;
@ -357,13 +306,10 @@ pub fn recast(ast: &Program, indentation: &str, is_with_block: bool) -> String {
} else {
"\n".to_string()
};
let mut custom_white_space_or_comment =
match ast.non_code_meta.none_code_nodes.get(&index) {
Some(custom_white_space_or_comment) => {
custom_white_space_or_comment.value.clone()
}
None => String::new(),
};
let mut custom_white_space_or_comment = match ast.non_code_meta.none_code_nodes.get(&index) {
Some(custom_white_space_or_comment) => custom_white_space_or_comment.value.clone(),
None => String::new(),
};
if !is_legit_custom_whitespace_or_comment(custom_white_space_or_comment.clone()) {
custom_white_space_or_comment = String::new();
}
@ -399,14 +345,3 @@ pub fn recast_function(expression: FunctionExpression) -> String {
)
)
}
// wasm_bindgen wrapper for recast
// test for this function and by extension the recaster are done in javascript land src/lang/recast.test.ts
#[wasm_bindgen]
pub fn recast_js(json_str: &str) -> Result<JsValue, JsError> {
// deserialize the ast from a stringified json
let program: Program = serde_json::from_str(json_str).map_err(JsError::from)?;
let result = recast(&program, "", false);
Ok(serde_wasm_bindgen::to_value(&result)?)
}

View File

@ -0,0 +1,79 @@
//! Functions related to extruding.
use anyhow::Result;
use derive_docs::stdlib;
use schemars::JsonSchema;
use crate::{
errors::{KclError, KclErrorDetails},
executor::{ExtrudeGroup, ExtrudeTransform, MemoryItem, SketchGroup},
std::Args,
};
/// Extrudes by a given amount.
pub fn extrude(args: &mut Args) -> Result<MemoryItem, KclError> {
let (length, sketch_group) = args.get_number_sketch_group()?;
let result = inner_extrude(length, sketch_group, args)?;
Ok(MemoryItem::ExtrudeGroup(result))
}
/// Extrudes by a given amount.
#[stdlib {
name = "extrude"
}]
fn inner_extrude(length: f64, sketch_group: SketchGroup, args: &mut Args) -> Result<ExtrudeGroup, KclError> {
let id = uuid::Uuid::new_v4();
let cmd = kittycad::types::ModelingCmd::Extrude {
target: sketch_group.id,
distance: length,
cap: true,
};
args.send_modeling_cmd(id, cmd)?;
Ok(ExtrudeGroup {
id,
// TODO, this is just an empty array now, should be deleted. This
// comment was originally in the JS code.
value: Default::default(),
height: length,
position: sketch_group.position,
rotation: sketch_group.rotation,
meta: sketch_group.meta,
})
}
/// Returns the extrude wall transform.
pub fn get_extrude_wall_transform(args: &mut Args) -> Result<MemoryItem, KclError> {
let (surface_name, extrude_group) = args.get_path_name_extrude_group()?;
let result = inner_get_extrude_wall_transform(&surface_name, extrude_group, args)?;
Ok(MemoryItem::ExtrudeTransform(result))
}
/// Returns the extrude wall transform.
#[stdlib {
name = "getExtrudeWallTransform"
}]
fn inner_get_extrude_wall_transform(
surface_name: &str,
extrude_group: ExtrudeGroup,
args: &mut Args,
) -> Result<ExtrudeTransform, KclError> {
let surface = extrude_group.get_path_by_name(surface_name).ok_or_else(|| {
KclError::Type(KclErrorDetails {
message: format!(
"Expected a surface name that exists in the given ExtrudeGroup, found `{}`",
surface_name
),
source_ranges: vec![args.source_range],
})
})?;
Ok(ExtrudeTransform {
position: surface.get_position(),
rotation: surface.get_rotation(),
meta: extrude_group.meta,
})
}

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