Compare commits
100 Commits
Author | SHA1 | Date | |
---|---|---|---|
1beb6b5186 | |||
17978ab1d7 | |||
a1bcad9dfb | |||
2e7bdf02cf | |||
6f76196b72 | |||
e7af064518 | |||
674d49e2ae | |||
4cb48674c6 | |||
82daec2aff | |||
f1ef9d5200 | |||
dc226d3270 | |||
7bf50d8fe0 | |||
b26764bc9a | |||
1b0c6298d7 | |||
fe9a483726 | |||
bd42ea037b | |||
fdb1b21af3 | |||
630ef316b8 | |||
e322926be9 | |||
a9e61da8b5 | |||
e2a835a437 | |||
c61273085f | |||
a79e365c0f | |||
2386ba24e5 | |||
e42a891df8 | |||
98200565bf | |||
570fd827ed | |||
0add26cf61 | |||
b54fc534c2 | |||
c66f851a3f | |||
13b8ab71d8 | |||
bdeab4f87d | |||
05ccf5e2f4 | |||
7ab015d783 | |||
3d6cfa980f | |||
9f5f1eb8c3 | |||
50fcdff879 | |||
efaae2b193 | |||
7e4ebacb72 | |||
72482506c3 | |||
a51b5b09a3 | |||
53ccc1ed6c | |||
8106749ccf | |||
081e34a600 | |||
541400f4be | |||
39d249030d | |||
f8a69fac73 | |||
24f4bf160f | |||
8011594e24 | |||
0e09affb8f | |||
197a47346a | |||
9d083710e0 | |||
afa7c1dc4e | |||
c74b695a71 | |||
d0c244e05e | |||
a315b77f02 | |||
15c854ff18 | |||
acd3a5717d | |||
8a2555550f | |||
62e75c852a | |||
dd3601ea7b | |||
a5e7782d9a | |||
79b0b70688 | |||
1d134c1be0 | |||
1c58572234 | |||
ecee51e82b | |||
978ac42f1c | |||
893996430e | |||
41e65fc4e9 | |||
99aa74ceba | |||
0bcf33ed00 | |||
d0a9b5ecab | |||
a569f818cf | |||
f73556ba7b | |||
29cdc66b34 | |||
c9800a58d0 | |||
e46aca4992 | |||
9564890b29 | |||
f8a1f40f20 | |||
c551d88db4 | |||
8eee3e1c58 | |||
b02529cae0 | |||
cf03021366 | |||
f52d2d55f1 | |||
59b1319e50 | |||
b07bbda20b | |||
3c01924184 | |||
bd16902f02 | |||
8c3af1a72a | |||
33f5d7740d | |||
b388f60648 | |||
8f4380be74 | |||
9ae8042a57 | |||
4b676d47da | |||
e6641e68f3 | |||
450afb1605 | |||
04433fecad | |||
6567e2ff92 | |||
91c32a7fe2 | |||
f735cdc22e |
@ -1,3 +1,3 @@
|
|||||||
[codespell]
|
[codespell]
|
||||||
ignore-words-list: crate,everytime,inout,co-ordinate,ot,nwo,absolutey,atleast
|
ignore-words-list: crate,everytime,inout,co-ordinate,ot,nwo,absolutey,atleast,ue
|
||||||
skip: **/target,node_modules,build,**/Cargo.lock,./docs/kcl/*.md,./src-tauri/gen/schemas
|
skip: **/target,node_modules,build,**/Cargo.lock,./docs/kcl/*.md,./src-tauri/gen/schemas
|
||||||
|
40
.github/workflows/cargo-check.yml
vendored
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
paths:
|
||||||
|
- '**/Cargo.toml'
|
||||||
|
- '**/Cargo.lock'
|
||||||
|
- '**/rust-toolchain.toml'
|
||||||
|
- '**.rs'
|
||||||
|
- .github/workflows/cargo-check.yml
|
||||||
|
pull_request:
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
name: cargo check
|
||||||
|
jobs:
|
||||||
|
cargocheck:
|
||||||
|
name: cargo check
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
dir: ['src/wasm-lib']
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Install latest rust
|
||||||
|
uses: actions-rs/toolchain@v1
|
||||||
|
with:
|
||||||
|
toolchain: stable
|
||||||
|
override: true
|
||||||
|
|
||||||
|
- name: Rust Cache
|
||||||
|
uses: Swatinem/rust-cache@v2.6.1
|
||||||
|
|
||||||
|
- name: Run check
|
||||||
|
run: |
|
||||||
|
cd "${{ matrix.dir }}"
|
||||||
|
# We specifically want to test the disable-println feature
|
||||||
|
# Since it is not enabled by default, we need to specify it
|
||||||
|
# This is used in kcl-lsp
|
||||||
|
cargo check --all --features disable-println --features pyo3
|
11
.github/workflows/cargo-clippy.yml
vendored
@ -9,6 +9,12 @@ on:
|
|||||||
- '**.rs'
|
- '**.rs'
|
||||||
- .github/workflows/cargo-clippy.yml
|
- .github/workflows/cargo-clippy.yml
|
||||||
pull_request:
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- '**/Cargo.toml'
|
||||||
|
- '**/Cargo.lock'
|
||||||
|
- '**/rust-toolchain.toml'
|
||||||
|
- '**.rs'
|
||||||
|
- .github/workflows/cargo-clippy.yml
|
||||||
concurrency:
|
concurrency:
|
||||||
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
|
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
|
||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
@ -54,3 +60,8 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
cd "${{ matrix.dir }}"
|
cd "${{ matrix.dir }}"
|
||||||
cargo clippy --all --tests --benches -- -D warnings
|
cargo clippy --all --tests --benches -- -D warnings
|
||||||
|
# If this fails, run "cargo check" to update Cargo.lock,
|
||||||
|
# then add Cargo.lock to the PR.
|
||||||
|
- name: Check Cargo.lock doesn't need updating
|
||||||
|
run: |
|
||||||
|
cargo check --locked || echo "Pls run cargo check and commit the changed Cargo.lock"
|
||||||
|
21
.github/workflows/ci.yml
vendored
@ -147,6 +147,14 @@ jobs:
|
|||||||
cp artifact/src-tauri/tauri.conf.json src-tauri/tauri.conf.json
|
cp artifact/src-tauri/tauri.conf.json src-tauri/tauri.conf.json
|
||||||
cp artifact/src-tauri/tauri.release.conf.json src-tauri/tauri.release.conf.json
|
cp artifact/src-tauri/tauri.release.conf.json src-tauri/tauri.release.conf.json
|
||||||
|
|
||||||
|
- name: Update WebView2 on Windows
|
||||||
|
if: matrix.os == 'windows-latest'
|
||||||
|
# Workaround needed to build the tauri windows app with matching edge version.
|
||||||
|
# From https://github.com/actions/runner-images/issues/9538
|
||||||
|
run: |
|
||||||
|
Invoke-WebRequest -Uri 'https://go.microsoft.com/fwlink/p/?LinkId=2124703' -OutFile 'setup.exe'
|
||||||
|
Start-Process -FilePath setup.exe -Verb RunAs -Wait
|
||||||
|
|
||||||
- name: Install ubuntu system dependencies
|
- name: Install ubuntu system dependencies
|
||||||
if: matrix.os == 'ubuntu-latest'
|
if: matrix.os == 'ubuntu-latest'
|
||||||
run: |
|
run: |
|
||||||
@ -172,9 +180,7 @@ jobs:
|
|||||||
- name: Setup Rust
|
- name: Setup Rust
|
||||||
uses: dtolnay/rust-toolchain@stable
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
|
||||||
# TODO: re-enable for Windows builds, see https://github.com/tauri-apps/tauri/issues/9045
|
|
||||||
- name: Setup Rust cache
|
- name: Setup Rust cache
|
||||||
if: matrix.os != 'windows-latest'
|
|
||||||
uses: swatinem/rust-cache@v2
|
uses: swatinem/rust-cache@v2
|
||||||
with:
|
with:
|
||||||
workspaces: './src-tauri -> target'
|
workspaces: './src-tauri -> target'
|
||||||
@ -364,6 +370,17 @@ jobs:
|
|||||||
E2E_APPLICATION: "./src-tauri/target/${{ env.BUILD_RELEASE == 'true' && 'release' || 'debug' }}/zoo-modeling-app"
|
E2E_APPLICATION: "./src-tauri/target/${{ env.BUILD_RELEASE == 'true' && 'release' || 'debug' }}/zoo-modeling-app"
|
||||||
KITTYCAD_API_TOKEN: ${{ env.BUILD_RELEASE == 'true' && secrets.KITTYCAD_API_TOKEN || secrets.KITTYCAD_API_TOKEN_DEV }}
|
KITTYCAD_API_TOKEN: ${{ env.BUILD_RELEASE == 'true' && secrets.KITTYCAD_API_TOKEN || secrets.KITTYCAD_API_TOKEN_DEV }}
|
||||||
|
|
||||||
|
- name: Run e2e tests (windows only)
|
||||||
|
if: ${{ matrix.os == 'windows-latest' && github.event_name != 'release' && github.event_name != 'schedule' }}
|
||||||
|
run: |
|
||||||
|
cargo install tauri-driver --force
|
||||||
|
yarn wdio run wdio.conf.ts
|
||||||
|
env:
|
||||||
|
E2E_APPLICATION: ".\\src-tauri\\target\\${{ env.BUILD_RELEASE == 'true' && 'release' || 'debug' }}\\Zoo Modeling App.exe"
|
||||||
|
KITTYCAD_API_TOKEN: ${{ env.BUILD_RELEASE == 'true' && secrets.KITTYCAD_API_TOKEN || secrets.KITTYCAD_API_TOKEN_DEV }}
|
||||||
|
VITE_KC_API_BASE_URL: ${{ env.BUILD_RELEASE == 'true' && 'https://api.zoo.dev' || 'https://api.dev.zoo.dev' }}
|
||||||
|
E2E_TAURI_ENABLED: true
|
||||||
|
TS_NODE_COMPILER_OPTIONS: '{"module": "commonjs"}'
|
||||||
|
|
||||||
publish-apps-release:
|
publish-apps-release:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
22
.github/workflows/playwright.yml
vendored
@ -46,12 +46,18 @@ jobs:
|
|||||||
- uses: KittyCAD/action-install-cli@main
|
- uses: KittyCAD/action-install-cli@main
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: yarn
|
run: yarn
|
||||||
|
- name: Cache Playwright Browsers
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cache/ms-playwright/
|
||||||
|
key: ${{ runner.os }}-playwright-${{ hashFiles('yarn.lock') }}
|
||||||
- name: Install Playwright Browsers
|
- name: Install Playwright Browsers
|
||||||
run: yarn playwright install --with-deps
|
run: yarn playwright install --with-deps
|
||||||
- name: Download Wasm Cache
|
- name: Download Wasm Cache
|
||||||
id: download-wasm
|
id: download-wasm
|
||||||
if: needs.check-rust-changes.outputs.rust-changed == 'false'
|
if: needs.check-rust-changes.outputs.rust-changed == 'false'
|
||||||
uses: dawidd6/action-download-artifact@v3
|
uses: dawidd6/action-download-artifact@v6
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
with:
|
with:
|
||||||
github_token: ${{secrets.GITHUB_TOKEN}}
|
github_token: ${{secrets.GITHUB_TOKEN}}
|
||||||
@ -127,7 +133,7 @@ jobs:
|
|||||||
- uses: actions/upload-artifact@v3
|
- uses: actions/upload-artifact@v3
|
||||||
if: always()
|
if: always()
|
||||||
with:
|
with:
|
||||||
name: playwright-report
|
name: playwright-report-ubuntu
|
||||||
path: playwright-report/
|
path: playwright-report/
|
||||||
retention-days: 30
|
retention-days: 30
|
||||||
|
|
||||||
@ -143,12 +149,20 @@ jobs:
|
|||||||
cache: 'yarn'
|
cache: 'yarn'
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: yarn
|
run: yarn
|
||||||
|
- name: Cache Playwright Browsers
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cache/ms-playwright
|
||||||
|
key: ${{ runner.os }}-playwright-${{ hashFiles('**/package-lock.json') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-playwright-
|
||||||
- name: Install Playwright Browsers
|
- name: Install Playwright Browsers
|
||||||
run: yarn playwright install --with-deps
|
run: yarn playwright install --with-deps
|
||||||
- name: Download Wasm Cache
|
- name: Download Wasm Cache
|
||||||
id: download-wasm
|
id: download-wasm
|
||||||
if: needs.check-rust-changes.outputs.rust-changed == 'false'
|
if: needs.check-rust-changes.outputs.rust-changed == 'false'
|
||||||
uses: dawidd6/action-download-artifact@v3
|
uses: dawidd6/action-download-artifact@v6
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
with:
|
with:
|
||||||
github_token: ${{secrets.GITHUB_TOKEN}}
|
github_token: ${{secrets.GITHUB_TOKEN}}
|
||||||
@ -190,6 +204,6 @@ jobs:
|
|||||||
- uses: actions/upload-artifact@v3
|
- uses: actions/upload-artifact@v3
|
||||||
if: always()
|
if: always()
|
||||||
with:
|
with:
|
||||||
name: playwright-report
|
name: playwright-report-macos
|
||||||
path: playwright-report/
|
path: playwright-report/
|
||||||
retention-days: 30
|
retention-days: 30
|
||||||
|
1
.gitignore
vendored
@ -17,6 +17,7 @@
|
|||||||
.env.development.local
|
.env.development.local
|
||||||
.env.test.local
|
.env.test.local
|
||||||
.env.production.local
|
.env.production.local
|
||||||
|
.direnv
|
||||||
|
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
|
14
Makefile
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
.PHONY: dev
|
||||||
|
|
||||||
|
WASM_LIB_FILES := $(wildcard src/wasm-lib/**/*.rs)
|
||||||
|
|
||||||
|
dev: node_modules public/wasm_lib_bg.wasm
|
||||||
|
yarn start
|
||||||
|
|
||||||
|
public/wasm_lib_bg.wasm: $(WASM_LIB_FILES)
|
||||||
|
yarn build:wasm-dev
|
||||||
|
|
||||||
|
node_modules: package.json
|
||||||
|
|
||||||
|
package.json:
|
||||||
|
yarn install
|
55
README.md
@ -197,28 +197,32 @@ For more information on fuzzing you can check out
|
|||||||
|
|
||||||
### Playwright
|
### Playwright
|
||||||
|
|
||||||
First time running plawright locally, you'll need to add the secrets file
|
For a portable way to run Playwright you'll need Docker.
|
||||||
|
|
||||||
|
After that, open a terminal and run:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
touch ./e2e/playwright/playwright-secrets.env
|
docker run --network host --rm --init -it playwright/chrome:playwright-1.43.1
|
||||||
printf 'token="your-token"\nsnapshottoken="your-snapshot-token"' > ./e2e/playwright/playwright-secrets.env
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
and in another terminal, run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PW_TEST_CONNECT_WS_ENDPOINT=ws://127.0.0.1:4444/ yarn playwright test --project="Google Chrome" <test suite>
|
||||||
|
```
|
||||||
|
|
||||||
|
An example of a `<test suite>` is: `e2e/playwright/flow-tests.spec.ts`
|
||||||
|
|
||||||
|
YOU WILL NEED A PLAYWRIGHT-SECRETS.ENV FILE:
|
||||||
|
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# ./e2e/playwright/playwright-secrets.env
|
||||||
|
token=<your-token>
|
||||||
|
snapshottoken=<your-snapshot-token>
|
||||||
|
```
|
||||||
then replace "your-token" with a dev token from dev.zoo.dev/account/api-tokens
|
then replace "your-token" with a dev token from dev.zoo.dev/account/api-tokens
|
||||||
|
|
||||||
then:
|
|
||||||
run playwright
|
|
||||||
|
|
||||||
```
|
|
||||||
yarn playwright test
|
|
||||||
```
|
|
||||||
|
|
||||||
run a specific test suite
|
|
||||||
|
|
||||||
```
|
|
||||||
yarn playwright test src/e2e-tests/example.spec.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
run a specific test change the test from `test('...` to `test.only('...`
|
run a specific test change the test from `test('...` to `test.only('...`
|
||||||
(note if you commit this, the tests will instantly fail without running any of the tests)
|
(note if you commit this, the tests will instantly fail without running any of the tests)
|
||||||
|
|
||||||
@ -309,6 +313,25 @@ PS: for the debug panel, the following JSON is useful for snapping the camera
|
|||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
### Tauri e2e tests
|
||||||
|
|
||||||
|
#### Windows (local only until the CI edge version mismatch is fixed)
|
||||||
|
|
||||||
|
```
|
||||||
|
yarn install
|
||||||
|
yarn build:wasm-dev
|
||||||
|
cp src/wasm-lib/pkg/wasm_lib_bg.wasm public
|
||||||
|
yarn vite build --mode development
|
||||||
|
yarn tauri build --debug -b
|
||||||
|
$env:KITTYCAD_API_TOKEN="<YOUR_KITTYCAD_API_TOKEN>"
|
||||||
|
$env:VITE_KC_API_BASE_URL="https://api.dev.zoo.dev"
|
||||||
|
$env:E2E_TAURI_ENABLED="true"
|
||||||
|
$env:TS_NODE_COMPILER_OPTIONS='{"module": "commonjs"}'
|
||||||
|
$env:E2E_APPLICATION=".\src-tauri\target\debug\Zoo Modeling App.exe"
|
||||||
|
Stop-Process -Name msedgedriver
|
||||||
|
yarn wdio run wdio.conf.ts
|
||||||
|
```
|
||||||
|
|
||||||
## KCL
|
## KCL
|
||||||
|
|
||||||
For how to contribute to KCL, [see our KCL README](https://github.com/KittyCAD/modeling-app/tree/main/src/wasm-lib/kcl).
|
For how to contribute to KCL, [see our KCL README](https://github.com/KittyCAD/modeling-app/tree/main/src/wasm-lib/kcl).
|
||||||
|
323
docs/kcl/chamfer.md
Normal file
@ -23,6 +23,7 @@ layout: manual
|
|||||||
* [`atan`](kcl/atan)
|
* [`atan`](kcl/atan)
|
||||||
* [`bezierCurve`](kcl/bezierCurve)
|
* [`bezierCurve`](kcl/bezierCurve)
|
||||||
* [`ceil`](kcl/ceil)
|
* [`ceil`](kcl/ceil)
|
||||||
|
* [`chamfer`](kcl/chamfer)
|
||||||
* [`circle`](kcl/circle)
|
* [`circle`](kcl/circle)
|
||||||
* [`close`](kcl/close)
|
* [`close`](kcl/close)
|
||||||
* [`cos`](kcl/cos)
|
* [`cos`](kcl/cos)
|
||||||
@ -64,6 +65,7 @@ layout: manual
|
|||||||
* [`segEndX`](kcl/segEndX)
|
* [`segEndX`](kcl/segEndX)
|
||||||
* [`segEndY`](kcl/segEndY)
|
* [`segEndY`](kcl/segEndY)
|
||||||
* [`segLen`](kcl/segLen)
|
* [`segLen`](kcl/segLen)
|
||||||
|
* [`shell`](kcl/shell)
|
||||||
* [`sin`](kcl/sin)
|
* [`sin`](kcl/sin)
|
||||||
* [`sqrt`](kcl/sqrt)
|
* [`sqrt`](kcl/sqrt)
|
||||||
* [`startProfileAt`](kcl/startProfileAt)
|
* [`startProfileAt`](kcl/startProfileAt)
|
||||||
|
@ -9,7 +9,7 @@ A circular pattern on a 2D sketch.
|
|||||||
|
|
||||||
|
|
||||||
```js
|
```js
|
||||||
patternCircular2d(data: CircularPattern2dData, sketch_group: SketchGroup) -> [SketchGroup]
|
patternCircular2d(data: CircularPattern2dData, sketch_group_set: SketchGroupSet) -> [SketchGroup]
|
||||||
```
|
```
|
||||||
|
|
||||||
### Examples
|
### Examples
|
||||||
@ -48,7 +48,7 @@ const example = extrude(1, exampleSketch)
|
|||||||
rotateDuplicates: string,
|
rotateDuplicates: string,
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
* `sketch_group`: `SketchGroup` - A sketch group is a collection of paths. (REQUIRED)
|
* `sketch_group_set`: `SketchGroupSet` - A sketch group or a group of sketch groups. (REQUIRED)
|
||||||
```js
|
```js
|
||||||
{
|
{
|
||||||
// The plane id or face id of the sketch group.
|
// The plane id or face id of the sketch group.
|
||||||
@ -129,6 +129,7 @@ const example = extrude(1, exampleSketch)
|
|||||||
// The to point.
|
// The to point.
|
||||||
to: [number, number],
|
to: [number, number],
|
||||||
},
|
},
|
||||||
|
type: "sketchGroup",
|
||||||
// The paths in the sketch group.
|
// The paths in the sketch group.
|
||||||
value: [{
|
value: [{
|
||||||
// The from point.
|
// The from point.
|
||||||
@ -212,6 +213,9 @@ const example = extrude(1, exampleSketch)
|
|||||||
y: number,
|
y: number,
|
||||||
z: number,
|
z: number,
|
||||||
},
|
},
|
||||||
|
} |
|
||||||
|
{
|
||||||
|
type: "sketchGroups",
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
@ -9,7 +9,7 @@ A circular pattern on a 3D model.
|
|||||||
|
|
||||||
|
|
||||||
```js
|
```js
|
||||||
patternCircular3d(data: CircularPattern3dData, extrude_group: ExtrudeGroup) -> [ExtrudeGroup]
|
patternCircular3d(data: CircularPattern3dData, extrude_group_set: ExtrudeGroupSet) -> [ExtrudeGroup]
|
||||||
```
|
```
|
||||||
|
|
||||||
### Examples
|
### Examples
|
||||||
@ -47,7 +47,7 @@ const example = extrude(-5, exampleSketch)
|
|||||||
rotateDuplicates: string,
|
rotateDuplicates: string,
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
* `extrude_group`: `ExtrudeGroup` - An extrude group is a collection of extrude surfaces. (REQUIRED)
|
* `extrude_group_set`: `ExtrudeGroupSet` - A extrude group or a group of extrude groups. (REQUIRED)
|
||||||
```js
|
```js
|
||||||
{
|
{
|
||||||
// The id of the extrusion end cap
|
// The id of the extrusion end cap
|
||||||
@ -127,6 +127,7 @@ const example = extrude(-5, exampleSketch)
|
|||||||
}],
|
}],
|
||||||
// The id of the extrusion start cap
|
// The id of the extrusion start cap
|
||||||
startCapId: uuid,
|
startCapId: uuid,
|
||||||
|
type: "extrudeGroup",
|
||||||
// The extrude surfaces.
|
// The extrude surfaces.
|
||||||
value: [{
|
value: [{
|
||||||
// The face id for the extrude plane.
|
// The face id for the extrude plane.
|
||||||
@ -176,6 +177,9 @@ const example = extrude(-5, exampleSketch)
|
|||||||
y: number,
|
y: number,
|
||||||
z: number,
|
z: number,
|
||||||
},
|
},
|
||||||
|
} |
|
||||||
|
{
|
||||||
|
type: "extrudeGroups",
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
@ -9,7 +9,7 @@ A linear pattern on a 3D model.
|
|||||||
|
|
||||||
|
|
||||||
```js
|
```js
|
||||||
patternLinear3d(data: LinearPattern3dData, extrude_group: ExtrudeGroup) -> [ExtrudeGroup]
|
patternLinear3d(data: LinearPattern3dData, extrude_group_set: ExtrudeGroupSet) -> [ExtrudeGroup]
|
||||||
```
|
```
|
||||||
|
|
||||||
### Examples
|
### Examples
|
||||||
@ -45,7 +45,7 @@ const example = extrude(1, exampleSketch)
|
|||||||
repetitions: number,
|
repetitions: number,
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
* `extrude_group`: `ExtrudeGroup` - An extrude group is a collection of extrude surfaces. (REQUIRED)
|
* `extrude_group_set`: `ExtrudeGroupSet` - A extrude group or a group of extrude groups. (REQUIRED)
|
||||||
```js
|
```js
|
||||||
{
|
{
|
||||||
// The id of the extrusion end cap
|
// The id of the extrusion end cap
|
||||||
@ -125,6 +125,7 @@ const example = extrude(1, exampleSketch)
|
|||||||
}],
|
}],
|
||||||
// The id of the extrusion start cap
|
// The id of the extrusion start cap
|
||||||
startCapId: uuid,
|
startCapId: uuid,
|
||||||
|
type: "extrudeGroup",
|
||||||
// The extrude surfaces.
|
// The extrude surfaces.
|
||||||
value: [{
|
value: [{
|
||||||
// The face id for the extrude plane.
|
// The face id for the extrude plane.
|
||||||
@ -174,6 +175,9 @@ const example = extrude(1, exampleSketch)
|
|||||||
y: number,
|
y: number,
|
||||||
z: number,
|
z: number,
|
||||||
},
|
},
|
||||||
|
} |
|
||||||
|
{
|
||||||
|
type: "extrudeGroups",
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
311
docs/kcl/shell.md
Normal file
10084
docs/kcl/std.json
@ -1,10 +1,10 @@
|
|||||||
import { test, expect, Download } from '@playwright/test'
|
import { test, expect } from '@playwright/test'
|
||||||
import { secrets } from './secrets'
|
import { secrets } from './secrets'
|
||||||
import { getUtils } from './test-utils'
|
import { Paths, doExport, getUtils } from './test-utils'
|
||||||
import { Models } from '@kittycad/lib'
|
import { Models } from '@kittycad/lib'
|
||||||
import fsp from 'fs/promises'
|
import fsp from 'fs/promises'
|
||||||
import { spawn } from 'child_process'
|
import { spawn } from 'child_process'
|
||||||
import { APP_NAME, KCL_DEFAULT_LENGTH } from 'lib/constants'
|
import { KCL_DEFAULT_LENGTH } from 'lib/constants'
|
||||||
import JSZip from 'jszip'
|
import JSZip from 'jszip'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
import { TEST_SETTINGS, TEST_SETTINGS_KEY } from './storageStates'
|
import { TEST_SETTINGS, TEST_SETTINGS_KEY } from './storageStates'
|
||||||
@ -99,78 +99,6 @@ const part001 = startSketchOn('-XZ')
|
|||||||
await page.waitForTimeout(1000)
|
await page.waitForTimeout(1000)
|
||||||
await u.clearAndCloseDebugPanel()
|
await u.clearAndCloseDebugPanel()
|
||||||
|
|
||||||
interface Paths {
|
|
||||||
modelPath: string
|
|
||||||
imagePath: string
|
|
||||||
outputType: string
|
|
||||||
}
|
|
||||||
const doExport = async (
|
|
||||||
output: Models['OutputFormat_type']
|
|
||||||
): Promise<Paths> => {
|
|
||||||
await page.getByRole('button', { name: APP_NAME }).click()
|
|
||||||
await expect(
|
|
||||||
page.getByRole('button', { name: 'Export Part' })
|
|
||||||
).toBeVisible()
|
|
||||||
await page.getByRole('button', { name: 'Export Part' }).click()
|
|
||||||
await expect(page.getByTestId('command-bar')).toBeVisible()
|
|
||||||
|
|
||||||
// Go through export via command bar
|
|
||||||
await page.getByRole('option', { name: output.type, exact: false }).click()
|
|
||||||
await page.locator('#arg-form').waitFor({ state: 'detached' })
|
|
||||||
if ('storage' in output) {
|
|
||||||
await page.getByTestId('arg-name-storage').waitFor({ timeout: 1000 })
|
|
||||||
await page.getByRole('button', { name: 'storage', exact: false }).click()
|
|
||||||
await page
|
|
||||||
.getByRole('option', { name: output.storage, exact: false })
|
|
||||||
.click()
|
|
||||||
await page.locator('#arg-form').waitFor({ state: 'detached' })
|
|
||||||
}
|
|
||||||
await expect(page.getByText('Confirm Export')).toBeVisible()
|
|
||||||
|
|
||||||
const getPromiseAndResolve = () => {
|
|
||||||
let resolve: any = () => {}
|
|
||||||
const promise = new Promise<Download>((r) => {
|
|
||||||
resolve = r
|
|
||||||
})
|
|
||||||
return [promise, resolve]
|
|
||||||
}
|
|
||||||
|
|
||||||
const [downloadPromise1, downloadResolve1] = getPromiseAndResolve()
|
|
||||||
let downloadCnt = 0
|
|
||||||
|
|
||||||
page.on('download', async (download) => {
|
|
||||||
if (downloadCnt === 0) {
|
|
||||||
downloadResolve1(download)
|
|
||||||
}
|
|
||||||
downloadCnt++
|
|
||||||
})
|
|
||||||
await page.getByRole('button', { name: 'Submit command' }).click()
|
|
||||||
|
|
||||||
// Handle download
|
|
||||||
const download = await downloadPromise1
|
|
||||||
const downloadLocationer = (extra = '', isImage = false) =>
|
|
||||||
`./e2e/playwright/export-snapshots/${output.type}-${
|
|
||||||
'storage' in output ? output.storage : ''
|
|
||||||
}${extra}.${isImage ? 'png' : output.type}`
|
|
||||||
const downloadLocation = downloadLocationer()
|
|
||||||
|
|
||||||
await download.saveAs(downloadLocation)
|
|
||||||
|
|
||||||
if (output.type === 'step') {
|
|
||||||
// stable timestamps for step files
|
|
||||||
const fileContents = await fsp.readFile(downloadLocation, 'utf-8')
|
|
||||||
const newFileContents = fileContents.replace(
|
|
||||||
/[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]+[0-9]+[0-9]\+[0-9]{2}:[0-9]{2}/g,
|
|
||||||
'1970-01-01T00:00:00.0+00:00'
|
|
||||||
)
|
|
||||||
await fsp.writeFile(downloadLocation, newFileContents)
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
modelPath: downloadLocation,
|
|
||||||
imagePath: downloadLocationer('', true),
|
|
||||||
outputType: output.type,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const axisDirectionPair: Models['AxisDirectionPair_type'] = {
|
const axisDirectionPair: Models['AxisDirectionPair_type'] = {
|
||||||
axis: 'z',
|
axis: 'z',
|
||||||
direction: 'positive',
|
direction: 'positive',
|
||||||
@ -186,84 +114,114 @@ const part001 = startSketchOn('-XZ')
|
|||||||
// just note that only `type` and `storage` are used for selecting the drop downs is the app
|
// just note that only `type` and `storage` are used for selecting the drop downs is the app
|
||||||
// the rest are only there to make typescript happy
|
// the rest are only there to make typescript happy
|
||||||
exportLocations.push(
|
exportLocations.push(
|
||||||
await doExport({
|
await doExport(
|
||||||
type: 'step',
|
{
|
||||||
coords: sysType,
|
type: 'step',
|
||||||
})
|
coords: sysType,
|
||||||
|
},
|
||||||
|
page
|
||||||
|
)
|
||||||
)
|
)
|
||||||
exportLocations.push(
|
exportLocations.push(
|
||||||
await doExport({
|
await doExport(
|
||||||
type: 'ply',
|
{
|
||||||
coords: sysType,
|
type: 'ply',
|
||||||
selection: { type: 'default_scene' },
|
coords: sysType,
|
||||||
storage: 'ascii',
|
selection: { type: 'default_scene' },
|
||||||
units: 'in',
|
storage: 'ascii',
|
||||||
})
|
units: 'in',
|
||||||
|
},
|
||||||
|
page
|
||||||
|
)
|
||||||
)
|
)
|
||||||
exportLocations.push(
|
exportLocations.push(
|
||||||
await doExport({
|
await doExport(
|
||||||
type: 'ply',
|
{
|
||||||
storage: 'binary_little_endian',
|
type: 'ply',
|
||||||
coords: sysType,
|
storage: 'binary_little_endian',
|
||||||
selection: { type: 'default_scene' },
|
coords: sysType,
|
||||||
units: 'in',
|
selection: { type: 'default_scene' },
|
||||||
})
|
units: 'in',
|
||||||
|
},
|
||||||
|
page
|
||||||
|
)
|
||||||
)
|
)
|
||||||
exportLocations.push(
|
exportLocations.push(
|
||||||
await doExport({
|
await doExport(
|
||||||
type: 'ply',
|
{
|
||||||
storage: 'binary_big_endian',
|
type: 'ply',
|
||||||
coords: sysType,
|
storage: 'binary_big_endian',
|
||||||
selection: { type: 'default_scene' },
|
coords: sysType,
|
||||||
units: 'in',
|
selection: { type: 'default_scene' },
|
||||||
})
|
units: 'in',
|
||||||
|
},
|
||||||
|
page
|
||||||
|
)
|
||||||
)
|
)
|
||||||
exportLocations.push(
|
exportLocations.push(
|
||||||
await doExport({
|
await doExport(
|
||||||
type: 'stl',
|
{
|
||||||
storage: 'ascii',
|
type: 'stl',
|
||||||
coords: sysType,
|
storage: 'ascii',
|
||||||
units: 'in',
|
coords: sysType,
|
||||||
selection: { type: 'default_scene' },
|
units: 'in',
|
||||||
})
|
selection: { type: 'default_scene' },
|
||||||
|
},
|
||||||
|
page
|
||||||
|
)
|
||||||
)
|
)
|
||||||
exportLocations.push(
|
exportLocations.push(
|
||||||
await doExport({
|
await doExport(
|
||||||
type: 'stl',
|
{
|
||||||
storage: 'binary',
|
type: 'stl',
|
||||||
coords: sysType,
|
storage: 'binary',
|
||||||
units: 'in',
|
coords: sysType,
|
||||||
selection: { type: 'default_scene' },
|
units: 'in',
|
||||||
})
|
selection: { type: 'default_scene' },
|
||||||
|
},
|
||||||
|
page
|
||||||
|
)
|
||||||
)
|
)
|
||||||
exportLocations.push(
|
exportLocations.push(
|
||||||
await doExport({
|
await doExport(
|
||||||
// obj seems to be a little flaky, times out tests sometimes
|
{
|
||||||
type: 'obj',
|
// obj seems to be a little flaky, times out tests sometimes
|
||||||
coords: sysType,
|
type: 'obj',
|
||||||
units: 'in',
|
coords: sysType,
|
||||||
})
|
units: 'in',
|
||||||
|
},
|
||||||
|
page
|
||||||
|
)
|
||||||
)
|
)
|
||||||
exportLocations.push(
|
exportLocations.push(
|
||||||
await doExport({
|
await doExport(
|
||||||
type: 'gltf',
|
{
|
||||||
storage: 'embedded',
|
type: 'gltf',
|
||||||
presentation: 'pretty',
|
storage: 'embedded',
|
||||||
})
|
presentation: 'pretty',
|
||||||
|
},
|
||||||
|
page
|
||||||
|
)
|
||||||
)
|
)
|
||||||
exportLocations.push(
|
exportLocations.push(
|
||||||
await doExport({
|
await doExport(
|
||||||
type: 'gltf',
|
{
|
||||||
storage: 'binary',
|
type: 'gltf',
|
||||||
presentation: 'pretty',
|
storage: 'binary',
|
||||||
})
|
presentation: 'pretty',
|
||||||
|
},
|
||||||
|
page
|
||||||
|
)
|
||||||
)
|
)
|
||||||
exportLocations.push(
|
exportLocations.push(
|
||||||
await doExport({
|
await doExport(
|
||||||
type: 'gltf',
|
{
|
||||||
storage: 'standard',
|
type: 'gltf',
|
||||||
presentation: 'pretty',
|
storage: 'standard',
|
||||||
})
|
presentation: 'pretty',
|
||||||
|
},
|
||||||
|
page
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
// close page to disconnect websocket since we can only have one open atm
|
// close page to disconnect websocket since we can only have one open atm
|
||||||
@ -447,17 +405,16 @@ test('Draft segments should look right', async ({ page, context }) => {
|
|||||||
// select a plane
|
// select a plane
|
||||||
await page.mouse.click(700, 200)
|
await page.mouse.click(700, 200)
|
||||||
|
|
||||||
await expect(page.locator('.cm-content')).toHaveText(
|
let code = `const sketch001 = startSketchOn('XZ')`
|
||||||
`const part001 = startSketchOn('XZ')`
|
await expect(page.locator('.cm-content')).toHaveText(code)
|
||||||
)
|
|
||||||
|
|
||||||
await page.waitForTimeout(300) // TODO detect animation ending, or disable animation
|
await page.waitForTimeout(700) // TODO detect animation ending, or disable animation
|
||||||
|
|
||||||
const startXPx = 600
|
const startXPx = 600
|
||||||
await page.mouse.click(startXPx + PUR * 10, 500 - PUR * 10)
|
await page.mouse.click(startXPx + PUR * 10, 500 - PUR * 10)
|
||||||
await expect(page.locator('.cm-content'))
|
code += `
|
||||||
.toHaveText(`const part001 = startSketchOn('XZ')
|
|> startProfileAt([7.19, -9.7], %)`
|
||||||
|> startProfileAt([9.06, -12.22], %)`)
|
await expect(page.locator('.cm-content')).toHaveText(code)
|
||||||
await page.waitForTimeout(100)
|
await page.waitForTimeout(100)
|
||||||
|
|
||||||
await u.closeDebugPanel()
|
await u.closeDebugPanel()
|
||||||
@ -469,10 +426,9 @@ test('Draft segments should look right', async ({ page, context }) => {
|
|||||||
await page.mouse.click(startXPx + PUR * 20, 500 - PUR * 10)
|
await page.mouse.click(startXPx + PUR * 20, 500 - PUR * 10)
|
||||||
await page.waitForTimeout(100)
|
await page.waitForTimeout(100)
|
||||||
|
|
||||||
await expect(page.locator('.cm-content'))
|
code += `
|
||||||
.toHaveText(`const part001 = startSketchOn('XZ')
|
|> line([7.25, 0], %)`
|
||||||
|> startProfileAt([9.06, -12.22], %)
|
await expect(page.locator('.cm-content')).toHaveText(code)
|
||||||
|> line([9.14, 0], %)`)
|
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Tangential Arc' }).click()
|
await page.getByRole('button', { name: 'Tangential Arc' }).click()
|
||||||
|
|
||||||
@ -507,7 +463,7 @@ test('Draft rectangles should look right', async ({ page, context }) => {
|
|||||||
await page.mouse.click(700, 200)
|
await page.mouse.click(700, 200)
|
||||||
|
|
||||||
await expect(page.locator('.cm-content')).toHaveText(
|
await expect(page.locator('.cm-content')).toHaveText(
|
||||||
`const part001 = startSketchOn('XZ')`
|
`const sketch001 = startSketchOn('XZ')`
|
||||||
)
|
)
|
||||||
|
|
||||||
await page.waitForTimeout(500) // TODO detect animation ending, or disable animation
|
await page.waitForTimeout(500) // TODO detect animation ending, or disable animation
|
||||||
@ -555,17 +511,16 @@ test.describe('Client side scene scale should match engine scale', () => {
|
|||||||
// select a plane
|
// select a plane
|
||||||
await page.mouse.click(700, 200)
|
await page.mouse.click(700, 200)
|
||||||
|
|
||||||
await expect(page.locator('.cm-content')).toHaveText(
|
let code = `const sketch001 = startSketchOn('XZ')`
|
||||||
`const part001 = startSketchOn('XZ')`
|
await expect(page.locator('.cm-content')).toHaveText(code)
|
||||||
)
|
|
||||||
|
|
||||||
await page.waitForTimeout(300) // TODO detect animation ending, or disable animation
|
await page.waitForTimeout(600) // TODO detect animation ending, or disable animation
|
||||||
|
|
||||||
const startXPx = 600
|
const startXPx = 600
|
||||||
await page.mouse.click(startXPx + PUR * 10, 500 - PUR * 10)
|
await page.mouse.click(startXPx + PUR * 10, 500 - PUR * 10)
|
||||||
await expect(page.locator('.cm-content'))
|
code += `
|
||||||
.toHaveText(`const part001 = startSketchOn('XZ')
|
|> startProfileAt([7.19, -9.7], %)`
|
||||||
|> startProfileAt([9.06, -12.22], %)`)
|
await expect(u.codeLocator).toHaveText(code)
|
||||||
await page.waitForTimeout(100)
|
await page.waitForTimeout(100)
|
||||||
|
|
||||||
await u.closeDebugPanel()
|
await u.closeDebugPanel()
|
||||||
@ -573,21 +528,18 @@ test.describe('Client side scene scale should match engine scale', () => {
|
|||||||
await page.mouse.click(startXPx + PUR * 20, 500 - PUR * 10)
|
await page.mouse.click(startXPx + PUR * 20, 500 - PUR * 10)
|
||||||
await page.waitForTimeout(100)
|
await page.waitForTimeout(100)
|
||||||
|
|
||||||
await expect(page.locator('.cm-content'))
|
code += `
|
||||||
.toHaveText(`const part001 = startSketchOn('XZ')
|
|> line([7.25, 0], %)`
|
||||||
|> startProfileAt([9.06, -12.22], %)
|
await expect(u.codeLocator).toHaveText(code)
|
||||||
|> line([9.14, 0], %)`)
|
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Tangential Arc' }).click()
|
await page.getByRole('button', { name: 'Tangential Arc' }).click()
|
||||||
await page.waitForTimeout(100)
|
await page.waitForTimeout(100)
|
||||||
|
|
||||||
await page.mouse.click(startXPx + PUR * 30, 500 - PUR * 20)
|
await page.mouse.click(startXPx + PUR * 30, 500 - PUR * 20)
|
||||||
|
|
||||||
await expect(page.locator('.cm-content'))
|
code += `
|
||||||
.toHaveText(`const part001 = startSketchOn('XZ')
|
|> tangentialArcTo([21.7, -2.44], %)`
|
||||||
|> startProfileAt([9.06, -12.22], %)
|
await expect(u.codeLocator).toHaveText(code)
|
||||||
|> line([9.14, 0], %)
|
|
||||||
|> tangentialArcTo([27.34, -3.08], %)`)
|
|
||||||
|
|
||||||
// click tangential arc tool again to unequip it
|
// click tangential arc tool again to unequip it
|
||||||
await page.getByRole('button', { name: 'Tangential Arc' }).click()
|
await page.getByRole('button', { name: 'Tangential Arc' }).click()
|
||||||
@ -658,17 +610,16 @@ test.describe('Client side scene scale should match engine scale', () => {
|
|||||||
// select a plane
|
// select a plane
|
||||||
await page.mouse.click(700, 200)
|
await page.mouse.click(700, 200)
|
||||||
|
|
||||||
await expect(page.locator('.cm-content')).toHaveText(
|
let code = `const sketch001 = startSketchOn('XZ')`
|
||||||
`const part001 = startSketchOn('XZ')`
|
await expect(u.codeLocator).toHaveText(code)
|
||||||
)
|
|
||||||
|
|
||||||
await page.waitForTimeout(300) // TODO detect animation ending, or disable animation
|
await page.waitForTimeout(600) // TODO detect animation ending, or disable animation
|
||||||
|
|
||||||
const startXPx = 600
|
const startXPx = 600
|
||||||
await page.mouse.click(startXPx + PUR * 10, 500 - PUR * 10)
|
await page.mouse.click(startXPx + PUR * 10, 500 - PUR * 10)
|
||||||
await expect(page.locator('.cm-content'))
|
code += `
|
||||||
.toHaveText(`const part001 = startSketchOn('XZ')
|
|> startProfileAt([182.59, -246.32], %)`
|
||||||
|> startProfileAt([230.03, -310.32], %)`)
|
await expect(u.codeLocator).toHaveText(code)
|
||||||
await page.waitForTimeout(100)
|
await page.waitForTimeout(100)
|
||||||
|
|
||||||
await u.closeDebugPanel()
|
await u.closeDebugPanel()
|
||||||
@ -676,21 +627,18 @@ test.describe('Client side scene scale should match engine scale', () => {
|
|||||||
await page.mouse.click(startXPx + PUR * 20, 500 - PUR * 10)
|
await page.mouse.click(startXPx + PUR * 20, 500 - PUR * 10)
|
||||||
await page.waitForTimeout(100)
|
await page.waitForTimeout(100)
|
||||||
|
|
||||||
await expect(page.locator('.cm-content'))
|
code += `
|
||||||
.toHaveText(`const part001 = startSketchOn('XZ')
|
|> line([184.3, 0], %)`
|
||||||
|> startProfileAt([230.03, -310.32], %)
|
await expect(u.codeLocator).toHaveText(code)
|
||||||
|> line([232.2, 0], %)`)
|
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Tangential Arc' }).click()
|
await page.getByRole('button', { name: 'Tangential Arc' }).click()
|
||||||
await page.waitForTimeout(100)
|
await page.waitForTimeout(100)
|
||||||
|
|
||||||
await page.mouse.click(startXPx + PUR * 30, 500 - PUR * 20)
|
await page.mouse.click(startXPx + PUR * 30, 500 - PUR * 20)
|
||||||
|
|
||||||
await expect(page.locator('.cm-content'))
|
code += `
|
||||||
.toHaveText(`const part001 = startSketchOn('XZ')
|
|> tangentialArcTo([551.2, -62.01], %)`
|
||||||
|> startProfileAt([230.03, -310.32], %)
|
await expect(u.codeLocator).toHaveText(code)
|
||||||
|> line([232.2, 0], %)
|
|
||||||
|> tangentialArcTo([694.43, -78.12], %)`)
|
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Tangential Arc' }).click()
|
await page.getByRole('button', { name: 'Tangential Arc' }).click()
|
||||||
await page.waitForTimeout(100)
|
await page.waitForTimeout(100)
|
||||||
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 42 KiB |
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 42 KiB |
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 49 KiB |
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 44 KiB |
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 31 KiB |
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 33 KiB |
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 36 KiB |
Before Width: | Height: | Size: 65 KiB After Width: | Height: | Size: 66 KiB |
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 71 KiB |
Before Width: | Height: | Size: 75 KiB After Width: | Height: | Size: 75 KiB |
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 44 KiB |
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 46 KiB |
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 44 KiB |
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 44 KiB |
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 47 KiB |
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 45 KiB |
@ -1,5 +1,6 @@
|
|||||||
import { SaveSettingsPayload } from 'lib/settings/settingsTypes'
|
import { SaveSettingsPayload } from 'lib/settings/settingsTypes'
|
||||||
import { Themes } from 'lib/theme'
|
import { Themes } from 'lib/theme'
|
||||||
|
import { onboardingPaths } from 'routes/Onboarding/paths'
|
||||||
|
|
||||||
export const TEST_SETTINGS_KEY = '/settings.toml'
|
export const TEST_SETTINGS_KEY = '/settings.toml'
|
||||||
export const TEST_SETTINGS = {
|
export const TEST_SETTINGS = {
|
||||||
@ -22,9 +23,22 @@ export const TEST_SETTINGS = {
|
|||||||
},
|
},
|
||||||
} satisfies Partial<SaveSettingsPayload>
|
} satisfies Partial<SaveSettingsPayload>
|
||||||
|
|
||||||
|
export const TEST_SETTINGS_ONBOARDING_USER_MENU = {
|
||||||
|
...TEST_SETTINGS,
|
||||||
|
app: { ...TEST_SETTINGS.app, onboardingStatus: onboardingPaths.USER_MENU },
|
||||||
|
} satisfies Partial<SaveSettingsPayload>
|
||||||
|
|
||||||
export const TEST_SETTINGS_ONBOARDING_EXPORT = {
|
export const TEST_SETTINGS_ONBOARDING_EXPORT = {
|
||||||
...TEST_SETTINGS,
|
...TEST_SETTINGS,
|
||||||
app: { ...TEST_SETTINGS.app, onboardingStatus: '/export' },
|
app: { ...TEST_SETTINGS.app, onboardingStatus: onboardingPaths.EXPORT },
|
||||||
|
} satisfies Partial<SaveSettingsPayload>
|
||||||
|
|
||||||
|
export const TEST_SETTINGS_ONBOARDING_PARAMETRIC_MODELING = {
|
||||||
|
...TEST_SETTINGS,
|
||||||
|
app: {
|
||||||
|
...TEST_SETTINGS.app,
|
||||||
|
onboardingStatus: onboardingPaths.PARAMETRIC_MODELING,
|
||||||
|
},
|
||||||
} satisfies Partial<SaveSettingsPayload>
|
} satisfies Partial<SaveSettingsPayload>
|
||||||
|
|
||||||
export const TEST_SETTINGS_ONBOARDING_START = {
|
export const TEST_SETTINGS_ONBOARDING_START = {
|
||||||
@ -50,3 +64,25 @@ export const TEST_SETTINGS_CORRUPTED = {
|
|||||||
textWrapping: true,
|
textWrapping: true,
|
||||||
},
|
},
|
||||||
} satisfies Partial<SaveSettingsPayload>
|
} satisfies Partial<SaveSettingsPayload>
|
||||||
|
|
||||||
|
export const TEST_CODE_GIZMO = `const part001 = startSketchOn('XZ')
|
||||||
|
|> startProfileAt([20, 0], %)
|
||||||
|
|> line([7.13, 4 + 0], %)
|
||||||
|
|> angledLine({ angle: 3 + 0, length: 3.14 + 0 }, %)
|
||||||
|
|> lineTo([20.14 + 0, -0.14 + 0], %)
|
||||||
|
|> xLineTo(29 + 0, %)
|
||||||
|
|> yLine(-3.14 + 0, %, 'a')
|
||||||
|
|> xLine(1.63, %)
|
||||||
|
|> angledLineOfXLength({ angle: 3 + 0, length: 3.14 }, %)
|
||||||
|
|> angledLineOfYLength({ angle: 30, length: 3 + 0 }, %)
|
||||||
|
|> angledLineToX({ angle: 22.14 + 0, to: 12 }, %)
|
||||||
|
|> angledLineToY({ angle: 30, to: 11.14 }, %)
|
||||||
|
|> angledLineThatIntersects({
|
||||||
|
angle: 3.14,
|
||||||
|
intersectTag: 'a',
|
||||||
|
offset: 0
|
||||||
|
}, %)
|
||||||
|
|> tangentialArcTo([13.14 + 0, 13.14], %)
|
||||||
|
|> close(%)
|
||||||
|
|> extrude(5 + 7, %)
|
||||||
|
`
|
||||||
|
@ -1,22 +1,27 @@
|
|||||||
import { test, expect, Page } from '@playwright/test'
|
import { test, expect, Page, Download } from '@playwright/test'
|
||||||
import { EngineCommand } from '../../src/lang/std/engineConnection'
|
import { EngineCommand } from '../../src/lang/std/engineConnection'
|
||||||
|
import os from 'os'
|
||||||
import fsp from 'fs/promises'
|
import fsp from 'fs/promises'
|
||||||
import pixelMatch from 'pixelmatch'
|
import pixelMatch from 'pixelmatch'
|
||||||
import { PNG } from 'pngjs'
|
import { PNG } from 'pngjs'
|
||||||
import { Protocol } from 'playwright-core/types/protocol'
|
import { Protocol } from 'playwright-core/types/protocol'
|
||||||
|
import type { Models } from '@kittycad/lib'
|
||||||
|
import { APP_NAME } from 'lib/constants'
|
||||||
|
|
||||||
async function waitForPageLoad(page: Page) {
|
async function waitForPageLoad(page: Page) {
|
||||||
// wait for 'Loading stream...' spinner
|
// wait for 'Loading stream...' spinner
|
||||||
await page.getByTestId('loading-stream').waitFor()
|
await page.getByTestId('loading-stream').waitFor()
|
||||||
// wait for all spinners to be gone
|
// wait for all spinners to be gone
|
||||||
await page.getByTestId('loading').waitFor({ state: 'detached' })
|
await page
|
||||||
|
.getByTestId('loading')
|
||||||
|
.waitFor({ state: 'detached', timeout: 20_000 })
|
||||||
|
|
||||||
await page.getByTestId('start-sketch').waitFor()
|
await page.getByTestId('start-sketch').waitFor()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function removeCurrentCode(page: Page) {
|
async function removeCurrentCode(page: Page) {
|
||||||
const hotkey = process.platform === 'darwin' ? 'Meta' : 'Control'
|
const hotkey = process.platform === 'darwin' ? 'Meta' : 'Control'
|
||||||
await page.click('.cm-content')
|
await page.locator('.cm-content').click()
|
||||||
await page.keyboard.down(hotkey)
|
await page.keyboard.down(hotkey)
|
||||||
await page.keyboard.press('a')
|
await page.keyboard.press('a')
|
||||||
await page.keyboard.up(hotkey)
|
await page.keyboard.up(hotkey)
|
||||||
@ -25,12 +30,12 @@ async function removeCurrentCode(page: Page) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function sendCustomCmd(page: Page, cmd: EngineCommand) {
|
async function sendCustomCmd(page: Page, cmd: EngineCommand) {
|
||||||
await page.fill('[data-testid="custom-cmd-input"]', JSON.stringify(cmd))
|
await page.getByTestId('custom-cmd-input').fill(JSON.stringify(cmd))
|
||||||
await page.click('[data-testid="custom-cmd-send-button"]')
|
await page.getByTestId('custom-cmd-send-button').click()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function clearCommandLogs(page: Page) {
|
async function clearCommandLogs(page: Page) {
|
||||||
await page.click('[data-testid="clear-commands"]')
|
await page.getByTestId('clear-commands').click()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function expectCmdLog(page: Page, locatorStr: string) {
|
async function expectCmdLog(page: Page, locatorStr: string) {
|
||||||
@ -94,11 +99,79 @@ async function waitForCmdReceive(page: Page, commandType: string) {
|
|||||||
.waitFor()
|
.waitFor()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const wiggleMove = async (
|
||||||
|
page: any,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
steps: number,
|
||||||
|
dist: number,
|
||||||
|
ang: number,
|
||||||
|
amplitude: number,
|
||||||
|
freq: number
|
||||||
|
) => {
|
||||||
|
const tau = Math.PI * 2
|
||||||
|
const deg = tau / 360
|
||||||
|
const step = dist / steps
|
||||||
|
for (let i = 0, j = 0; i < dist; i += step, j += 1) {
|
||||||
|
const [x1, y1] = [0, Math.sin((tau / steps) * j * freq) * amplitude]
|
||||||
|
const [x2, y2] = [
|
||||||
|
Math.cos(-ang * deg) * i - Math.sin(-ang * deg) * y1,
|
||||||
|
Math.sin(-ang * deg) * i + Math.cos(-ang * deg) * y1,
|
||||||
|
]
|
||||||
|
const [xr, yr] = [x2, y2]
|
||||||
|
await page.mouse.move(x + xr, y + yr, { steps: 2 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getMovementUtils = (opts: any) => {
|
||||||
|
// The way we truncate is kinda odd apparently, so we need this function
|
||||||
|
// "[k]itty[c]ad round"
|
||||||
|
const kcRound = (n: number) => Math.trunc(n * 100) / 100
|
||||||
|
|
||||||
|
// To translate between screen and engine ("[U]nit") coordinates
|
||||||
|
// NOTE: these pretty much can't be perfect because of screen scaling.
|
||||||
|
// Handle on a case-by-case.
|
||||||
|
const toU = (x: number, y: number) => [
|
||||||
|
kcRound(x * 0.0678),
|
||||||
|
kcRound(-y * 0.0678), // Y is inverted in our coordinate system
|
||||||
|
]
|
||||||
|
|
||||||
|
// Turn the array into a string with specific formatting
|
||||||
|
const fromUToString = (xy: number[]) => `[${xy[0]}, ${xy[1]}]`
|
||||||
|
|
||||||
|
// Combine because used often
|
||||||
|
const toSU = (xy: number[]) => fromUToString(toU(xy[0], xy[1]))
|
||||||
|
|
||||||
|
// Make it easier to click around from center ("click [from] zero zero")
|
||||||
|
const click00 = (x: number, y: number) =>
|
||||||
|
opts.page.mouse.click(opts.center.x + x, opts.center.y + y)
|
||||||
|
|
||||||
|
// Relative clicker, must keep state
|
||||||
|
let last = { x: 0, y: 0 }
|
||||||
|
const click00r = (x?: number, y?: number) => {
|
||||||
|
// reset relative coordinates when anything is undefined
|
||||||
|
if (x === undefined || y === undefined) {
|
||||||
|
last.x = 0
|
||||||
|
last.y = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const ret = click00(last.x + x, last.y + y)
|
||||||
|
last.x += x
|
||||||
|
last.y += y
|
||||||
|
|
||||||
|
// Returns the new absolute coordinate if you need it.
|
||||||
|
return ret.then(() => [last.x, last.y])
|
||||||
|
}
|
||||||
|
|
||||||
|
return { toSU, click00r }
|
||||||
|
}
|
||||||
|
|
||||||
export async function getUtils(page: Page) {
|
export async function getUtils(page: Page) {
|
||||||
|
// Chrome devtools protocol session only works in Chromium
|
||||||
|
const browserType = page.context().browser()?.browserType().name()
|
||||||
const cdpSession =
|
const cdpSession =
|
||||||
process.platform === 'darwin'
|
browserType !== 'chromium' ? null : await page.context().newCDPSession(page)
|
||||||
? null
|
|
||||||
: await page.context().newCDPSession(page)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
waitForAuthSkipAppStart: () => waitForPageLoad(page),
|
waitForAuthSkipAppStart: () => waitForPageLoad(page),
|
||||||
@ -130,11 +203,30 @@ export async function getUtils(page: Page) {
|
|||||||
},
|
},
|
||||||
waitForCmdReceive: (commandType: string) =>
|
waitForCmdReceive: (commandType: string) =>
|
||||||
waitForCmdReceive(page, commandType),
|
waitForCmdReceive(page, commandType),
|
||||||
|
getSegmentBodyCoords: async (locator: string, px = 30) => {
|
||||||
|
const overlay = page.locator(locator)
|
||||||
|
const bbox = await overlay
|
||||||
|
.boundingBox()
|
||||||
|
.then((box) => ({ ...box, x: box?.x || 0, y: box?.y || 0 }))
|
||||||
|
const angle = Number(await overlay.getAttribute('data-overlay-angle'))
|
||||||
|
const angleXOffset = Math.cos(((angle - 180) * Math.PI) / 180) * px
|
||||||
|
const angleYOffset = Math.sin(((angle - 180) * Math.PI) / 180) * px
|
||||||
|
return {
|
||||||
|
x: Math.round(bbox.x + angleXOffset),
|
||||||
|
y: Math.round(bbox.y - angleYOffset),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
getAngle: async (locator: string) => {
|
||||||
|
const overlay = page.locator(locator)
|
||||||
|
return Number(await overlay.getAttribute('data-overlay-angle'))
|
||||||
|
},
|
||||||
getBoundingBox: async (locator: string) =>
|
getBoundingBox: async (locator: string) =>
|
||||||
page
|
page
|
||||||
.locator(locator)
|
.locator(locator)
|
||||||
.boundingBox()
|
.boundingBox()
|
||||||
.then((box) => ({ x: box?.x || 0, y: box?.y || 0 })),
|
.then((box) => ({ ...box, x: box?.x || 0, y: box?.y || 0 })),
|
||||||
|
codeLocator: page.locator('.cm-content'),
|
||||||
|
canvasLocator: page.getByTestId('client-side-scene'),
|
||||||
doAndWaitForCmd: async (
|
doAndWaitForCmd: async (
|
||||||
fn: () => Promise<void>,
|
fn: () => Promise<void>,
|
||||||
commandType: string,
|
commandType: string,
|
||||||
@ -150,6 +242,30 @@ export async function getUtils(page: Page) {
|
|||||||
await closeDebugPanel(page)
|
await closeDebugPanel(page)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
/**
|
||||||
|
* Given an expected RGB value, diff if the channel with the largest difference
|
||||||
|
*/
|
||||||
|
getGreatestPixDiff: async (
|
||||||
|
coords: { x: number; y: number },
|
||||||
|
expected: [number, number, number]
|
||||||
|
): Promise<number> => {
|
||||||
|
const buffer = await page.screenshot({
|
||||||
|
fullPage: true,
|
||||||
|
})
|
||||||
|
const screenshot = await PNG.sync.read(buffer)
|
||||||
|
// most likely related to pixel density but the screenshots for webkit are 2x the size
|
||||||
|
// there might be a more robust way of doing this.
|
||||||
|
const pixMultiplier = browserType === 'webkit' ? 2 : 1
|
||||||
|
const index =
|
||||||
|
(screenshot.width * coords.y * pixMultiplier +
|
||||||
|
coords.x * pixMultiplier) *
|
||||||
|
4 // rbga is 4 channels
|
||||||
|
return Math.max(
|
||||||
|
Math.abs(screenshot.data[index] - expected[0]),
|
||||||
|
Math.abs(screenshot.data[index + 1] - expected[1]),
|
||||||
|
Math.abs(screenshot.data[index + 2] - expected[2])
|
||||||
|
)
|
||||||
|
},
|
||||||
doAndWaitForImageDiff: (fn: () => Promise<any>, diffCount = 200) =>
|
doAndWaitForImageDiff: (fn: () => Promise<any>, diffCount = 200) =>
|
||||||
new Promise(async (resolve) => {
|
new Promise(async (resolve) => {
|
||||||
await page.screenshot({
|
await page.screenshot({
|
||||||
@ -277,3 +393,82 @@ export const makeTemplate: (
|
|||||||
),
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Paths {
|
||||||
|
modelPath: string
|
||||||
|
imagePath: string
|
||||||
|
outputType: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const doExport = async (
|
||||||
|
output: Models['OutputFormat_type'],
|
||||||
|
page: Page
|
||||||
|
): Promise<Paths> => {
|
||||||
|
await page.getByRole('button', { name: APP_NAME }).click()
|
||||||
|
await expect(page.getByRole('button', { name: 'Export Part' })).toBeVisible()
|
||||||
|
await page.getByRole('button', { name: 'Export Part' }).click()
|
||||||
|
await expect(page.getByTestId('command-bar')).toBeVisible()
|
||||||
|
|
||||||
|
// Go through export via command bar
|
||||||
|
await page.getByRole('option', { name: output.type, exact: false }).click()
|
||||||
|
await page.locator('#arg-form').waitFor({ state: 'detached' })
|
||||||
|
if ('storage' in output) {
|
||||||
|
await page.getByTestId('arg-name-storage').waitFor({ timeout: 1000 })
|
||||||
|
await page.getByRole('button', { name: 'storage', exact: false }).click()
|
||||||
|
await page
|
||||||
|
.getByRole('option', { name: output.storage, exact: false })
|
||||||
|
.click()
|
||||||
|
await page.locator('#arg-form').waitFor({ state: 'detached' })
|
||||||
|
}
|
||||||
|
await expect(page.getByText('Confirm Export')).toBeVisible()
|
||||||
|
|
||||||
|
const getPromiseAndResolve = () => {
|
||||||
|
let resolve: any = () => {}
|
||||||
|
const promise = new Promise<Download>((r) => {
|
||||||
|
resolve = r
|
||||||
|
})
|
||||||
|
return [promise, resolve]
|
||||||
|
}
|
||||||
|
|
||||||
|
const [downloadPromise1, downloadResolve1] = getPromiseAndResolve()
|
||||||
|
let downloadCnt = 0
|
||||||
|
|
||||||
|
page.on('download', async (download) => {
|
||||||
|
if (downloadCnt === 0) {
|
||||||
|
downloadResolve1(download)
|
||||||
|
}
|
||||||
|
downloadCnt++
|
||||||
|
})
|
||||||
|
await page.getByRole('button', { name: 'Submit command' }).click()
|
||||||
|
|
||||||
|
// Handle download
|
||||||
|
const download = await downloadPromise1
|
||||||
|
const downloadLocationer = (extra = '', isImage = false) =>
|
||||||
|
`./e2e/playwright/export-snapshots/${output.type}-${
|
||||||
|
'storage' in output ? output.storage : ''
|
||||||
|
}${extra}.${isImage ? 'png' : output.type}`
|
||||||
|
const downloadLocation = downloadLocationer()
|
||||||
|
|
||||||
|
await download.saveAs(downloadLocation)
|
||||||
|
|
||||||
|
if (output.type === 'step') {
|
||||||
|
// stable timestamps for step files
|
||||||
|
const fileContents = await fsp.readFile(downloadLocation, 'utf-8')
|
||||||
|
const newFileContents = fileContents.replace(
|
||||||
|
/[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]+[0-9]+[0-9]\+[0-9]{2}:[0-9]{2}/g,
|
||||||
|
'1970-01-01T00:00:00.0+00:00'
|
||||||
|
)
|
||||||
|
await fsp.writeFile(downloadLocation, newFileContents)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
modelPath: downloadLocation,
|
||||||
|
imagePath: downloadLocationer('', true),
|
||||||
|
outputType: output.type,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the appropriate modifier key for the platform.
|
||||||
|
*/
|
||||||
|
export const metaModifier = os.platform() === 'darwin' ? 'Meta' : 'Control'
|
||||||
|
@ -1,31 +1,23 @@
|
|||||||
import { browser, $, expect } from '@wdio/globals'
|
import { browser, $, expect } from '@wdio/globals'
|
||||||
import fs from 'fs/promises'
|
import fs from 'fs/promises'
|
||||||
|
import path from 'path'
|
||||||
|
import os from 'os'
|
||||||
|
import { click, setDatasetValue } from '../utils'
|
||||||
|
|
||||||
const documentsDir = `${process.env.HOME}/Documents`
|
const isWin32 = os.platform() === 'win32'
|
||||||
const userSettingsDir = `${process.env.HOME}/.config/dev.zoo.modeling-app`
|
const documentsDir = path.join(os.homedir(), 'Documents')
|
||||||
const defaultProjectDir = `${documentsDir}/zoo-modeling-app-projects`
|
const userSettingsDir = path.join(
|
||||||
const newProjectDir = `${documentsDir}/a-different-directory`
|
os.homedir(),
|
||||||
const userCodeDir = '/tmp/kittycad_user_code'
|
'.config',
|
||||||
|
'dev.zoo.modeling-app'
|
||||||
|
)
|
||||||
|
const defaultProjectDir = path.join(documentsDir, 'zoo-modeling-app-projects')
|
||||||
|
const newProjectDir = path.join(documentsDir, 'a-different-directory')
|
||||||
|
const tmp = process.env.TEMP || '/tmp'
|
||||||
|
const userCodeDir = path.join(tmp, 'kittycad_user_code')
|
||||||
|
|
||||||
async function click(element: WebdriverIO.Element): Promise<void> {
|
describe('ZMA sign in flow', () => {
|
||||||
// Workaround for .click(), see https://github.com/tauri-apps/tauri/issues/6541
|
before(async () => {
|
||||||
await element.waitForClickable()
|
|
||||||
await browser.execute('arguments[0].click();', element)
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Shoutout to @Sheap on Github for a great workaround utility:
|
|
||||||
* https://github.com/tauri-apps/tauri/issues/6541#issue-1638944060
|
|
||||||
*/
|
|
||||||
async function setDatasetValue(
|
|
||||||
field: WebdriverIO.Element,
|
|
||||||
property: string,
|
|
||||||
value: string
|
|
||||||
) {
|
|
||||||
await browser.execute(`arguments[0].dataset.${property} = "${value}"`, field)
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('ZMA (Tauri, Linux)', () => {
|
|
||||||
it('opens the auth page and signs in', async () => {
|
|
||||||
// Clean up filesystem from previous tests
|
// Clean up filesystem from previous tests
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||||
await fs.rm(defaultProjectDir, { force: true, recursive: true })
|
await fs.rm(defaultProjectDir, { force: true, recursive: true })
|
||||||
@ -34,7 +26,9 @@ describe('ZMA (Tauri, Linux)', () => {
|
|||||||
await fs.rm(userSettingsDir, { force: true, recursive: true })
|
await fs.rm(userSettingsDir, { force: true, recursive: true })
|
||||||
await fs.mkdir(defaultProjectDir, { recursive: true })
|
await fs.mkdir(defaultProjectDir, { recursive: true })
|
||||||
await fs.mkdir(newProjectDir, { recursive: true })
|
await fs.mkdir(newProjectDir, { recursive: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('opens the auth page and signs in', async () => {
|
||||||
const signInButton = await $('[data-testid="sign-in-button"]')
|
const signInButton = await $('[data-testid="sign-in-button"]')
|
||||||
expect(await signInButton.getText()).toEqual('Sign in')
|
expect(await signInButton.getText()).toEqual('Sign in')
|
||||||
|
|
||||||
@ -42,9 +36,7 @@ describe('ZMA (Tauri, Linux)', () => {
|
|||||||
await new Promise((resolve) => setTimeout(resolve, 2000))
|
await new Promise((resolve) => setTimeout(resolve, 2000))
|
||||||
|
|
||||||
// Get from main.rs
|
// Get from main.rs
|
||||||
const userCode = await (
|
const userCode = await (await fs.readFile(userCodeDir)).toString()
|
||||||
await fs.readFile('/tmp/kittycad_user_code')
|
|
||||||
).toString()
|
|
||||||
console.log(`Found user code ${userCode}`)
|
console.log(`Found user code ${userCode}`)
|
||||||
|
|
||||||
// Device flow: verify
|
// Device flow: verify
|
||||||
@ -76,6 +68,10 @@ describe('ZMA (Tauri, Linux)', () => {
|
|||||||
const newFileButton = await $('[data-testid="home-new-file"]')
|
const newFileButton = await $('[data-testid="home-new-file"]')
|
||||||
expect(await newFileButton.getText()).toEqual('New project')
|
expect(await newFileButton.getText()).toEqual('New project')
|
||||||
})
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('ZMA authorized user flows', () => {
|
||||||
|
// Note: each flow below is intended to start *and* end from the home page
|
||||||
|
|
||||||
it('opens the settings page, checks filesystem settings, and closes the settings page', async () => {
|
it('opens the settings page, checks filesystem settings, and closes the settings page', async () => {
|
||||||
const menuButton = await $('[data-testid="user-sidebar-toggle"]')
|
const menuButton = await $('[data-testid="user-sidebar-toggle"]')
|
||||||
@ -92,7 +88,12 @@ describe('ZMA (Tauri, Linux)', () => {
|
|||||||
* to be able to skip the folder selection dialog if data-testValue
|
* to be able to skip the folder selection dialog if data-testValue
|
||||||
* has a value, allowing us to test the input otherwise works.
|
* has a value, allowing us to test the input otherwise works.
|
||||||
*/
|
*/
|
||||||
await setDatasetValue(projectDirInput, 'testValue', newProjectDir)
|
// TODO: understand why we need to force double \ on Windows
|
||||||
|
await setDatasetValue(
|
||||||
|
projectDirInput,
|
||||||
|
'testValue',
|
||||||
|
isWin32 ? newProjectDir.replaceAll('\\', '\\\\') : newProjectDir
|
||||||
|
)
|
||||||
const projectDirButton = await $('[data-testid="project-directory-button"]')
|
const projectDirButton = await $('[data-testid="project-directory-button"]')
|
||||||
await click(projectDirButton)
|
await click(projectDirButton)
|
||||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||||
@ -102,6 +103,15 @@ describe('ZMA (Tauri, Linux)', () => {
|
|||||||
const nameInput = await $('[data-testid="projects-defaultProjectName"]')
|
const nameInput = await $('[data-testid="projects-defaultProjectName"]')
|
||||||
expect(await nameInput.getValue()).toEqual('project-$nnn')
|
expect(await nameInput.getValue()).toEqual('project-$nnn')
|
||||||
|
|
||||||
|
// Setting it back (for back to back local tests)
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 5000))
|
||||||
|
await setDatasetValue(
|
||||||
|
projectDirInput,
|
||||||
|
'testValue',
|
||||||
|
isWin32 ? defaultProjectDir.replaceAll('\\', '\\\\') : newProjectDir
|
||||||
|
)
|
||||||
|
await click(projectDirButton)
|
||||||
|
|
||||||
const closeButton = await $('[data-testid="settings-close-button"]')
|
const closeButton = await $('[data-testid="settings-close-button"]')
|
||||||
await click(closeButton)
|
await click(closeButton)
|
||||||
})
|
})
|
||||||
@ -120,12 +130,21 @@ describe('ZMA (Tauri, Linux)', () => {
|
|||||||
it('opens the new file and expects a loading stream', async () => {
|
it('opens the new file and expects a loading stream', async () => {
|
||||||
const projectLink = await $('[data-testid="project-link"]')
|
const projectLink = await $('[data-testid="project-link"]')
|
||||||
await click(projectLink)
|
await click(projectLink)
|
||||||
const errorText = await $('[data-testid="unexpected-error"]')
|
if (isWin32) {
|
||||||
expect(await errorText.getText()).toContain('unexpected error')
|
// TODO: actually do something to check that the stream is up
|
||||||
await browser.execute('window.location.href = "tauri://localhost/home"')
|
await new Promise((resolve) => setTimeout(resolve, 5000))
|
||||||
|
} else {
|
||||||
|
const errorText = await $('[data-testid="unexpected-error"]')
|
||||||
|
expect(await errorText.getText()).toContain('unexpected error')
|
||||||
|
}
|
||||||
|
const base = isWin32 ? 'http://tauri.localhost' : 'tauri://localhost'
|
||||||
|
await browser.execute(`window.location.href = "${base}/home"`)
|
||||||
})
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('ZMA sign out flow', () => {
|
||||||
it('signs out', async () => {
|
it('signs out', async () => {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||||
const menuButton = await $('[data-testid="user-sidebar-toggle"]')
|
const menuButton = await $('[data-testid="user-sidebar-toggle"]')
|
||||||
await click(menuButton)
|
await click(menuButton)
|
||||||
const signoutButton = await $('[data-testid="user-sidebar-sign-out"]')
|
const signoutButton = await $('[data-testid="user-sidebar-sign-out"]')
|
18
e2e/tauri/utils.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { browser } from '@wdio/globals'
|
||||||
|
|
||||||
|
export async function click(element: WebdriverIO.Element): Promise<void> {
|
||||||
|
// Workaround for .click(), see https://github.com/tauri-apps/tauri/issues/6541
|
||||||
|
await element.waitForClickable()
|
||||||
|
await browser.execute('arguments[0].click();', element)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Shoutout to @Sheap on Github for a great workaround utility:
|
||||||
|
* https://github.com/tauri-apps/tauri/issues/6541#issue-1638944060
|
||||||
|
*/
|
||||||
|
export async function setDatasetValue(
|
||||||
|
field: WebdriverIO.Element,
|
||||||
|
property: string,
|
||||||
|
value: string
|
||||||
|
) {
|
||||||
|
await browser.execute(`arguments[0].dataset.${property} = "${value}"`, field)
|
||||||
|
}
|
62
flake.lock
generated
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
{
|
||||||
|
"nodes": {
|
||||||
|
"nixpkgs": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1718470082,
|
||||||
|
"narHash": "sha256-u2F0MMYE+Efc+ocruTbtU/wWHuYHWcJafp5zJ++n/YE=",
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "3027ba73dfef68eb555fc2fa97aed4e999e74f97",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "NixOS",
|
||||||
|
"ref": "nixpkgs-unstable",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nixpkgs_2": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1718428119,
|
||||||
|
"narHash": "sha256-WdWDpNaq6u1IPtxtYHHWpl5BmabtpmLnMAx0RdJ/vo8=",
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "e6cea36f83499eb4e9cd184c8a8e823296b50ad5",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "NixOS",
|
||||||
|
"ref": "nixpkgs-unstable",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs": "nixpkgs",
|
||||||
|
"rust-overlay": "rust-overlay"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"rust-overlay": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs": "nixpkgs_2"
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1718681902,
|
||||||
|
"narHash": "sha256-E/T7Ge6ayEQe7FVKMJqDBoHyLhRhjc6u9CmU8MyYfy0=",
|
||||||
|
"owner": "oxalica",
|
||||||
|
"repo": "rust-overlay",
|
||||||
|
"rev": "16c8ad83297c278eebe740dea5491c1708960dd1",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "oxalica",
|
||||||
|
"repo": "rust-overlay",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": "root",
|
||||||
|
"version": 7
|
||||||
|
}
|
70
flake.nix
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
{
|
||||||
|
description = "modeling-app development environment";
|
||||||
|
|
||||||
|
# Flake inputs
|
||||||
|
inputs = {
|
||||||
|
nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
|
||||||
|
rust-overlay.url = "github:oxalica/rust-overlay"; # A helper for Rust + Nix
|
||||||
|
};
|
||||||
|
|
||||||
|
# Flake outputs
|
||||||
|
outputs = { self, nixpkgs, rust-overlay }:
|
||||||
|
let
|
||||||
|
# Overlays enable you to customize the Nixpkgs attribute set
|
||||||
|
overlays = [
|
||||||
|
# Makes a `rust-bin` attribute available in Nixpkgs
|
||||||
|
(import rust-overlay)
|
||||||
|
# Provides a `rustToolchain` attribute for Nixpkgs that we can use to
|
||||||
|
# create a Rust environment
|
||||||
|
(self: super: {
|
||||||
|
rustToolchain = super. rust-bin.stable.latest.default.override {
|
||||||
|
targets = [ "wasm32-unknown-unknown" ];
|
||||||
|
extensions = [ "rustfmt" "llvm-tools-preview" ];
|
||||||
|
};
|
||||||
|
})
|
||||||
|
];
|
||||||
|
|
||||||
|
# Systems supported
|
||||||
|
allSystems = [
|
||||||
|
"x86_64-linux" # 64-bit Intel/AMD Linux
|
||||||
|
"aarch64-linux" # 64-bit ARM Linux
|
||||||
|
"x86_64-darwin" # 64-bit Intel macOS
|
||||||
|
"aarch64-darwin" # 64-bit ARM macOS
|
||||||
|
];
|
||||||
|
|
||||||
|
# Helper to provide system-specific attributes
|
||||||
|
forAllSystems = f: nixpkgs.lib.genAttrs allSystems (system: f {
|
||||||
|
pkgs = import nixpkgs { inherit overlays system; };
|
||||||
|
});
|
||||||
|
|
||||||
|
in
|
||||||
|
{
|
||||||
|
# Development environment output
|
||||||
|
devShells = forAllSystems ({ pkgs }: {
|
||||||
|
default = pkgs.mkShell {
|
||||||
|
# The Nix packages provided in the environment
|
||||||
|
packages = (with pkgs; [
|
||||||
|
# The package provided by our custom overlay. Includes cargo, Clippy, cargo-fmt,
|
||||||
|
# rustdoc, rustfmt, and other tools.
|
||||||
|
rustToolchain
|
||||||
|
|
||||||
|
cargo-llvm-cov
|
||||||
|
cargo-nextest
|
||||||
|
|
||||||
|
just
|
||||||
|
postgresql.lib
|
||||||
|
openssl
|
||||||
|
pkg-config
|
||||||
|
|
||||||
|
nodejs_22
|
||||||
|
]) ++ pkgs.lib.optionals pkgs.stdenv.isDarwin (with pkgs; [
|
||||||
|
libiconv
|
||||||
|
darwin.apple_sdk.frameworks.Security
|
||||||
|
]);
|
||||||
|
|
||||||
|
TARGET_CC = "${pkgs.stdenv.cc}/bin/${pkgs.stdenv.cc.targetPrefix}cc";
|
||||||
|
LIBCLANG_PATH = "${pkgs.libclang.lib}/lib";
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "untitled-app",
|
"name": "untitled-app",
|
||||||
"version": "0.21.9",
|
"version": "0.22.3",
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/autocomplete": "^6.16.0",
|
"@codemirror/autocomplete": "^6.16.0",
|
||||||
@ -10,7 +10,7 @@
|
|||||||
"@fortawesome/react-fontawesome": "^0.2.0",
|
"@fortawesome/react-fontawesome": "^0.2.0",
|
||||||
"@headlessui/react": "^1.7.19",
|
"@headlessui/react": "^1.7.19",
|
||||||
"@headlessui/tailwindcss": "^0.2.0",
|
"@headlessui/tailwindcss": "^0.2.0",
|
||||||
"@kittycad/lib": "^0.0.63",
|
"@kittycad/lib": "^0.0.67",
|
||||||
"@lezer/javascript": "^1.4.9",
|
"@lezer/javascript": "^1.4.9",
|
||||||
"@open-rpc/client-js": "^1.8.1",
|
"@open-rpc/client-js": "^1.8.1",
|
||||||
"@react-hook/resize-observer": "^2.0.1",
|
"@react-hook/resize-observer": "^2.0.1",
|
||||||
@ -95,7 +95,8 @@
|
|||||||
"lint": "eslint --fix src",
|
"lint": "eslint --fix src",
|
||||||
"bump-jsons": "echo \"$(jq --arg v \"$VERSION\" '.version=$v' package.json --indent 2)\" > package.json && echo \"$(jq --arg v \"$VERSION\" '.version=$v' src-tauri/tauri.conf.json --indent 2)\" > src-tauri/tauri.conf.json",
|
"bump-jsons": "echo \"$(jq --arg v \"$VERSION\" '.version=$v' package.json --indent 2)\" > package.json && echo \"$(jq --arg v \"$VERSION\" '.version=$v' src-tauri/tauri.conf.json --indent 2)\" > src-tauri/tauri.conf.json",
|
||||||
"postinstall": "yarn xstate:typegen",
|
"postinstall": "yarn xstate:typegen",
|
||||||
"xstate:typegen": "yarn xstate typegen \"src/**/*.ts?(x)\""
|
"xstate:typegen": "yarn xstate typegen \"src/**/*.ts?(x)\"",
|
||||||
|
"make:dev": "make dev"
|
||||||
},
|
},
|
||||||
"prettier": {
|
"prettier": {
|
||||||
"trailingComma": "es5",
|
"trailingComma": "es5",
|
||||||
|
@ -12,12 +12,12 @@ import { defineConfig, devices } from '@playwright/test'
|
|||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: './e2e/playwright',
|
testDir: './e2e/playwright',
|
||||||
/* Run tests in files in parallel */
|
/* Run tests in files in parallel */
|
||||||
fullyParallel: true,
|
fullyParallel: false,
|
||||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||||
forbidOnly: !!process.env.CI,
|
forbidOnly: !!process.env.CI,
|
||||||
/* Retry on CI only */
|
/* Retry on CI only */
|
||||||
retries: process.env.CI ? 3 : 0,
|
retries: process.env.CI ? 3 : 0,
|
||||||
/* Opt out of parallel tests on CI. */
|
/* Different amount of parallelism on CI and local. */
|
||||||
workers: process.env.CI ? 1 : 1,
|
workers: process.env.CI ? 1 : 1,
|
||||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||||
reporter: 'html',
|
reporter: 'html',
|
||||||
@ -34,7 +34,14 @@ export default defineConfig({
|
|||||||
projects: [
|
projects: [
|
||||||
{
|
{
|
||||||
name: 'Google Chrome',
|
name: 'Google Chrome',
|
||||||
use: { ...devices['Desktop Chrome'], channel: 'chrome' }, // or 'chrome-beta'
|
use: {
|
||||||
|
...devices['Desktop Chrome'],
|
||||||
|
channel: 'chrome',
|
||||||
|
contextOptions: {
|
||||||
|
/* Chromium is the only one with these permission types */
|
||||||
|
permissions: ['clipboard-write', 'clipboard-read'],
|
||||||
|
},
|
||||||
|
}, // or 'chrome-beta'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'webkit',
|
name: 'webkit',
|
||||||
@ -72,7 +79,7 @@ export default defineConfig({
|
|||||||
|
|
||||||
/* Run your local dev server before starting the tests */
|
/* Run your local dev server before starting the tests */
|
||||||
webServer: {
|
webServer: {
|
||||||
command: 'yarn serve',
|
command: 'yarn start',
|
||||||
// url: 'http://127.0.0.1:3000',
|
// url: 'http://127.0.0.1:3000',
|
||||||
reuseExistingServer: !process.env.CI,
|
reuseExistingServer: !process.env.CI,
|
||||||
},
|
},
|
||||||
|
1504
src-tauri/Cargo.lock
generated
@ -16,11 +16,11 @@ tauri-build = { version = "2.0.0-beta.13", features = [] }
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
kcl-lib = { version = "0.1.53", path = "../src/wasm-lib/kcl" }
|
kcl-lib = { version = "0.1.53", path = "../src/wasm-lib/kcl" }
|
||||||
kittycad = "0.3.0"
|
kittycad = "0.3.5"
|
||||||
log = "0.4.21"
|
log = "0.4.21"
|
||||||
oauth2 = "4.4.2"
|
oauth2 = "4.4.2"
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
tauri = { version = "2.0.0-beta.15", features = [ "devtools", "unstable"] }
|
tauri = { version = "2.0.0-beta.22", features = [ "devtools", "unstable"] }
|
||||||
tauri-plugin-cli = { version = "2.0.0-beta.3" }
|
tauri-plugin-cli = { version = "2.0.0-beta.3" }
|
||||||
tauri-plugin-deep-link = { version = "2.0.0-beta.3" }
|
tauri-plugin-deep-link = { version = "2.0.0-beta.3" }
|
||||||
tauri-plugin-dialog = { version = "2.0.0-beta.6" }
|
tauri-plugin-dialog = { version = "2.0.0-beta.6" }
|
||||||
|
@ -267,7 +267,15 @@ async fn login(app: tauri::AppHandle, host: &str) -> Result<String, InvokeError>
|
|||||||
let e2e_tauri_enabled = env::var("E2E_TAURI_ENABLED").is_ok();
|
let e2e_tauri_enabled = env::var("E2E_TAURI_ENABLED").is_ok();
|
||||||
if e2e_tauri_enabled {
|
if e2e_tauri_enabled {
|
||||||
log::warn!("E2E_TAURI_ENABLED is set, won't open {} externally", auth_uri.secret());
|
log::warn!("E2E_TAURI_ENABLED is set, won't open {} externally", auth_uri.secret());
|
||||||
tokio::fs::write("/tmp/kittycad_user_code", details.user_code().secret())
|
let mut temp = String::from("/tmp");
|
||||||
|
// Overwrite with Windows variable
|
||||||
|
match env::var("TEMP") {
|
||||||
|
Ok(val) => temp = val,
|
||||||
|
Err(_e) => println!("Fallback to default /tmp"),
|
||||||
|
}
|
||||||
|
let path = Path::new(&temp).join("kittycad_user_code");
|
||||||
|
println!("Writing to {}", path.to_string_lossy());
|
||||||
|
tokio::fs::write(path, details.user_code().secret())
|
||||||
.await
|
.await
|
||||||
.map_err(|e| InvokeError::from_anyhow(e.into()))?;
|
.map_err(|e| InvokeError::from_anyhow(e.into()))?;
|
||||||
} else {
|
} else {
|
||||||
|
@ -63,16 +63,17 @@
|
|||||||
"subcommands": {}
|
"subcommands": {}
|
||||||
},
|
},
|
||||||
"deep-link": {
|
"deep-link": {
|
||||||
"domains": [
|
"mobile": [],
|
||||||
{
|
"desktop": {
|
||||||
"host": "app.zoo.dev"
|
"schemes": [
|
||||||
}
|
"app.zoo.dev"
|
||||||
]
|
]
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"shell": {
|
"shell": {
|
||||||
"open": true
|
"open": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"productName": "Zoo Modeling App",
|
"productName": "Zoo Modeling App",
|
||||||
"version": "0.21.9"
|
"version": "0.22.3"
|
||||||
}
|
}
|
||||||
|
@ -127,7 +127,7 @@ export function App() {
|
|||||||
/>
|
/>
|
||||||
<ModalContainer />
|
<ModalContainer />
|
||||||
<ModelingSidebar paneOpacity={paneOpacity} />
|
<ModelingSidebar paneOpacity={paneOpacity} />
|
||||||
<Stream className="absolute inset-0 z-0" />
|
<Stream />
|
||||||
{/* <CamToggle /> */}
|
{/* <CamToggle /> */}
|
||||||
<LowerRightControls>
|
<LowerRightControls>
|
||||||
<Gizmo />
|
<Gizmo />
|
||||||
|
@ -12,6 +12,8 @@ import SignIn from './routes/SignIn'
|
|||||||
import { Auth } from './Auth'
|
import { Auth } from './Auth'
|
||||||
import { isTauri } from './lib/isTauri'
|
import { isTauri } from './lib/isTauri'
|
||||||
import Home from './routes/Home'
|
import Home from './routes/Home'
|
||||||
|
import { NetworkContext } from './hooks/useNetworkContext'
|
||||||
|
import { useNetworkStatus } from './hooks/useNetworkStatus'
|
||||||
import makeUrlPathRelative from './lib/makeUrlPathRelative'
|
import makeUrlPathRelative from './lib/makeUrlPathRelative'
|
||||||
import DownloadAppBanner from 'components/DownloadAppBanner'
|
import DownloadAppBanner from 'components/DownloadAppBanner'
|
||||||
import { WasmErrBanner } from 'components/WasmErrBanner'
|
import { WasmErrBanner } from 'components/WasmErrBanner'
|
||||||
@ -155,5 +157,11 @@ const router = createBrowserRouter([
|
|||||||
* @returns RouterProvider
|
* @returns RouterProvider
|
||||||
*/
|
*/
|
||||||
export const Router = () => {
|
export const Router = () => {
|
||||||
return <RouterProvider router={router} />
|
const networkStatus = useNetworkStatus()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NetworkContext.Provider value={networkStatus}>
|
||||||
|
<RouterProvider router={router} />
|
||||||
|
</NetworkContext.Provider>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
@ -3,13 +3,11 @@ import { isCursorInSketchCommandRange } from 'lang/util'
|
|||||||
import { engineCommandManager, kclManager } from 'lib/singletons'
|
import { engineCommandManager, kclManager } from 'lib/singletons'
|
||||||
import { useModelingContext } from 'hooks/useModelingContext'
|
import { useModelingContext } from 'hooks/useModelingContext'
|
||||||
import { useCommandsContext } from 'hooks/useCommandsContext'
|
import { useCommandsContext } from 'hooks/useCommandsContext'
|
||||||
|
import { useNetworkContext } from 'hooks/useNetworkContext'
|
||||||
|
import { NetworkHealthState } from 'hooks/useNetworkStatus'
|
||||||
import { ActionButton } from 'components/ActionButton'
|
import { ActionButton } from 'components/ActionButton'
|
||||||
import { isSingleCursorInPipe } from 'lang/queryAst'
|
import { isSingleCursorInPipe } from 'lang/queryAst'
|
||||||
import { useKclContext } from 'lang/KclProvider'
|
import { useKclContext } from 'lang/KclProvider'
|
||||||
import {
|
|
||||||
NetworkHealthState,
|
|
||||||
useNetworkStatus,
|
|
||||||
} from 'components/NetworkHealthIndicator'
|
|
||||||
import { useStore } from 'useStore'
|
import { useStore } from 'useStore'
|
||||||
import { ActionButtonDropdown } from 'components/ActionButtonDropdown'
|
import { ActionButtonDropdown } from 'components/ActionButtonDropdown'
|
||||||
import { useHotkeys } from 'react-hotkeys-hook'
|
import { useHotkeys } from 'react-hotkeys-hook'
|
||||||
@ -38,14 +36,16 @@ export function Toolbar({
|
|||||||
}, [engineCommandManager.artifactMap, context.selectionRanges])
|
}, [engineCommandManager.artifactMap, context.selectionRanges])
|
||||||
|
|
||||||
const toolbarButtonsRef = useRef<HTMLUListElement>(null)
|
const toolbarButtonsRef = useRef<HTMLUListElement>(null)
|
||||||
|
const { overallState } = useNetworkContext()
|
||||||
const { overallState } = useNetworkStatus()
|
|
||||||
const { isExecuting } = useKclContext()
|
const { isExecuting } = useKclContext()
|
||||||
const { isStreamReady } = useStore((s) => ({
|
const { isStreamReady } = useStore((s) => ({
|
||||||
isStreamReady: s.isStreamReady,
|
isStreamReady: s.isStreamReady,
|
||||||
}))
|
}))
|
||||||
const disableAllButtons =
|
const disableAllButtons =
|
||||||
overallState !== NetworkHealthState.Ok || isExecuting || !isStreamReady
|
(overallState !== NetworkHealthState.Ok &&
|
||||||
|
overallState !== NetworkHealthState.Weak) ||
|
||||||
|
isExecuting ||
|
||||||
|
!isStreamReady
|
||||||
|
|
||||||
useHotkeys(
|
useHotkeys(
|
||||||
'l',
|
'l',
|
||||||
|
@ -48,12 +48,14 @@ export type ReactCameraProperties =
|
|||||||
type: 'perspective'
|
type: 'perspective'
|
||||||
fov?: number
|
fov?: number
|
||||||
position: [number, number, number]
|
position: [number, number, number]
|
||||||
|
target: [number, number, number]
|
||||||
quaternion: [number, number, number, number]
|
quaternion: [number, number, number, number]
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: 'orthographic'
|
type: 'orthographic'
|
||||||
zoom?: number
|
zoom?: number
|
||||||
position: [number, number, number]
|
position: [number, number, number]
|
||||||
|
target: [number, number, number]
|
||||||
quaternion: [number, number, number, number]
|
quaternion: [number, number, number, number]
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -172,41 +174,6 @@ export class CameraControls {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
throttledUpdateEngineFov = throttle(
|
|
||||||
(vals: {
|
|
||||||
position: Vector3
|
|
||||||
quaternion: Quaternion
|
|
||||||
zoom: number
|
|
||||||
fov: number
|
|
||||||
target: Vector3
|
|
||||||
}) => {
|
|
||||||
const cmd: EngineCommand = {
|
|
||||||
type: 'modeling_cmd_req',
|
|
||||||
cmd_id: uuidv4(),
|
|
||||||
cmd: {
|
|
||||||
type: 'default_camera_perspective_settings',
|
|
||||||
...convertThreeCamValuesToEngineCam({
|
|
||||||
...vals,
|
|
||||||
isPerspective: true,
|
|
||||||
}),
|
|
||||||
fov_y: vals.fov,
|
|
||||||
...calculateNearFarFromFOV(vals.fov),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
this.engineCommandManager.sendSceneCommand(cmd)
|
|
||||||
this.lastPerspectiveCmd = cmd
|
|
||||||
this.lastPerspectiveCmdTime = Date.now()
|
|
||||||
if (this.lastPerspectiveCmdTimeoutId !== null) {
|
|
||||||
clearTimeout(this.lastPerspectiveCmdTimeoutId)
|
|
||||||
}
|
|
||||||
this.lastPerspectiveCmdTimeoutId = setTimeout(
|
|
||||||
this.sendLastPerspectiveReliableChannel,
|
|
||||||
lastCmdDelay
|
|
||||||
) as any as number
|
|
||||||
},
|
|
||||||
1000 / 30
|
|
||||||
)
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
isOrtho = false,
|
isOrtho = false,
|
||||||
domElement: HTMLCanvasElement,
|
domElement: HTMLCanvasElement,
|
||||||
@ -442,7 +409,7 @@ export class CameraControls {
|
|||||||
this.handleEnd()
|
this.handleEnd()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
this.throttledEngCmd({
|
this.engineCommandManager.sendSceneCommand({
|
||||||
type: 'modeling_cmd_req',
|
type: 'modeling_cmd_req',
|
||||||
cmd: {
|
cmd: {
|
||||||
type: 'default_camera_zoom',
|
type: 'default_camera_zoom',
|
||||||
@ -454,11 +421,11 @@ export class CameraControls {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const isTrackpad = Math.abs(event.deltaY) <= 1 || event.deltaY % 1 === 0
|
// Else "clientToEngine" (Sketch Mode) or forceUpdate
|
||||||
|
|
||||||
const zoomSpeed = isTrackpad ? 0.02 : 0.1 // Reduced zoom speed for trackpad
|
// From onMouseMove zoom handling which seems to be really smooth
|
||||||
this.pendingZoom = this.pendingZoom ? this.pendingZoom : 1
|
this.pendingZoom = this.pendingZoom ? this.pendingZoom : 1
|
||||||
this.pendingZoom *= 1 + (event.deltaY > 0 ? zoomSpeed : -zoomSpeed)
|
this.pendingZoom *= 1 + event.deltaY * 0.01
|
||||||
this.handleEnd()
|
this.handleEnd()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -532,26 +499,28 @@ export class CameraControls {
|
|||||||
direction.normalize()
|
direction.normalize()
|
||||||
this.camera.position.copy(this.target).addScaledVector(direction, distance)
|
this.camera.position.copy(this.target).addScaledVector(direction, distance)
|
||||||
}
|
}
|
||||||
usePerspectiveCamera = () => {
|
usePerspectiveCamera = async () => {
|
||||||
this._usePerspectiveCamera()
|
this._usePerspectiveCamera()
|
||||||
this.engineCommandManager.sendSceneCommand({
|
if (this.syncDirection === 'clientToEngine') {
|
||||||
type: 'modeling_cmd_req',
|
await this.engineCommandManager.sendSceneCommand({
|
||||||
cmd_id: uuidv4(),
|
type: 'modeling_cmd_req',
|
||||||
cmd: {
|
cmd_id: uuidv4(),
|
||||||
type: 'default_camera_set_perspective',
|
cmd: {
|
||||||
parameters: {
|
type: 'default_camera_set_perspective',
|
||||||
fov_y:
|
parameters: {
|
||||||
this.camera instanceof PerspectiveCamera ? this.camera.fov : 45,
|
fov_y:
|
||||||
...calculateNearFarFromFOV(this.lastPerspectiveFov),
|
this.camera instanceof PerspectiveCamera ? this.camera.fov : 45,
|
||||||
|
...calculateNearFarFromFOV(this.lastPerspectiveFov),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
})
|
||||||
})
|
}
|
||||||
this.onCameraChange()
|
this.onCameraChange()
|
||||||
this.update()
|
this.update()
|
||||||
return this.camera
|
return this.camera
|
||||||
}
|
}
|
||||||
|
|
||||||
dollyZoom = (newFov: number) => {
|
dollyZoom = async (newFov: number, splitEngineCalls = false) => {
|
||||||
if (!(this.camera instanceof PerspectiveCamera)) {
|
if (!(this.camera instanceof PerspectiveCamera)) {
|
||||||
console.warn('Dolly zoom is only applicable to perspective cameras.')
|
console.warn('Dolly zoom is only applicable to perspective cameras.')
|
||||||
return
|
return
|
||||||
@ -602,13 +571,52 @@ export class CameraControls {
|
|||||||
this.camera.near = z_near
|
this.camera.near = z_near
|
||||||
this.camera.far = z_far
|
this.camera.far = z_far
|
||||||
|
|
||||||
this.throttledUpdateEngineFov({
|
if (splitEngineCalls) {
|
||||||
fov: newFov,
|
await this.engineCommandManager.sendSceneCommand({
|
||||||
position: newPosition,
|
type: 'modeling_cmd_req',
|
||||||
quaternion: this.camera.quaternion,
|
cmd_id: uuidv4(),
|
||||||
zoom: this.camera.zoom,
|
cmd: {
|
||||||
target: this.target,
|
type: 'default_camera_look_at',
|
||||||
})
|
...convertThreeCamValuesToEngineCam({
|
||||||
|
isPerspective: true,
|
||||||
|
position: newPosition,
|
||||||
|
quaternion: this.camera.quaternion,
|
||||||
|
zoom: this.camera.zoom,
|
||||||
|
target: this.target,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await this.engineCommandManager.sendSceneCommand({
|
||||||
|
type: 'modeling_cmd_req',
|
||||||
|
cmd_id: uuidv4(),
|
||||||
|
cmd: {
|
||||||
|
type: 'default_camera_set_perspective',
|
||||||
|
parameters: {
|
||||||
|
fov_y: newFov,
|
||||||
|
z_near: 0.01,
|
||||||
|
z_far: 1000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
await this.engineCommandManager.sendSceneCommand({
|
||||||
|
type: 'modeling_cmd_req',
|
||||||
|
cmd_id: uuidv4(),
|
||||||
|
cmd: {
|
||||||
|
type: 'default_camera_perspective_settings',
|
||||||
|
...convertThreeCamValuesToEngineCam({
|
||||||
|
isPerspective: true,
|
||||||
|
position: newPosition,
|
||||||
|
quaternion: this.camera.quaternion,
|
||||||
|
zoom: this.camera.zoom,
|
||||||
|
target: this.target,
|
||||||
|
}),
|
||||||
|
fov_y: newFov,
|
||||||
|
z_near: 0.01,
|
||||||
|
z_far: 1000,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
update = (forceUpdate = false) => {
|
update = (forceUpdate = false) => {
|
||||||
@ -773,6 +781,75 @@ export class CameraControls {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async updateCameraToAxis(
|
||||||
|
axis: 'x' | 'y' | 'z' | '-x' | '-y' | '-z'
|
||||||
|
): Promise<void> {
|
||||||
|
const distance = this.camera.position.distanceTo(this.target)
|
||||||
|
|
||||||
|
const vantage = this.target.clone()
|
||||||
|
let up = { x: 0, y: 0, z: 1 }
|
||||||
|
|
||||||
|
if (axis === 'x') {
|
||||||
|
vantage.x += distance
|
||||||
|
} else if (axis === 'y') {
|
||||||
|
vantage.y += distance
|
||||||
|
} else if (axis === 'z') {
|
||||||
|
vantage.z += distance
|
||||||
|
up = { x: -1, y: 0, z: 0 }
|
||||||
|
} else if (axis === '-x') {
|
||||||
|
vantage.x -= distance
|
||||||
|
} else if (axis === '-y') {
|
||||||
|
vantage.y -= distance
|
||||||
|
} else if (axis === '-z') {
|
||||||
|
vantage.z -= distance
|
||||||
|
up = { x: -1, y: 0, z: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.engineCommandManager.sendSceneCommand({
|
||||||
|
type: 'modeling_cmd_req',
|
||||||
|
cmd_id: uuidv4(),
|
||||||
|
cmd: {
|
||||||
|
type: 'default_camera_look_at',
|
||||||
|
center: this.target,
|
||||||
|
vantage: vantage,
|
||||||
|
up: up,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await this.engineCommandManager.sendSceneCommand({
|
||||||
|
type: 'modeling_cmd_req',
|
||||||
|
cmd_id: uuidv4(),
|
||||||
|
cmd: {
|
||||||
|
type: 'default_camera_get_settings',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async resetCameraPosition(): Promise<void> {
|
||||||
|
await this.engineCommandManager.sendSceneCommand({
|
||||||
|
type: 'modeling_cmd_req',
|
||||||
|
cmd_id: uuidv4(),
|
||||||
|
cmd: {
|
||||||
|
type: 'default_camera_look_at',
|
||||||
|
center: this.target,
|
||||||
|
vantage: {
|
||||||
|
x: this.target.x,
|
||||||
|
y: this.target.y - 128,
|
||||||
|
z: this.target.z + 64,
|
||||||
|
},
|
||||||
|
up: { x: 0, y: 0, z: 1 },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await this.engineCommandManager.sendSceneCommand({
|
||||||
|
type: 'modeling_cmd_req',
|
||||||
|
cmd_id: uuidv4(),
|
||||||
|
cmd: {
|
||||||
|
type: 'zoom_to_fit',
|
||||||
|
object_ids: [], // leave empty to zoom to all objects
|
||||||
|
padding: 0.2, // padding around the objects
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
async tweenCameraToQuaternion(
|
async tweenCameraToQuaternion(
|
||||||
targetQuaternion: Quaternion,
|
targetQuaternion: Quaternion,
|
||||||
targetPosition = new Vector3(),
|
targetPosition = new Vector3(),
|
||||||
@ -944,6 +1021,29 @@ export class CameraControls {
|
|||||||
.onComplete(onComplete)
|
.onComplete(onComplete)
|
||||||
.start()
|
.start()
|
||||||
})
|
})
|
||||||
|
snapToPerspectiveBeforeHandingBackControlToEngine = async (
|
||||||
|
targetCamUp = new Vector3(0, 0, 1)
|
||||||
|
) => {
|
||||||
|
if (this.syncDirection === 'engineToClient') {
|
||||||
|
console.warn(
|
||||||
|
'animate To Perspective not design to work with engineToClient syncDirection.'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
this.isFovAnimationInProgress = true
|
||||||
|
const targetFov = this.fovBeforeOrtho // Target FOV for perspective
|
||||||
|
this.lastPerspectiveFov = 4
|
||||||
|
let currentFov = 4
|
||||||
|
const initialCameraUp = this.camera.up.clone()
|
||||||
|
this.usePerspectiveCamera()
|
||||||
|
const tempVec = new Vector3()
|
||||||
|
|
||||||
|
currentFov = this.lastPerspectiveFov + (targetFov - this.lastPerspectiveFov)
|
||||||
|
const currentUp = tempVec.lerpVectors(initialCameraUp, targetCamUp, 1)
|
||||||
|
this.camera.up.copy(currentUp)
|
||||||
|
await this.dollyZoom(currentFov, true)
|
||||||
|
|
||||||
|
this.isFovAnimationInProgress = false
|
||||||
|
}
|
||||||
|
|
||||||
get reactCameraProperties(): ReactCameraProperties {
|
get reactCameraProperties(): ReactCameraProperties {
|
||||||
return {
|
return {
|
||||||
@ -957,6 +1057,11 @@ export class CameraControls {
|
|||||||
roundOff(this.camera.position.y, 2),
|
roundOff(this.camera.position.y, 2),
|
||||||
roundOff(this.camera.position.z, 2),
|
roundOff(this.camera.position.z, 2),
|
||||||
],
|
],
|
||||||
|
target: [
|
||||||
|
roundOff(this.target.x, 2),
|
||||||
|
roundOff(this.target.y, 2),
|
||||||
|
roundOff(this.target.z, 2),
|
||||||
|
],
|
||||||
quaternion: [
|
quaternion: [
|
||||||
roundOff(this.camera.quaternion.x, 2),
|
roundOff(this.camera.quaternion.x, 2),
|
||||||
roundOff(this.camera.quaternion.y, 2),
|
roundOff(this.camera.quaternion.y, 2),
|
||||||
@ -1011,7 +1116,7 @@ function calculateNearFarFromFOV(fov: number) {
|
|||||||
// const nearFarRatio = (fov - 3) / (45 - 3)
|
// const nearFarRatio = (fov - 3) / (45 - 3)
|
||||||
// const z_near = 0.1 + nearFarRatio * (5 - 0.1)
|
// const z_near = 0.1 + nearFarRatio * (5 - 0.1)
|
||||||
// const z_far = 1000 + nearFarRatio * (100000 - 1000)
|
// const z_far = 1000 + nearFarRatio * (100000 - 1000)
|
||||||
return { z_near: 0.1, z_far: 1000 }
|
return { z_near: 0.01, z_far: 1000 }
|
||||||
}
|
}
|
||||||
|
|
||||||
function convertThreeCamValuesToEngineCam({
|
function convertThreeCamValuesToEngineCam({
|
||||||
@ -1030,11 +1135,6 @@ function convertThreeCamValuesToEngineCam({
|
|||||||
// leaving for now since it's working but maybe revisit later
|
// leaving for now since it's working but maybe revisit later
|
||||||
const euler = new Euler().setFromQuaternion(quaternion, 'XYZ')
|
const euler = new Euler().setFromQuaternion(quaternion, 'XYZ')
|
||||||
|
|
||||||
const lookAtVector = new Vector3(0, 0, -1)
|
|
||||||
.applyEuler(euler)
|
|
||||||
.normalize()
|
|
||||||
.add(position)
|
|
||||||
|
|
||||||
const upVector = new Vector3(0, 1, 0).applyEuler(euler).normalize()
|
const upVector = new Vector3(0, 1, 0).applyEuler(euler).normalize()
|
||||||
if (isPerspective) {
|
if (isPerspective) {
|
||||||
return {
|
return {
|
||||||
@ -1043,6 +1143,10 @@ function convertThreeCamValuesToEngineCam({
|
|||||||
vantage: position,
|
vantage: position,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const lookAtVector = new Vector3(0, 0, -1)
|
||||||
|
.applyEuler(euler)
|
||||||
|
.normalize()
|
||||||
|
.add(position)
|
||||||
const fudgeFactor2 = zoom * 0.9979224466814468 - 0.03473692325839295
|
const fudgeFactor2 = zoom * 0.9979224466814468 - 0.03473692325839295
|
||||||
const zoomFactor = (-ZOOM_MAGIC_NUMBER + fudgeFactor2) / zoom
|
const zoomFactor = (-ZOOM_MAGIC_NUMBER + fudgeFactor2) / zoom
|
||||||
const direction = lookAtVector.clone().sub(position).normalize()
|
const direction = lookAtVector.clone().sub(position).normalize()
|
||||||
|
@ -136,6 +136,7 @@ export const ClientSideScene = ({
|
|||||||
<div
|
<div
|
||||||
ref={canvasRef}
|
ref={canvasRef}
|
||||||
style={{ cursor: cursor }}
|
style={{ cursor: cursor }}
|
||||||
|
data-testid="client-side-scene"
|
||||||
className={`absolute inset-0 h-full w-full transition-all duration-300 ${
|
className={`absolute inset-0 h-full w-full transition-all duration-300 ${
|
||||||
hideClient ? 'opacity-0' : 'opacity-100'
|
hideClient ? 'opacity-0' : 'opacity-100'
|
||||||
} ${hideServer ? 'bg-chalkboard-10 dark:bg-chalkboard-100' : ''} ${
|
} ${hideServer ? 'bg-chalkboard-10 dark:bg-chalkboard-100' : ''} ${
|
||||||
@ -420,12 +421,16 @@ const SegmentMenu = ({
|
|||||||
verticalPosition === 'top' ? 'bottom-full' : 'top-full'
|
verticalPosition === 'top' ? 'bottom-full' : 'top-full'
|
||||||
} z-10 w-36 flex flex-col gap-1 divide-y divide-chalkboard-20 dark:divide-chalkboard-70 align-stretch px-0 py-1 bg-chalkboard-10 dark:bg-chalkboard-100 rounded-sm shadow-lg border border-solid border-chalkboard-20/50 dark:border-chalkboard-80/50`}
|
} z-10 w-36 flex flex-col gap-1 divide-y divide-chalkboard-20 dark:divide-chalkboard-70 align-stretch px-0 py-1 bg-chalkboard-10 dark:bg-chalkboard-100 rounded-sm shadow-lg border border-solid border-chalkboard-20/50 dark:border-chalkboard-80/50`}
|
||||||
>
|
>
|
||||||
{/* <button className="hover:bg-white/80 bg-white/50 rounded p-1 text-nowrap">
|
|
||||||
Remove segment constraints
|
|
||||||
</button> */}
|
|
||||||
<button
|
<button
|
||||||
className="!border-transparent rounded-sm text-left p-1 text-nowrap"
|
className="!border-transparent rounded-sm text-left p-1 text-nowrap"
|
||||||
// disabled={dependentSourceRanges.length > 0}
|
onClick={() => {
|
||||||
|
send({ type: 'Constrain remove constraints', data: pathToNode })
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Remove constraints
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="!border-transparent rounded-sm text-left p-1 text-nowrap"
|
||||||
title={
|
title={
|
||||||
dependentSourceRanges.length > 0
|
dependentSourceRanges.length > 0
|
||||||
? `At least ${dependentSourceRanges.length} segment rely on this segment's tag.`
|
? `At least ${dependentSourceRanges.length} segment rely on this segment's tag.`
|
||||||
@ -531,8 +536,7 @@ const ConstraintSymbol = ({
|
|||||||
varNameMap[_type as LineInputsType]?.implicitConstraintDesc
|
varNameMap[_type as LineInputsType]?.implicitConstraintDesc
|
||||||
|
|
||||||
const node = useMemo(
|
const node = useMemo(
|
||||||
() =>
|
() => getNodeFromPath<Value>(kclManager.ast, pathToNode).node,
|
||||||
getNodeFromPath<Value>(parse(recast(kclManager.ast)), pathToNode).node,
|
|
||||||
[kclManager.ast, pathToNode]
|
[kclManager.ast, pathToNode]
|
||||||
)
|
)
|
||||||
const range: SourceRange = node ? [node.start, node.end] : [0, 0]
|
const range: SourceRange = node ? [node.start, node.end] : [0, 0]
|
||||||
@ -696,6 +700,15 @@ export const CamDebugSettings = () => {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
sceneInfra.camControls.resetCameraPosition()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Reset Camera Position
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
{camSettings.type === 'perspective' && (
|
{camSettings.type === 'perspective' && (
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
@ -813,6 +826,71 @@ export const CamDebugSettings = () => {
|
|||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
target
|
||||||
|
<ul className="flex">
|
||||||
|
<li>
|
||||||
|
<span className="pl-2 pr-1">x:</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step={5}
|
||||||
|
data-testid="cam-x-target"
|
||||||
|
value={camSettings.target[0]}
|
||||||
|
className="text-black w-16"
|
||||||
|
onChange={(e) => {
|
||||||
|
sceneInfra.camControls.setCam({
|
||||||
|
...camSettings,
|
||||||
|
target: [
|
||||||
|
parseFloat(e.target.value),
|
||||||
|
camSettings.target[1],
|
||||||
|
camSettings.target[2],
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span className="pl-2 pr-1">y:</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step={5}
|
||||||
|
data-testid="cam-y-target"
|
||||||
|
value={camSettings.target[1]}
|
||||||
|
className="text-black w-16"
|
||||||
|
onChange={(e) => {
|
||||||
|
sceneInfra.camControls.setCam({
|
||||||
|
...camSettings,
|
||||||
|
target: [
|
||||||
|
camSettings.target[0],
|
||||||
|
parseFloat(e.target.value),
|
||||||
|
camSettings.target[2],
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span className="pl-2 pr-1">z:</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step={5}
|
||||||
|
data-testid="cam-z-target"
|
||||||
|
value={camSettings.target[2]}
|
||||||
|
className="text-black w-16"
|
||||||
|
onChange={(e) => {
|
||||||
|
sceneInfra.camControls.setCam({
|
||||||
|
...camSettings,
|
||||||
|
target: [
|
||||||
|
camSettings.target[0],
|
||||||
|
camSettings.target[1],
|
||||||
|
parseFloat(e.target.value),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -32,9 +32,7 @@ import {
|
|||||||
SKETCH_GROUP_SEGMENTS,
|
SKETCH_GROUP_SEGMENTS,
|
||||||
SKETCH_LAYER,
|
SKETCH_LAYER,
|
||||||
X_AXIS,
|
X_AXIS,
|
||||||
XZ_PLANE,
|
|
||||||
Y_AXIS,
|
Y_AXIS,
|
||||||
YZ_PLANE,
|
|
||||||
} from './sceneInfra'
|
} from './sceneInfra'
|
||||||
import { isQuaternionVertical, quaternionFromUpNForward } from './helpers'
|
import { isQuaternionVertical, quaternionFromUpNForward } from './helpers'
|
||||||
import {
|
import {
|
||||||
@ -93,7 +91,10 @@ import { createGridHelper, orthoScale, perspScale } from './helpers'
|
|||||||
import { Models } from '@kittycad/lib'
|
import { Models } from '@kittycad/lib'
|
||||||
import { uuidv4 } from 'lib/utils'
|
import { uuidv4 } from 'lib/utils'
|
||||||
import { SegmentOverlayPayload, SketchDetails } from 'machines/modelingMachine'
|
import { SegmentOverlayPayload, SketchDetails } from 'machines/modelingMachine'
|
||||||
import { EngineCommandManager } from 'lang/std/engineConnection'
|
import {
|
||||||
|
ArtifactMapCommand,
|
||||||
|
EngineCommandManager,
|
||||||
|
} from 'lang/std/engineConnection'
|
||||||
import {
|
import {
|
||||||
getRectangleCallExpressions,
|
getRectangleCallExpressions,
|
||||||
updateRectangleSketch,
|
updateRectangleSketch,
|
||||||
@ -759,14 +760,6 @@ export class SceneEntities {
|
|||||||
|
|
||||||
_ast = parse(recast(_ast))
|
_ast = parse(recast(_ast))
|
||||||
|
|
||||||
console.log('onClick', {
|
|
||||||
sketchInit: sketchInit,
|
|
||||||
_ast,
|
|
||||||
x,
|
|
||||||
y,
|
|
||||||
truncatedAst,
|
|
||||||
})
|
|
||||||
|
|
||||||
// Update the primary AST and unequip the rectangle tool
|
// Update the primary AST and unequip the rectangle tool
|
||||||
await kclManager.executeAstMock(_ast)
|
await kclManager.executeAstMock(_ast)
|
||||||
sceneInfra.modelingSend({ type: 'CancelSketch' })
|
sceneInfra.modelingSend({ type: 'CancelSketch' })
|
||||||
@ -1334,13 +1327,6 @@ export class SceneEntities {
|
|||||||
to,
|
to,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
async animateAfterSketch() {
|
|
||||||
// if (isReducedMotion()) {
|
|
||||||
// sceneInfra.camControls.usePerspectiveCamera()
|
|
||||||
// return
|
|
||||||
// }
|
|
||||||
await sceneInfra.camControls.animateToPerspective()
|
|
||||||
}
|
|
||||||
removeSketchGrid() {
|
removeSketchGrid() {
|
||||||
if (this.axisGroup) this.scene.remove(this.axisGroup)
|
if (this.axisGroup) this.scene.remove(this.axisGroup)
|
||||||
}
|
}
|
||||||
@ -1404,87 +1390,135 @@ export class SceneEntities {
|
|||||||
selected.material.color = defaultPlaneColor(type)
|
selected.material.color = defaultPlaneColor(type)
|
||||||
},
|
},
|
||||||
onClick: async (args) => {
|
onClick: async (args) => {
|
||||||
const checkExtrudeFaceClick = async (): Promise<
|
const { streamDimensions } = useStore.getState()
|
||||||
['face' | 'plane' | 'other', string]
|
const { entity_id, ...rest } = await sendSelectEventToEngine(
|
||||||
> => {
|
args?.mouseEvent,
|
||||||
const { streamDimensions } = useStore.getState()
|
document.getElementById('video-stream') as HTMLVideoElement,
|
||||||
const { entity_id } = await sendSelectEventToEngine(
|
streamDimensions
|
||||||
args?.mouseEvent,
|
)
|
||||||
document.getElementById('video-stream') as HTMLVideoElement,
|
let _entity_id = entity_id
|
||||||
streamDimensions
|
console.log('things', _entity_id, rest)
|
||||||
)
|
if (!_entity_id) return
|
||||||
if (!entity_id) return ['other', '']
|
if (
|
||||||
if (
|
engineCommandManager.defaultPlanes?.xy === _entity_id ||
|
||||||
engineCommandManager.defaultPlanes?.xy === entity_id ||
|
engineCommandManager.defaultPlanes?.xz === _entity_id ||
|
||||||
engineCommandManager.defaultPlanes?.xz === entity_id ||
|
engineCommandManager.defaultPlanes?.yz === _entity_id ||
|
||||||
engineCommandManager.defaultPlanes?.yz === entity_id
|
engineCommandManager.defaultPlanes?.negXy === _entity_id ||
|
||||||
) {
|
engineCommandManager.defaultPlanes?.negXz === _entity_id ||
|
||||||
return ['plane', entity_id]
|
engineCommandManager.defaultPlanes?.negYz === _entity_id
|
||||||
|
) {
|
||||||
|
const defaultPlaneStrMap: Record<string, DefaultPlaneStr> = {
|
||||||
|
[engineCommandManager.defaultPlanes.xy]: 'XY',
|
||||||
|
[engineCommandManager.defaultPlanes.xz]: 'XZ',
|
||||||
|
[engineCommandManager.defaultPlanes.yz]: 'YZ',
|
||||||
|
[engineCommandManager.defaultPlanes.negXy]: '-XY',
|
||||||
|
[engineCommandManager.defaultPlanes.negXz]: '-XZ',
|
||||||
|
[engineCommandManager.defaultPlanes.negYz]: '-YZ',
|
||||||
}
|
}
|
||||||
const artifact = this.engineCommandManager.artifactMap[entity_id]
|
// TODO can we get this information from rust land when it creates the default planes?
|
||||||
if (artifact?.commandType !== 'solid3d_get_extrusion_face_info')
|
// maybe returned from make_default_planes (src/wasm-lib/src/wasm.rs)
|
||||||
return ['other', entity_id]
|
let zAxis: [number, number, number] = [0, 0, 1]
|
||||||
|
let yAxis: [number, number, number] = [0, 1, 0]
|
||||||
|
|
||||||
const faceInfo = await getFaceDetails(entity_id)
|
// get unit vector from camera position to target
|
||||||
if (!faceInfo?.origin || !faceInfo?.z_axis || !faceInfo?.y_axis)
|
const camVector = sceneInfra.camControls.camera.position
|
||||||
return ['other', entity_id]
|
.clone()
|
||||||
const { z_axis, y_axis, origin } = faceInfo
|
.sub(sceneInfra.camControls.target)
|
||||||
const pathToNode = getNodePathFromSourceRange(
|
|
||||||
kclManager.ast,
|
if (engineCommandManager.defaultPlanes?.xy === _entity_id) {
|
||||||
artifact.range
|
console.log('XY')
|
||||||
)
|
zAxis = [0, 0, 1]
|
||||||
|
yAxis = [0, 1, 0]
|
||||||
|
if (camVector.z < 0) {
|
||||||
|
zAxis = [0, 0, -1]
|
||||||
|
_entity_id = engineCommandManager.defaultPlanes?.negXy || ''
|
||||||
|
}
|
||||||
|
} else if (engineCommandManager.defaultPlanes?.yz === _entity_id) {
|
||||||
|
console.log('YZ')
|
||||||
|
zAxis = [1, 0, 0]
|
||||||
|
yAxis = [0, 0, 1]
|
||||||
|
if (camVector.x < 0) {
|
||||||
|
zAxis = [-1, 0, 0]
|
||||||
|
_entity_id = engineCommandManager.defaultPlanes?.negYz || ''
|
||||||
|
}
|
||||||
|
} else if (engineCommandManager.defaultPlanes?.xz === _entity_id) {
|
||||||
|
console.log('XZ')
|
||||||
|
zAxis = [0, 1, 0]
|
||||||
|
yAxis = [0, 0, 1]
|
||||||
|
_entity_id = engineCommandManager.defaultPlanes?.negXz || ''
|
||||||
|
if (camVector.y < 0) {
|
||||||
|
zAxis = [0, -1, 0]
|
||||||
|
_entity_id = engineCommandManager.defaultPlanes?.xz || ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
sceneInfra.modelingSend({
|
sceneInfra.modelingSend({
|
||||||
type: 'Select default plane',
|
type: 'Select default plane',
|
||||||
data: {
|
data: {
|
||||||
type: 'extrudeFace',
|
type: 'defaultPlane',
|
||||||
zAxis: [z_axis.x, z_axis.y, z_axis.z],
|
planeId: _entity_id,
|
||||||
yAxis: [y_axis.x, y_axis.y, y_axis.z],
|
plane: defaultPlaneStrMap[_entity_id],
|
||||||
position: [origin.x, origin.y, origin.z].map(
|
zAxis,
|
||||||
(num) => num / sceneInfra._baseUnitMultiplier
|
yAxis,
|
||||||
) as [number, number, number],
|
|
||||||
extrudeSegmentPathToNode: pathToNode,
|
|
||||||
cap:
|
|
||||||
artifact?.additionalData?.type === 'cap'
|
|
||||||
? artifact.additionalData.info
|
|
||||||
: 'none',
|
|
||||||
faceId: entity_id,
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
return ['face', entity_id]
|
return
|
||||||
}
|
}
|
||||||
|
const artifact = this.engineCommandManager.artifactMap[_entity_id]
|
||||||
|
// If we clicked on an extrude wall, we climb up the parent Id
|
||||||
|
// to get the sketch profile's face ID. If we clicked on an endcap,
|
||||||
|
// we already have it.
|
||||||
|
const targetId =
|
||||||
|
'additionalData' in artifact &&
|
||||||
|
artifact.additionalData?.type === 'cap'
|
||||||
|
? _entity_id
|
||||||
|
: artifact.parentId
|
||||||
|
|
||||||
const faceResult = await checkExtrudeFaceClick()
|
// tsc cannot infer that target can have extrusions
|
||||||
console.log('faceResult', faceResult)
|
// from the commandType (why?) so we need to cast it
|
||||||
if (faceResult[0] === 'face') return
|
const target = this.engineCommandManager.artifactMap?.[
|
||||||
|
targetId || ''
|
||||||
|
] as ArtifactMapCommand & { extrusions?: string[] }
|
||||||
|
|
||||||
|
// TODO: We get the first extrusion command ID,
|
||||||
|
// which is fine while backend systems only support one extrusion.
|
||||||
|
// but we need to more robustly handle resolving to the correct extrusion
|
||||||
|
// if there are multiple.
|
||||||
|
const extrusions =
|
||||||
|
this.engineCommandManager.artifactMap?.[target?.extrusions?.[0] || '']
|
||||||
|
|
||||||
|
if (artifact?.commandType !== 'solid3d_get_extrusion_face_info') return
|
||||||
|
|
||||||
|
const faceInfo = await getFaceDetails(_entity_id)
|
||||||
|
if (!faceInfo?.origin || !faceInfo?.z_axis || !faceInfo?.y_axis) return
|
||||||
|
const { z_axis, y_axis, origin } = faceInfo
|
||||||
|
const sketchPathToNode = getNodePathFromSourceRange(
|
||||||
|
kclManager.ast,
|
||||||
|
artifact.range
|
||||||
|
)
|
||||||
|
const extrudePathToNode = extrusions?.range
|
||||||
|
? getNodePathFromSourceRange(kclManager.ast, extrusions.range)
|
||||||
|
: []
|
||||||
|
|
||||||
if (!args || !args.intersects?.[0]) return
|
|
||||||
if (args.mouseEvent.which !== 1) return
|
|
||||||
const { intersects } = args
|
|
||||||
const type = intersects?.[0].object.name || ''
|
|
||||||
const posNorm = Number(intersects?.[0]?.normal?.z) > 0
|
|
||||||
let planeString: DefaultPlaneStr = posNorm ? 'XY' : '-XY'
|
|
||||||
let zAxis: [number, number, number] = posNorm ? [0, 0, 1] : [0, 0, -1]
|
|
||||||
let yAxis: [number, number, number] = [0, 1, 0]
|
|
||||||
if (type === YZ_PLANE) {
|
|
||||||
planeString = posNorm ? 'YZ' : '-YZ'
|
|
||||||
zAxis = posNorm ? [1, 0, 0] : [-1, 0, 0]
|
|
||||||
yAxis = [0, 0, 1]
|
|
||||||
} else if (type === XZ_PLANE) {
|
|
||||||
planeString = posNorm ? '-XZ' : 'XZ'
|
|
||||||
zAxis = posNorm ? [0, 1, 0] : [0, -1, 0]
|
|
||||||
yAxis = [0, 0, 1]
|
|
||||||
}
|
|
||||||
sceneInfra.modelingSend({
|
sceneInfra.modelingSend({
|
||||||
type: 'Select default plane',
|
type: 'Select default plane',
|
||||||
data: {
|
data: {
|
||||||
type: 'defaultPlane',
|
type: 'extrudeFace',
|
||||||
plane: planeString,
|
zAxis: [z_axis.x, z_axis.y, z_axis.z],
|
||||||
zAxis,
|
yAxis: [y_axis.x, y_axis.y, y_axis.z],
|
||||||
yAxis,
|
position: [origin.x, origin.y, origin.z].map(
|
||||||
planeId: faceResult[1],
|
(num) => num / sceneInfra._baseUnitMultiplier
|
||||||
|
) as [number, number, number],
|
||||||
|
sketchPathToNode,
|
||||||
|
extrudePathToNode,
|
||||||
|
cap:
|
||||||
|
artifact?.additionalData?.type === 'cap'
|
||||||
|
? artifact.additionalData.info
|
||||||
|
: 'none',
|
||||||
|
faceId: _entity_id,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
return
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
@ -39,6 +39,7 @@ export function ActionButtonDropdown({
|
|||||||
onClick={item.onClick}
|
onClick={item.onClick}
|
||||||
className="block px-3 py-1 hover:bg-primary/10 dark:hover:bg-chalkboard-80 border-0 m-0 text-sm w-full rounded-none text-left disabled:!bg-transparent dark:disabled:text-chalkboard-60"
|
className="block px-3 py-1 hover:bg-primary/10 dark:hover:bg-chalkboard-80 border-0 m-0 text-sm w-full rounded-none text-left disabled:!bg-transparent dark:disabled:text-chalkboard-60"
|
||||||
disabled={item.disabled}
|
disabled={item.disabled}
|
||||||
|
data-testid={item.label}
|
||||||
>
|
>
|
||||||
<span className="capitalize">{item.label}</span>
|
<span className="capitalize">{item.label}</span>
|
||||||
{item.shortcut && (
|
{item.shortcut && (
|
||||||
|
@ -214,13 +214,17 @@ export const CreateNewVariable = ({
|
|||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<label htmlFor="create-new-variable" className="block mt-3 font-mono">
|
<label
|
||||||
|
htmlFor="create-new-variable"
|
||||||
|
className="block mt-3 font-mono text-chalkboard-90"
|
||||||
|
>
|
||||||
Create new variable
|
Create new variable
|
||||||
</label>
|
</label>
|
||||||
<div className="mt-1 flex gap-2 items-center">
|
<div className="mt-1 flex gap-2 items-center">
|
||||||
{showCheckbox && (
|
{showCheckbox && (
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
|
data-testid="create-new-variable-checkbox"
|
||||||
checked={shouldCreateVariable}
|
checked={shouldCreateVariable}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setShouldCreateVariable(e.target.checked)
|
setShouldCreateVariable(e.target.checked)
|
||||||
|
@ -6,6 +6,8 @@ import CommandComboBox from '../CommandComboBox'
|
|||||||
import CommandBarReview from './CommandBarReview'
|
import CommandBarReview from './CommandBarReview'
|
||||||
import { useLocation } from 'react-router-dom'
|
import { useLocation } from 'react-router-dom'
|
||||||
import useHotkeyWrapper from 'lib/hotkeyWrapper'
|
import useHotkeyWrapper from 'lib/hotkeyWrapper'
|
||||||
|
import { CustomIcon } from 'components/CustomIcon'
|
||||||
|
import Tooltip from 'components/Tooltip'
|
||||||
|
|
||||||
export const CommandBar = () => {
|
export const CommandBar = () => {
|
||||||
const { pathname } = useLocation()
|
const { pathname } = useLocation()
|
||||||
@ -103,7 +105,7 @@ export const CommandBar = () => {
|
|||||||
leaveTo="opacity-0 scale-95"
|
leaveTo="opacity-0 scale-95"
|
||||||
>
|
>
|
||||||
<WrapperComponent.Panel
|
<WrapperComponent.Panel
|
||||||
className="relative z-50 pointer-events-auto w-full max-w-xl py-2 mx-auto border rounded shadow-lg bg-chalkboard-10 dark:bg-chalkboard-100 dark:border-chalkboard-70"
|
className="relative z-50 pointer-events-auto w-full max-w-xl py-2 mx-auto border rounded rounded-tl-none shadow-lg bg-chalkboard-10 dark:bg-chalkboard-100 dark:border-chalkboard-70"
|
||||||
as="div"
|
as="div"
|
||||||
data-testid="command-bar"
|
data-testid="command-bar"
|
||||||
>
|
>
|
||||||
@ -116,6 +118,19 @@ export const CommandBar = () => {
|
|||||||
<CommandBarReview stepBack={stepBack} />
|
<CommandBarReview stepBack={stepBack} />
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => commandBarSend({ type: 'Close' })}
|
||||||
|
className="group block !absolute left-auto right-full top-[-3px] m-2.5 p-0 border-none bg-transparent hover:bg-transparent"
|
||||||
|
>
|
||||||
|
<CustomIcon
|
||||||
|
name="close"
|
||||||
|
className="w-5 h-5 rounded-sm bg-destroy-10 text-destroy-80 dark:bg-destroy-80 dark:text-destroy-10 group-hover:brightness-110"
|
||||||
|
/>
|
||||||
|
<Tooltip position="bottom" delay={500}>
|
||||||
|
Cancel{' '}
|
||||||
|
<kbd className="hotkey ml-4 dark:!bg-chalkboard-80">esc</kbd>
|
||||||
|
</Tooltip>
|
||||||
|
</button>
|
||||||
</WrapperComponent.Panel>
|
</WrapperComponent.Panel>
|
||||||
</Transition.Child>
|
</Transition.Child>
|
||||||
</WrapperComponent>
|
</WrapperComponent>
|
||||||
|
@ -7,10 +7,8 @@ import {
|
|||||||
getSelectionType,
|
getSelectionType,
|
||||||
getSelectionTypeDisplayText,
|
getSelectionTypeDisplayText,
|
||||||
} from 'lib/selections'
|
} from 'lib/selections'
|
||||||
import { kclManager } from 'lib/singletons'
|
|
||||||
import { modelingMachine } from 'machines/modelingMachine'
|
import { modelingMachine } from 'machines/modelingMachine'
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import { useHotkeys } from 'react-hotkeys-hook'
|
|
||||||
import { StateFrom } from 'xstate'
|
import { StateFrom } from 'xstate'
|
||||||
|
|
||||||
const selectionSelector = (snapshot: StateFrom<typeof modelingMachine>) =>
|
const selectionSelector = (snapshot: StateFrom<typeof modelingMachine>) =>
|
||||||
@ -41,24 +39,10 @@ function CommandBarSelectionInput({
|
|||||||
canSubmitSelectionArg(selectionsByType, arg)
|
canSubmitSelectionArg(selectionsByType, arg)
|
||||||
)
|
)
|
||||||
|
|
||||||
useHotkeys('tab', () => onSubmit(selection), {
|
|
||||||
enableOnFormTags: true,
|
|
||||||
enableOnContentEditable: true,
|
|
||||||
keyup: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
inputRef.current?.focus()
|
inputRef.current?.focus()
|
||||||
}, [selection, inputRef])
|
}, [selection, inputRef])
|
||||||
|
|
||||||
// Exit engine's edit mode when this input step is active,
|
|
||||||
// and re-enter it when it's not.
|
|
||||||
// In future the engine's edit mode will go away and this will be handled differently.
|
|
||||||
useEffect(() => {
|
|
||||||
kclManager.exitEditMode()
|
|
||||||
return () => kclManager.defaultSelectionFilter()
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// Fast-forward through this arg if it's marked as skippable
|
// Fast-forward through this arg if it's marked as skippable
|
||||||
// and we have a valid selection already
|
// and we have a valid selection already
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
199
src/components/ContextMenu.tsx
Normal file
@ -0,0 +1,199 @@
|
|||||||
|
import toast from 'react-hot-toast'
|
||||||
|
import { ActionIcon, ActionIconProps } from './ActionIcon'
|
||||||
|
import { RefObject, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import { useHotkeys } from 'react-hotkeys-hook'
|
||||||
|
import { Dialog } from '@headlessui/react'
|
||||||
|
|
||||||
|
interface ContextMenuProps
|
||||||
|
extends Omit<React.HTMLAttributes<HTMLUListElement>, 'children'> {
|
||||||
|
items?: React.ReactElement[]
|
||||||
|
menuTargetElement?: RefObject<HTMLElement>
|
||||||
|
}
|
||||||
|
|
||||||
|
const DefaultContextMenuItems = [
|
||||||
|
<ContextMenuItemRefresh />,
|
||||||
|
<ContextMenuItemCopy />,
|
||||||
|
// add more default context menu items here
|
||||||
|
]
|
||||||
|
|
||||||
|
export function ContextMenu({
|
||||||
|
items = DefaultContextMenuItems,
|
||||||
|
menuTargetElement,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: ContextMenuProps) {
|
||||||
|
const dialogRef = useRef<HTMLDivElement>(null)
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [windowSize, setWindowSize] = useState({
|
||||||
|
width: globalThis?.window?.innerWidth,
|
||||||
|
height: globalThis?.window?.innerHeight,
|
||||||
|
})
|
||||||
|
const [position, setPosition] = useState({ x: 0, y: 0 })
|
||||||
|
useHotkeys('esc', () => setOpen(false), {
|
||||||
|
enabled: open,
|
||||||
|
})
|
||||||
|
|
||||||
|
const dialogPositionStyle = useMemo(() => {
|
||||||
|
if (!dialogRef.current)
|
||||||
|
return {
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 'auto',
|
||||||
|
bottom: 'auto',
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
top:
|
||||||
|
position.y + dialogRef.current.clientHeight > windowSize.height
|
||||||
|
? 'auto'
|
||||||
|
: position.y,
|
||||||
|
left:
|
||||||
|
position.x + dialogRef.current.clientWidth > windowSize.width
|
||||||
|
? 'auto'
|
||||||
|
: position.x,
|
||||||
|
right:
|
||||||
|
position.x + dialogRef.current.clientWidth > windowSize.width
|
||||||
|
? windowSize.width - position.x
|
||||||
|
: 'auto',
|
||||||
|
bottom:
|
||||||
|
position.y + dialogRef.current.clientHeight > windowSize.height
|
||||||
|
? windowSize.height - position.y
|
||||||
|
: 'auto',
|
||||||
|
}
|
||||||
|
}, [position, windowSize, dialogRef.current])
|
||||||
|
|
||||||
|
// Listen for window resize to update context menu position
|
||||||
|
useEffect(() => {
|
||||||
|
const handleResize = () => {
|
||||||
|
setWindowSize({
|
||||||
|
width: globalThis?.window?.innerWidth,
|
||||||
|
height: globalThis?.window?.innerHeight,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
globalThis?.window?.addEventListener('resize', handleResize)
|
||||||
|
return () => {
|
||||||
|
globalThis?.window?.removeEventListener('resize', handleResize)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Add context menu listener to target once mounted
|
||||||
|
useEffect(() => {
|
||||||
|
const handleContextMenu = (e: MouseEvent) => {
|
||||||
|
console.log('context menu', e)
|
||||||
|
e.preventDefault()
|
||||||
|
setPosition({ x: e.x, y: e.y })
|
||||||
|
setOpen(true)
|
||||||
|
}
|
||||||
|
menuTargetElement?.current?.addEventListener(
|
||||||
|
'contextmenu',
|
||||||
|
handleContextMenu
|
||||||
|
)
|
||||||
|
return () => {
|
||||||
|
menuTargetElement?.current?.removeEventListener(
|
||||||
|
'contextmenu',
|
||||||
|
handleContextMenu
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}, [menuTargetElement?.current])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onClose={() => setOpen(false)}>
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 w-screen h-screen"
|
||||||
|
onContextMenu={(e) => e.preventDefault()}
|
||||||
|
>
|
||||||
|
<Dialog.Backdrop className="fixed z-10 inset-0" />
|
||||||
|
<Dialog.Panel
|
||||||
|
ref={dialogRef}
|
||||||
|
className={`w-48 fixed bg-chalkboard-10 dark:bg-chalkboard-90
|
||||||
|
border border-solid border-chalkboard-10 dark:border-chalkboard-90 rounded
|
||||||
|
shadow-lg backdrop:fixed backdrop:inset-0 backdrop:bg-primary ${className}`}
|
||||||
|
style={{
|
||||||
|
...dialogPositionStyle,
|
||||||
|
...props.style,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ul
|
||||||
|
{...props}
|
||||||
|
className="relative flex flex-col gap-0.5 items-stretch content-stretch"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
>
|
||||||
|
{...items}
|
||||||
|
</ul>
|
||||||
|
</Dialog.Panel>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ContextMenuDivider() {
|
||||||
|
return <hr className="border-chalkboard-20 dark:border-chalkboard-80" />
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ContextMenuItemProps {
|
||||||
|
children: React.ReactNode
|
||||||
|
icon?: ActionIconProps['icon']
|
||||||
|
onClick?: () => void
|
||||||
|
hotkey?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ContextMenuItem({
|
||||||
|
children,
|
||||||
|
icon,
|
||||||
|
onClick,
|
||||||
|
hotkey,
|
||||||
|
}: ContextMenuItemProps) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className="flex items-center gap-2 py-1 px-2 cursor-pointer hover:bg-chalkboard-20 dark:hover:bg-chalkboard-80 border-none text-left"
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
{icon && <ActionIcon icon={icon} bgClassName="!bg-transparent" />}
|
||||||
|
<div className="flex-1">{children}</div>
|
||||||
|
{hotkey && (
|
||||||
|
<kbd className="px-1.5 py-0.5 rounded bg-primary/10 text-primary dark:bg-chalkboard-80 dark:text-chalkboard-40">
|
||||||
|
{hotkey}
|
||||||
|
</kbd>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ContextMenuItemRefresh() {
|
||||||
|
return (
|
||||||
|
<ContextMenuItem
|
||||||
|
icon="arrowRotateRight"
|
||||||
|
onClick={() => globalThis?.window?.location.reload()}
|
||||||
|
>
|
||||||
|
Refresh
|
||||||
|
</ContextMenuItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ContextMenuItemCopyProps {
|
||||||
|
toBeCopiedContent?: string
|
||||||
|
toBeCopiedLabel?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ContextMenuItemCopy({
|
||||||
|
toBeCopiedContent = globalThis.window?.getSelection()?.toString(),
|
||||||
|
toBeCopiedLabel = 'selection',
|
||||||
|
}: ContextMenuItemCopyProps) {
|
||||||
|
return (
|
||||||
|
<ContextMenuItem
|
||||||
|
icon="clipboardPlus"
|
||||||
|
onClick={() => {
|
||||||
|
if (toBeCopiedContent) {
|
||||||
|
globalThis?.navigator?.clipboard
|
||||||
|
.writeText(toBeCopiedContent)
|
||||||
|
.then(() => toast.success(`Copied ${toBeCopiedLabel} to clipboard`))
|
||||||
|
.catch(() =>
|
||||||
|
toast.error(`Failed to copy ${toBeCopiedLabel} to clipboard`)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Copy
|
||||||
|
</ContextMenuItem>
|
||||||
|
)
|
||||||
|
}
|
@ -71,6 +71,16 @@ const CustomIconMap = {
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
),
|
),
|
||||||
|
bug: (
|
||||||
|
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
clipRule="evenodd"
|
||||||
|
d="M10.8209 5.99884C10.6403 5.73962 10.3399 5.57001 10 5.57001C9.65984 5.57001 9.35936 5.73984 9.17871 5.99935C9.43724 5.95129 9.71142 5.92578 10.0012 5.92578C10.29 5.92578 10.5633 5.95111 10.8209 5.99884ZM10 4.57001C8.9459 4.57001 8.08227 5.38548 8.00554 6.41997C7.58916 6.65398 7.23724 6.95989 6.95014 7.31304L5.85355 6.21645L5.14645 6.92356L6.40931 8.18642C6.20774 8.62503 6.08043 9.09624 6.0278 9.57001H5V10.57H6.01946C6.06396 11.1581 6.1867 11.8173 6.4071 12.4558L5.14645 13.7165L5.85355 14.4236L6.8408 13.4363C7.46354 14.555 8.47307 15.4258 10.0012 15.4258C11.529 15.4258 12.5378 14.5554 13.16 13.4371L14.1464 14.4236L14.8536 13.7165L13.5934 12.4563C13.8136 11.8177 13.9362 11.1583 13.9806 10.57H15V9.57001H13.9722C13.9197 9.0961 13.7925 8.62474 13.5911 8.18602L14.8536 6.92356L14.1464 6.21645L13.0505 7.31239C12.7633 6.95894 12.4112 6.65285 11.9944 6.41883C11.9171 5.38488 11.0537 4.57001 10 4.57001ZM10.5 14.3801V8.57001H9.5V14.3796C8.72105 14.2298 8.15885 13.7245 7.7428 12.9999C7.22316 12.095 7 10.937 7 10.07C7 8.46381 8.04281 6.92578 10.0012 6.92578C11.9589 6.92578 13 8.4629 13 10.07C13 10.9373 12.7773 12.0954 12.2582 13.0003C11.8422 13.7254 11.2799 14.2309 10.5 14.3801Z"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
checkmark: (
|
checkmark: (
|
||||||
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
<path
|
<path
|
||||||
@ -257,6 +267,14 @@ const CustomIconMap = {
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
),
|
),
|
||||||
|
keyboard: (
|
||||||
|
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path
|
||||||
|
d="M16 12V15H13.5M16 12V9M16 12H13.5M4 12V15H6.5M4 12V9M4 12H6.5M4 9V6H6.5M4 9H6.5M16 9V6H13.5M16 9H13.5M6.5 12V15M6.5 12H7.5M6.5 15H13.5M13.5 15V12M13.5 12H12.5M7.5 12V9M7.5 12H10M7.5 9H8.75M7.5 9H6.5M10 12V9M10 12H12.5M10 9H11.25M10 9H8.75M12.5 12V9M12.5 9H13.5M12.5 9H11.25M13.5 9V6M13.5 6H11.25M11.25 9V6M11.25 6H8.75M8.75 9V6M8.75 6H6.5M6.5 9V6"
|
||||||
|
stroke="currentColor"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
line: (
|
line: (
|
||||||
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
<path
|
<path
|
||||||
|
@ -18,6 +18,8 @@ import { useLspContext } from './LspProvider'
|
|||||||
import useHotkeyWrapper from 'lib/hotkeyWrapper'
|
import useHotkeyWrapper from 'lib/hotkeyWrapper'
|
||||||
import { useModelingContext } from 'hooks/useModelingContext'
|
import { useModelingContext } from 'hooks/useModelingContext'
|
||||||
import { DeleteConfirmationDialog } from './ProjectCard/DeleteProjectDialog'
|
import { DeleteConfirmationDialog } from './ProjectCard/DeleteProjectDialog'
|
||||||
|
import { ContextMenu, ContextMenuItem } from './ContextMenu'
|
||||||
|
import usePlatform from 'hooks/usePlatform'
|
||||||
|
|
||||||
function getIndentationCSS(level: number) {
|
function getIndentationCSS(level: number) {
|
||||||
return `calc(1rem * ${level + 1})`
|
return `calc(1rem * ${level + 1})`
|
||||||
@ -125,6 +127,7 @@ const FileTreeItem = ({
|
|||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [isConfirmingDelete, setIsConfirmingDelete] = useState(false)
|
const [isConfirmingDelete, setIsConfirmingDelete] = useState(false)
|
||||||
const isCurrentFile = fileOrDir.path === currentFile?.path
|
const isCurrentFile = fileOrDir.path === currentFile?.path
|
||||||
|
const itemRef = useRef(null)
|
||||||
|
|
||||||
const isRenaming = fileContext.itemsBeingRenamed.includes(fileOrDir.path)
|
const isRenaming = fileContext.itemsBeingRenamed.includes(fileOrDir.path)
|
||||||
const removeCurrentItemFromRenaming = useCallback(
|
const removeCurrentItemFromRenaming = useCallback(
|
||||||
@ -185,7 +188,7 @@ const FileTreeItem = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="contents" ref={itemRef}>
|
||||||
{fileOrDir.children === undefined ? (
|
{fileOrDir.children === undefined ? (
|
||||||
<li
|
<li
|
||||||
className={
|
className={
|
||||||
@ -321,7 +324,41 @@ const FileTreeItem = ({
|
|||||||
setIsOpen={setIsConfirmingDelete}
|
setIsOpen={setIsConfirmingDelete}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
<FileTreeContextMenu
|
||||||
|
itemRef={itemRef}
|
||||||
|
onRename={addCurrentItemToRenaming}
|
||||||
|
onDelete={() => setIsConfirmingDelete(true)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FileTreeContextMenuProps {
|
||||||
|
itemRef: React.RefObject<HTMLElement>
|
||||||
|
onRename: () => void
|
||||||
|
onDelete: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function FileTreeContextMenu({
|
||||||
|
itemRef,
|
||||||
|
onRename,
|
||||||
|
onDelete,
|
||||||
|
}: FileTreeContextMenuProps) {
|
||||||
|
const platform = usePlatform()
|
||||||
|
const metaKey = platform === 'macos' ? '⌘' : 'Ctrl'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ContextMenu
|
||||||
|
menuTargetElement={itemRef}
|
||||||
|
items={[
|
||||||
|
<ContextMenuItem onClick={onRename} hotkey="Enter">
|
||||||
|
Rename
|
||||||
|
</ContextMenuItem>,
|
||||||
|
<ContextMenuItem onClick={onDelete} hotkey={metaKey + ' + Del'}>
|
||||||
|
Delete
|
||||||
|
</ContextMenuItem>,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
|
import { SceneInfra } from 'clientSideScene/sceneInfra'
|
||||||
import { sceneInfra } from 'lib/singletons'
|
import { sceneInfra } from 'lib/singletons'
|
||||||
import { useEffect, useRef } from 'react'
|
import { MutableRefObject, useEffect, useRef } from 'react'
|
||||||
import {
|
import {
|
||||||
WebGLRenderer,
|
WebGLRenderer,
|
||||||
Scene,
|
Scene,
|
||||||
@ -12,21 +13,52 @@ import {
|
|||||||
Clock,
|
Clock,
|
||||||
Quaternion,
|
Quaternion,
|
||||||
ColorRepresentation,
|
ColorRepresentation,
|
||||||
|
Vector2,
|
||||||
|
Raycaster,
|
||||||
|
Camera,
|
||||||
|
Intersection,
|
||||||
|
Object3D,
|
||||||
} from 'three'
|
} from 'three'
|
||||||
|
import {
|
||||||
|
ContextMenu,
|
||||||
|
ContextMenuDivider,
|
||||||
|
ContextMenuItem,
|
||||||
|
ContextMenuItemRefresh,
|
||||||
|
} from './ContextMenu'
|
||||||
|
|
||||||
const CANVAS_SIZE = 80
|
const CANVAS_SIZE = 80
|
||||||
const FRUSTUM_SIZE = 0.5
|
const FRUSTUM_SIZE = 0.5
|
||||||
const AXIS_LENGTH = 0.35
|
const AXIS_LENGTH = 0.35
|
||||||
const AXIS_WIDTH = 0.02
|
const AXIS_WIDTH = 0.02
|
||||||
const AXIS_COLORS = {
|
enum AxisColors {
|
||||||
x: '#fa6668',
|
X = '#fa6668',
|
||||||
y: '#11eb6b',
|
Y = '#11eb6b',
|
||||||
z: '#6689ef',
|
Z = '#6689ef',
|
||||||
gray: '#c6c7c2',
|
Gray = '#c6c7c2',
|
||||||
|
}
|
||||||
|
enum AxisNames {
|
||||||
|
X = 'x',
|
||||||
|
Y = 'y',
|
||||||
|
Z = 'z',
|
||||||
|
NEG_X = '-x',
|
||||||
|
NEG_Y = '-y',
|
||||||
|
NEG_Z = '-z',
|
||||||
|
}
|
||||||
|
const axisNamesSemantic: Record<AxisNames, string> = {
|
||||||
|
[AxisNames.X]: 'Right',
|
||||||
|
[AxisNames.Y]: 'Back',
|
||||||
|
[AxisNames.Z]: 'Top',
|
||||||
|
[AxisNames.NEG_X]: 'Left',
|
||||||
|
[AxisNames.NEG_Y]: 'Front',
|
||||||
|
[AxisNames.NEG_Z]: 'Bottom',
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Gizmo() {
|
export default function Gizmo() {
|
||||||
|
const wrapperRef = useRef<HTMLDivElement | null>(null)
|
||||||
const canvasRef = useRef<HTMLCanvasElement | null>(null)
|
const canvasRef = useRef<HTMLCanvasElement | null>(null)
|
||||||
|
const raycasterIntersect = useRef<Intersection<Object3D> | null>(null)
|
||||||
|
const cameraPassiveUpdateTimer = useRef(0)
|
||||||
|
const raycasterPassiveUpdateTimer = useRef(0)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!canvasRef.current) return
|
if (!canvasRef.current) return
|
||||||
@ -41,35 +73,89 @@ export default function Gizmo() {
|
|||||||
const { gizmoAxes, gizmoAxisHeads } = createGizmo()
|
const { gizmoAxes, gizmoAxisHeads } = createGizmo()
|
||||||
scene.add(...gizmoAxes, ...gizmoAxisHeads)
|
scene.add(...gizmoAxes, ...gizmoAxisHeads)
|
||||||
|
|
||||||
|
const raycaster = new Raycaster()
|
||||||
|
const { mouse, disposeMouseEvents } = initializeMouseEvents(
|
||||||
|
canvas,
|
||||||
|
raycasterIntersect,
|
||||||
|
sceneInfra
|
||||||
|
)
|
||||||
|
const raycasterObjects = [...gizmoAxisHeads]
|
||||||
|
|
||||||
const clock = new Clock()
|
const clock = new Clock()
|
||||||
const clientCamera = sceneInfra.camControls.camera
|
const clientCamera = sceneInfra.camControls.camera
|
||||||
let currentQuaternion = new Quaternion().copy(clientCamera.quaternion)
|
let currentQuaternion = new Quaternion().copy(clientCamera.quaternion)
|
||||||
|
|
||||||
const animate = () => {
|
const animate = () => {
|
||||||
requestAnimationFrame(animate)
|
const delta = clock.getDelta()
|
||||||
updateCameraOrientation(
|
updateCameraOrientation(
|
||||||
camera,
|
camera,
|
||||||
currentQuaternion,
|
currentQuaternion,
|
||||||
sceneInfra.camControls.camera.quaternion,
|
sceneInfra.camControls.camera.quaternion,
|
||||||
clock.getDelta()
|
delta,
|
||||||
|
cameraPassiveUpdateTimer
|
||||||
|
)
|
||||||
|
updateRayCaster(
|
||||||
|
raycasterObjects,
|
||||||
|
raycaster,
|
||||||
|
mouse,
|
||||||
|
camera,
|
||||||
|
raycasterIntersect,
|
||||||
|
delta,
|
||||||
|
raycasterPassiveUpdateTimer
|
||||||
)
|
)
|
||||||
renderer.render(scene, camera)
|
renderer.render(scene, camera)
|
||||||
|
requestAnimationFrame(animate)
|
||||||
}
|
}
|
||||||
animate()
|
animate()
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
renderer.dispose()
|
renderer.dispose()
|
||||||
|
disposeMouseEvents()
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid place-content-center rounded-full overflow-hidden border border-solid border-primary/50 pointer-events-none">
|
<>
|
||||||
<canvas ref={canvasRef} />
|
<div
|
||||||
</div>
|
ref={wrapperRef}
|
||||||
|
aria-label="View orientation gizmo"
|
||||||
|
className="grid place-content-center rounded-full overflow-hidden border border-solid border-primary/50 pointer-events-auto"
|
||||||
|
>
|
||||||
|
<canvas ref={canvasRef} />
|
||||||
|
<ContextMenu
|
||||||
|
menuTargetElement={wrapperRef}
|
||||||
|
items={[
|
||||||
|
...Object.entries(axisNamesSemantic).map(
|
||||||
|
([axisName, axisSemantic]) => (
|
||||||
|
<ContextMenuItem
|
||||||
|
key={axisName}
|
||||||
|
onClick={() => {
|
||||||
|
sceneInfra.camControls.updateCameraToAxis(
|
||||||
|
axisName as AxisNames
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{axisSemantic} view
|
||||||
|
</ContextMenuItem>
|
||||||
|
)
|
||||||
|
),
|
||||||
|
<ContextMenuItem
|
||||||
|
onClick={() => {
|
||||||
|
sceneInfra.camControls.resetCameraPosition()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Reset view
|
||||||
|
</ContextMenuItem>,
|
||||||
|
<ContextMenuDivider />,
|
||||||
|
<ContextMenuItemRefresh />,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const createCamera = () => {
|
const createCamera = (): OrthographicCamera => {
|
||||||
return new OrthographicCamera(
|
return new OrthographicCamera(
|
||||||
-FRUSTUM_SIZE,
|
-FRUSTUM_SIZE,
|
||||||
FRUSTUM_SIZE,
|
FRUSTUM_SIZE,
|
||||||
@ -82,21 +168,21 @@ const createCamera = () => {
|
|||||||
|
|
||||||
const createGizmo = () => {
|
const createGizmo = () => {
|
||||||
const gizmoAxes = [
|
const gizmoAxes = [
|
||||||
createAxis(AXIS_LENGTH, AXIS_WIDTH, AXIS_COLORS.x, 0, 'z'),
|
createAxis(AXIS_LENGTH, AXIS_WIDTH, AxisColors.X, 0, 'z'),
|
||||||
createAxis(AXIS_LENGTH, AXIS_WIDTH, AXIS_COLORS.y, Math.PI / 2, 'z'),
|
createAxis(AXIS_LENGTH, AXIS_WIDTH, AxisColors.Y, Math.PI / 2, 'z'),
|
||||||
createAxis(AXIS_LENGTH, AXIS_WIDTH, AXIS_COLORS.z, -Math.PI / 2, 'y'),
|
createAxis(AXIS_LENGTH, AXIS_WIDTH, AxisColors.Z, -Math.PI / 2, 'y'),
|
||||||
createAxis(AXIS_LENGTH, AXIS_WIDTH, AXIS_COLORS.gray, Math.PI, 'z'),
|
createAxis(AXIS_LENGTH, AXIS_WIDTH, AxisColors.Gray, Math.PI, 'z'),
|
||||||
createAxis(AXIS_LENGTH, AXIS_WIDTH, AXIS_COLORS.gray, -Math.PI / 2, 'z'),
|
createAxis(AXIS_LENGTH, AXIS_WIDTH, AxisColors.Gray, -Math.PI / 2, 'z'),
|
||||||
createAxis(AXIS_LENGTH, AXIS_WIDTH, AXIS_COLORS.gray, Math.PI / 2, 'y'),
|
createAxis(AXIS_LENGTH, AXIS_WIDTH, AxisColors.Gray, Math.PI / 2, 'y'),
|
||||||
]
|
]
|
||||||
|
|
||||||
const gizmoAxisHeads = [
|
const gizmoAxisHeads = [
|
||||||
createAxisHead(AXIS_LENGTH, AXIS_COLORS.x, 0, 'z'),
|
createAxisHead(AxisNames.X, AxisColors.X, [AXIS_LENGTH, 0, 0]),
|
||||||
createAxisHead(AXIS_LENGTH, AXIS_COLORS.y, Math.PI / 2, 'z'),
|
createAxisHead(AxisNames.Y, AxisColors.Y, [0, AXIS_LENGTH, 0]),
|
||||||
createAxisHead(AXIS_LENGTH, AXIS_COLORS.z, -Math.PI / 2, 'y'),
|
createAxisHead(AxisNames.Z, AxisColors.Z, [0, 0, AXIS_LENGTH]),
|
||||||
createAxisHead(AXIS_LENGTH, AXIS_COLORS.gray, Math.PI, 'z'),
|
createAxisHead(AxisNames.NEG_X, AxisColors.Gray, [-AXIS_LENGTH, 0, 0]),
|
||||||
createAxisHead(AXIS_LENGTH, AXIS_COLORS.gray, -Math.PI / 2, 'z'),
|
createAxisHead(AxisNames.NEG_Y, AxisColors.Gray, [0, -AXIS_LENGTH, 0]),
|
||||||
createAxisHead(AXIS_LENGTH, AXIS_COLORS.gray, Math.PI / 2, 'y'),
|
createAxisHead(AxisNames.NEG_Z, AxisColors.Gray, [0, 0, -AXIS_LENGTH]),
|
||||||
]
|
]
|
||||||
|
|
||||||
return { gizmoAxes, gizmoAxisHeads }
|
return { gizmoAxes, gizmoAxisHeads }
|
||||||
@ -108,12 +194,9 @@ const createAxis = (
|
|||||||
color: ColorRepresentation,
|
color: ColorRepresentation,
|
||||||
rotation = 0,
|
rotation = 0,
|
||||||
axis = 'x'
|
axis = 'x'
|
||||||
) => {
|
): Mesh => {
|
||||||
const geometry = new BoxGeometry(length, width, width).translate(
|
const geometry = new BoxGeometry(length, width, width)
|
||||||
length / 2,
|
geometry.translate(length / 2, 0, 0)
|
||||||
0,
|
|
||||||
0
|
|
||||||
)
|
|
||||||
const material = new MeshBasicMaterial({ color: new Color(color) })
|
const material = new MeshBasicMaterial({ color: new Color(color) })
|
||||||
const mesh = new Mesh(geometry, material)
|
const mesh = new Mesh(geometry, material)
|
||||||
mesh.rotation[axis as 'x' | 'y' | 'z'] = rotation
|
mesh.rotation[axis as 'x' | 'y' | 'z'] = rotation
|
||||||
@ -121,15 +204,17 @@ const createAxis = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
const createAxisHead = (
|
const createAxisHead = (
|
||||||
length: number,
|
name: AxisNames,
|
||||||
color: ColorRepresentation,
|
color: ColorRepresentation,
|
||||||
rotation = 0,
|
position: number[]
|
||||||
axis = 'x'
|
): Mesh => {
|
||||||
) => {
|
const geometry = new SphereGeometry(0.065, 16, 8)
|
||||||
const geometry = new SphereGeometry(0.065, 16, 8).translate(length, 0, 0)
|
|
||||||
const material = new MeshBasicMaterial({ color: new Color(color) })
|
const material = new MeshBasicMaterial({ color: new Color(color) })
|
||||||
const mesh = new Mesh(geometry, material)
|
const mesh = new Mesh(geometry, material)
|
||||||
mesh.rotation[axis as 'x' | 'y' | 'z'] = rotation
|
|
||||||
|
mesh.position.set(position[0], position[1], position[2])
|
||||||
|
mesh.updateMatrixWorld()
|
||||||
|
mesh.name = name
|
||||||
return mesh
|
return mesh
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -137,10 +222,97 @@ const updateCameraOrientation = (
|
|||||||
camera: OrthographicCamera,
|
camera: OrthographicCamera,
|
||||||
currentQuaternion: Quaternion,
|
currentQuaternion: Quaternion,
|
||||||
targetQuaternion: Quaternion,
|
targetQuaternion: Quaternion,
|
||||||
deltaTime: number
|
deltaTime: number,
|
||||||
|
cameraPassiveUpdateTimer: MutableRefObject<number>
|
||||||
) => {
|
) => {
|
||||||
const slerpFactor = 1 - Math.exp(-30 * deltaTime)
|
cameraPassiveUpdateTimer.current += deltaTime
|
||||||
currentQuaternion.slerp(targetQuaternion, slerpFactor).normalize()
|
if (
|
||||||
camera.position.set(0, 0, 1).applyQuaternion(currentQuaternion)
|
!quaternionsEqual(currentQuaternion, targetQuaternion) ||
|
||||||
camera.quaternion.copy(currentQuaternion)
|
cameraPassiveUpdateTimer.current >= 5
|
||||||
|
) {
|
||||||
|
const slerpFactor = 1 - Math.exp(-30 * deltaTime)
|
||||||
|
currentQuaternion.slerp(targetQuaternion, slerpFactor).normalize()
|
||||||
|
camera.position.set(0, 0, 1).applyQuaternion(currentQuaternion)
|
||||||
|
camera.quaternion.copy(currentQuaternion)
|
||||||
|
cameraPassiveUpdateTimer.current = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const quaternionsEqual = (
|
||||||
|
q1: Quaternion,
|
||||||
|
q2: Quaternion,
|
||||||
|
tolerance: number = 0.001
|
||||||
|
): boolean => {
|
||||||
|
return (
|
||||||
|
Math.abs(q1.x - q2.x) < tolerance &&
|
||||||
|
Math.abs(q1.y - q2.y) < tolerance &&
|
||||||
|
Math.abs(q1.z - q2.z) < tolerance &&
|
||||||
|
Math.abs(q1.w - q2.w) < tolerance
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const initializeMouseEvents = (
|
||||||
|
canvas: HTMLCanvasElement,
|
||||||
|
raycasterIntersect: MutableRefObject<Intersection<Object3D> | null>,
|
||||||
|
sceneInfra: SceneInfra
|
||||||
|
): { mouse: Vector2; disposeMouseEvents: () => void } => {
|
||||||
|
const mouse = new Vector2()
|
||||||
|
mouse.x = 1 // fix initial mouse position issue
|
||||||
|
|
||||||
|
const handleMouseMove = (event: MouseEvent) => {
|
||||||
|
const { left, top, width, height } = canvas.getBoundingClientRect()
|
||||||
|
mouse.x = ((event.clientX - left) / width) * 2 - 1
|
||||||
|
mouse.y = ((event.clientY - top) / height) * -2 + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
if (raycasterIntersect.current) {
|
||||||
|
const axisName = raycasterIntersect.current.object.name as AxisNames
|
||||||
|
sceneInfra.camControls.updateCameraToAxis(axisName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('mousemove', handleMouseMove)
|
||||||
|
window.addEventListener('click', handleClick)
|
||||||
|
|
||||||
|
const disposeMouseEvents = () => {
|
||||||
|
window.removeEventListener('mousemove', handleMouseMove)
|
||||||
|
window.removeEventListener('click', handleClick)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { mouse, disposeMouseEvents }
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateRayCaster = (
|
||||||
|
objects: Object3D[],
|
||||||
|
raycaster: Raycaster,
|
||||||
|
mouse: Vector2,
|
||||||
|
camera: Camera,
|
||||||
|
raycasterIntersect: MutableRefObject<Intersection<Object3D> | null>,
|
||||||
|
deltaTime: number,
|
||||||
|
raycasterPassiveUpdateTimer: MutableRefObject<number>
|
||||||
|
) => {
|
||||||
|
raycasterPassiveUpdateTimer.current += deltaTime
|
||||||
|
|
||||||
|
// check if mouse is outside the canvas bounds and stop raycaster
|
||||||
|
if (raycasterPassiveUpdateTimer.current < 2) {
|
||||||
|
if (mouse.x < -1 || mouse.x > 1 || mouse.y < -1 || mouse.y > 1) {
|
||||||
|
raycasterIntersect.current = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
raycaster.setFromCamera(mouse, camera)
|
||||||
|
const intersects = raycaster.intersectObjects(objects)
|
||||||
|
|
||||||
|
objects.forEach((object) => object.scale.set(1, 1, 1))
|
||||||
|
if (intersects.length) {
|
||||||
|
intersects[0].object.scale.set(1.5, 1.5, 1.5)
|
||||||
|
raycasterIntersect.current = intersects[0] // filter first object
|
||||||
|
} else {
|
||||||
|
raycasterIntersect.current = null
|
||||||
|
}
|
||||||
|
if (raycasterPassiveUpdateTimer.current > 2) {
|
||||||
|
raycasterPassiveUpdateTimer.current = 0
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -81,6 +81,12 @@ export function HelpMenu(props: React.PropsWithChildren) {
|
|||||||
>
|
>
|
||||||
Release notes
|
Release notes
|
||||||
</HelpMenuItem>
|
</HelpMenuItem>
|
||||||
|
<HelpMenuItem
|
||||||
|
as="button"
|
||||||
|
onClick={() => navigate('settings?tab=keybindings')}
|
||||||
|
>
|
||||||
|
Keyboard shortcuts
|
||||||
|
</HelpMenuItem>
|
||||||
<HelpMenuItem
|
<HelpMenuItem
|
||||||
as="button"
|
as="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
@ -1,14 +1,65 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
import {
|
||||||
|
EngineConnectionStateType,
|
||||||
|
DisconnectingType,
|
||||||
|
EngineCommandManagerEvents,
|
||||||
|
EngineConnectionEvents,
|
||||||
|
ConnectionError,
|
||||||
|
CONNECTION_ERROR_TEXT,
|
||||||
|
} from '../lang/std/engineConnection'
|
||||||
|
|
||||||
|
import { engineCommandManager } from '../lib/singletons'
|
||||||
|
|
||||||
const Loading = ({ children }: React.PropsWithChildren) => {
|
const Loading = ({ children }: React.PropsWithChildren) => {
|
||||||
const [hasLongLoadTime, setHasLongLoadTime] = useState(false)
|
const [error, setError] = useState<ConnectionError>(ConnectionError.Unset)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const onConnectionStateChange = ({ detail: state }: CustomEvent) => {
|
||||||
|
if (
|
||||||
|
(state.type !== EngineConnectionStateType.Disconnected ||
|
||||||
|
state.type !== EngineConnectionStateType.Disconnecting) &&
|
||||||
|
state.value?.type !== DisconnectingType.Error
|
||||||
|
)
|
||||||
|
return
|
||||||
|
setError(state.value.value.error)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onEngineAvailable = ({ detail: engineConnection }: CustomEvent) => {
|
||||||
|
engineConnection.addEventListener(
|
||||||
|
EngineConnectionEvents.ConnectionStateChanged,
|
||||||
|
onConnectionStateChange as EventListener
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
engineCommandManager.addEventListener(
|
||||||
|
EngineCommandManagerEvents.EngineAvailable,
|
||||||
|
onEngineAvailable as EventListener
|
||||||
|
)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
engineCommandManager.removeEventListener(
|
||||||
|
EngineCommandManagerEvents.EngineAvailable,
|
||||||
|
onEngineAvailable as EventListener
|
||||||
|
)
|
||||||
|
engineCommandManager.engineConnection?.removeEventListener(
|
||||||
|
EngineConnectionEvents.ConnectionStateChanged,
|
||||||
|
onConnectionStateChange as EventListener
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Don't set long loading time if there's a more severe error
|
||||||
|
if (error > ConnectionError.LongLoadingTime) return
|
||||||
|
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
setHasLongLoadTime(true)
|
setError(ConnectionError.LongLoadingTime)
|
||||||
}, 4000)
|
}, 4000)
|
||||||
|
|
||||||
return () => clearTimeout(timer)
|
return () => clearTimeout(timer)
|
||||||
}, [setHasLongLoadTime])
|
}, [error, setError])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="body-bg flex flex-col items-center justify-center h-screen"
|
className="body-bg flex flex-col items-center justify-center h-screen"
|
||||||
@ -29,10 +80,10 @@ const Loading = ({ children }: React.PropsWithChildren) => {
|
|||||||
<p
|
<p
|
||||||
className={
|
className={
|
||||||
'text-sm mt-4 text-primary/60 transition-opacity duration-500' +
|
'text-sm mt-4 text-primary/60 transition-opacity duration-500' +
|
||||||
(hasLongLoadTime ? ' opacity-100' : ' opacity-0')
|
(error !== ConnectionError.Unset ? ' opacity-100' : ' opacity-0')
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
Loading is taking longer than expected.
|
{CONNECTION_ERROR_TEXT[error]}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
@ -33,7 +33,7 @@ export function LowerRightControls(props: React.PropsWithChildren) {
|
|||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
>
|
>
|
||||||
<CustomIcon
|
<CustomIcon
|
||||||
name="exclamationMark"
|
name="bug"
|
||||||
className={`w-5 h-5 ${linkOverrideClassName}`}
|
className={`w-5 h-5 ${linkOverrideClassName}`}
|
||||||
/>
|
/>
|
||||||
<Tooltip position="top">Report a bug</Tooltip>
|
<Tooltip position="top">Report a bug</Tooltip>
|
||||||
|
@ -13,7 +13,6 @@ import { LanguageSupport } from '@codemirror/language'
|
|||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { paths } from 'lib/paths'
|
import { paths } from 'lib/paths'
|
||||||
import { FileEntry } from 'lib/types'
|
import { FileEntry } from 'lib/types'
|
||||||
import { NetworkHealthState, useNetworkStatus } from './NetworkHealthIndicator'
|
|
||||||
import Worker from 'editor/plugins/lsp/worker.ts?worker'
|
import Worker from 'editor/plugins/lsp/worker.ts?worker'
|
||||||
import {
|
import {
|
||||||
LspWorkerEventType,
|
LspWorkerEventType,
|
||||||
@ -23,6 +22,8 @@ import {
|
|||||||
} from 'editor/plugins/lsp/types'
|
} from 'editor/plugins/lsp/types'
|
||||||
import { wasmUrl } from 'lang/wasm'
|
import { wasmUrl } from 'lang/wasm'
|
||||||
import { PROJECT_ENTRYPOINT } from 'lib/constants'
|
import { PROJECT_ENTRYPOINT } from 'lib/constants'
|
||||||
|
import { useNetworkContext } from 'hooks/useNetworkContext'
|
||||||
|
import { NetworkHealthState } from 'hooks/useNetworkStatus'
|
||||||
|
|
||||||
function getWorkspaceFolders(): LSP.WorkspaceFolder[] {
|
function getWorkspaceFolders(): LSP.WorkspaceFolder[] {
|
||||||
return []
|
return []
|
||||||
@ -86,7 +87,7 @@ export const LspProvider = ({ children }: { children: React.ReactNode }) => {
|
|||||||
} = useSettingsAuthContext()
|
} = useSettingsAuthContext()
|
||||||
const token = auth?.context.token
|
const token = auth?.context.token
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { overallState } = useNetworkStatus()
|
const { overallState } = useNetworkContext()
|
||||||
const isNetworkOkay = overallState === NetworkHealthState.Ok
|
const isNetworkOkay = overallState === NetworkHealthState.Ok
|
||||||
|
|
||||||
// So this is a bit weird, we need to initialize the lsp server and client.
|
// So this is a bit weird, we need to initialize the lsp server and client.
|
||||||
|
@ -11,7 +11,10 @@ import {
|
|||||||
import { SetSelections, modelingMachine } from 'machines/modelingMachine'
|
import { SetSelections, modelingMachine } from 'machines/modelingMachine'
|
||||||
import { useSetupEngineManager } from 'hooks/useSetupEngineManager'
|
import { useSetupEngineManager } from 'hooks/useSetupEngineManager'
|
||||||
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
|
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
|
||||||
import { isCursorInSketchCommandRange } from 'lang/util'
|
import {
|
||||||
|
isCursorInSketchCommandRange,
|
||||||
|
updatePathToNodeFromMap,
|
||||||
|
} from 'lang/util'
|
||||||
import {
|
import {
|
||||||
kclManager,
|
kclManager,
|
||||||
sceneInfra,
|
sceneInfra,
|
||||||
@ -44,7 +47,6 @@ import {
|
|||||||
TANGENTIAL_ARC_TO_SEGMENT,
|
TANGENTIAL_ARC_TO_SEGMENT,
|
||||||
getParentGroup,
|
getParentGroup,
|
||||||
getSketchOrientationDetails,
|
getSketchOrientationDetails,
|
||||||
getSketchQuaternion,
|
|
||||||
} from 'clientSideScene/sceneEntities'
|
} from 'clientSideScene/sceneEntities'
|
||||||
import {
|
import {
|
||||||
moveValueIntoNewVariablePath,
|
moveValueIntoNewVariablePath,
|
||||||
@ -73,6 +75,7 @@ import { useSearchParams } from 'react-router-dom'
|
|||||||
import { letEngineAnimateAndSyncCamAfter } from 'clientSideScene/CameraControls'
|
import { letEngineAnimateAndSyncCamAfter } from 'clientSideScene/CameraControls'
|
||||||
import { getVarNameModal } from 'hooks/useToolbarGuards'
|
import { getVarNameModal } from 'hooks/useToolbarGuards'
|
||||||
import useHotkeyWrapper from 'lib/hotkeyWrapper'
|
import useHotkeyWrapper from 'lib/hotkeyWrapper'
|
||||||
|
import { uuidv4 } from 'lib/utils'
|
||||||
|
|
||||||
type MachineContext<T extends AnyStateMachine> = {
|
type MachineContext<T extends AnyStateMachine> = {
|
||||||
state: StateFrom<T>
|
state: StateFrom<T>
|
||||||
@ -118,7 +121,24 @@ export const ModelingMachineProvider = ({
|
|||||||
htmlRef,
|
htmlRef,
|
||||||
token
|
token
|
||||||
)
|
)
|
||||||
useHotkeyWrapper(['meta + shift + .'], () => coreDump(coreDumpManager, true))
|
useHotkeyWrapper(['meta + shift + .'], () => {
|
||||||
|
console.warn('CoreDump: Initializing core dump')
|
||||||
|
toast.promise(
|
||||||
|
coreDump(coreDumpManager, true),
|
||||||
|
{
|
||||||
|
loading: 'Starting core dump...',
|
||||||
|
success: 'Core dump completed successfully',
|
||||||
|
error: 'Error while exporting core dump',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
success: {
|
||||||
|
// Note: this extended duration is especially important for Playwright e2e testing
|
||||||
|
// default duration is 2000 - https://react-hot-toast.com/docs/toast#default-durations
|
||||||
|
duration: 6000,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
// Settings machine setup
|
// Settings machine setup
|
||||||
// const retrievedSettings = useRef(
|
// const retrievedSettings = useRef(
|
||||||
@ -138,7 +158,41 @@ export const ModelingMachineProvider = ({
|
|||||||
{
|
{
|
||||||
actions: {
|
actions: {
|
||||||
'sketch exit execute': () => {
|
'sketch exit execute': () => {
|
||||||
kclManager.executeCode(true)
|
;(async () => {
|
||||||
|
await sceneInfra.camControls.snapToPerspectiveBeforeHandingBackControlToEngine()
|
||||||
|
|
||||||
|
sceneInfra.camControls.syncDirection = 'engineToClient'
|
||||||
|
|
||||||
|
const settings: Models['CameraSettings_type'] = (
|
||||||
|
await engineCommandManager.sendSceneCommand({
|
||||||
|
type: 'modeling_cmd_req',
|
||||||
|
cmd_id: uuidv4(),
|
||||||
|
cmd: {
|
||||||
|
type: 'default_camera_get_settings',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)?.data?.data?.settings
|
||||||
|
if (settings.up.z !== 1) {
|
||||||
|
// workaround for gimbal lock situation
|
||||||
|
await engineCommandManager.sendSceneCommand({
|
||||||
|
type: 'modeling_cmd_req',
|
||||||
|
cmd_id: uuidv4(),
|
||||||
|
cmd: {
|
||||||
|
type: 'default_camera_look_at',
|
||||||
|
center: settings.center,
|
||||||
|
vantage: {
|
||||||
|
...settings.pos,
|
||||||
|
y:
|
||||||
|
settings.pos.y +
|
||||||
|
(settings.center.z - settings.pos.z > 0 ? 2 : -2),
|
||||||
|
},
|
||||||
|
up: { x: 0, y: 0, z: 1 },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
kclManager.executeCode(true)
|
||||||
|
})()
|
||||||
},
|
},
|
||||||
'Set mouse state': assign({
|
'Set mouse state': assign({
|
||||||
mouseState: (_, event) => event.data,
|
mouseState: (_, event) => event.data,
|
||||||
@ -150,7 +204,7 @@ export const ModelingMachineProvider = ({
|
|||||||
])
|
])
|
||||||
const pathToNode = parent?.userData?.pathToNode
|
const pathToNode = parent?.userData?.pathToNode
|
||||||
const pathToNodeString = JSON.stringify(pathToNode)
|
const pathToNodeString = JSON.stringify(pathToNode)
|
||||||
if (!parent || !pathToNode) return {}
|
if (!parent || !pathToNode) return segmentHoverMap
|
||||||
if (segmentHoverMap[pathToNodeString] !== undefined)
|
if (segmentHoverMap[pathToNodeString] !== undefined)
|
||||||
clearTimeout(segmentHoverMap[JSON.stringify(pathToNode)])
|
clearTimeout(segmentHoverMap[JSON.stringify(pathToNode)])
|
||||||
return {
|
return {
|
||||||
@ -218,9 +272,8 @@ export const ModelingMachineProvider = ({
|
|||||||
}
|
}
|
||||||
: {}
|
: {}
|
||||||
),
|
),
|
||||||
'Set selection': assign(({ selectionRanges }, event) => {
|
'Set selection': assign(({ selectionRanges, sketchDetails }, event) => {
|
||||||
if (event.type !== 'Set selection') return {} // this was needed for ts after adding 'Set selection' action to on done modal events
|
const setSelections = event.data as SetSelections // this was needed for ts after adding 'Set selection' action to on done modal events
|
||||||
const setSelections = event.data
|
|
||||||
if (!editorManager.editorView) return {}
|
if (!editorManager.editorView) return {}
|
||||||
const dispatchSelection = (selection?: EditorSelection) => {
|
const dispatchSelection = (selection?: EditorSelection) => {
|
||||||
if (!selection) return // TODO less of hack for the below please
|
if (!selection) return // TODO less of hack for the below please
|
||||||
@ -307,11 +360,29 @@ export const ModelingMachineProvider = ({
|
|||||||
selectionRanges: selections,
|
selectionRanges: selections,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (setSelections.selectionType === 'completeSelection') {
|
||||||
|
editorManager.selectRange(setSelections.selection)
|
||||||
|
if (!sketchDetails)
|
||||||
|
return {
|
||||||
|
selectionRanges: setSelections.selection,
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
selectionRanges: setSelections.selection,
|
||||||
|
sketchDetails: {
|
||||||
|
...sketchDetails,
|
||||||
|
sketchPathToNode:
|
||||||
|
setSelections.updatedPathToNode ||
|
||||||
|
sketchDetails?.sketchPathToNode ||
|
||||||
|
[],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {}
|
return {}
|
||||||
}),
|
}),
|
||||||
'Engine export': (_, event) => {
|
'Engine export': async (_, event) => {
|
||||||
if (event.type !== 'Export' || TEST) return
|
if (event.type !== 'Export' || TEST) return
|
||||||
|
console.log('exporting', event.data)
|
||||||
const format = {
|
const format = {
|
||||||
...event.data,
|
...event.data,
|
||||||
} as Partial<Models['OutputFormat_type']>
|
} as Partial<Models['OutputFormat_type']>
|
||||||
@ -355,9 +426,16 @@ export const ModelingMachineProvider = ({
|
|||||||
format.selection = { type: 'default_scene' }
|
format.selection = { type: 'default_scene' }
|
||||||
}
|
}
|
||||||
|
|
||||||
exportFromEngine({
|
toast.promise(
|
||||||
format: format as Models['OutputFormat_type'],
|
exportFromEngine({
|
||||||
}).catch((e) => toast.error('Error while exporting', e)) // TODO I think we need to throw the error from engineCommandManager
|
format: format as Models['OutputFormat_type'],
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
loading: 'Exporting...',
|
||||||
|
success: 'Exported successfully',
|
||||||
|
error: 'Error while exporting',
|
||||||
|
}
|
||||||
|
)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
guards: {
|
guards: {
|
||||||
@ -426,7 +504,8 @@ export const ModelingMachineProvider = ({
|
|||||||
const { modifiedAst, pathToNode: pathToNewSketchNode } =
|
const { modifiedAst, pathToNode: pathToNewSketchNode } =
|
||||||
sketchOnExtrudedFace(
|
sketchOnExtrudedFace(
|
||||||
kclManager.ast,
|
kclManager.ast,
|
||||||
data.extrudeSegmentPathToNode,
|
data.sketchPathToNode,
|
||||||
|
data.extrudePathToNode,
|
||||||
kclManager.programMemory,
|
kclManager.programMemory,
|
||||||
data.cap
|
data.cap
|
||||||
)
|
)
|
||||||
@ -436,7 +515,7 @@ export const ModelingMachineProvider = ({
|
|||||||
engineCommandManager,
|
engineCommandManager,
|
||||||
data.faceId
|
data.faceId
|
||||||
)
|
)
|
||||||
|
sceneInfra.camControls.syncDirection = 'clientToEngine'
|
||||||
return {
|
return {
|
||||||
sketchPathToNode: pathToNewSketchNode,
|
sketchPathToNode: pathToNewSketchNode,
|
||||||
zAxis: data.zAxis,
|
zAxis: data.zAxis,
|
||||||
@ -450,8 +529,10 @@ export const ModelingMachineProvider = ({
|
|||||||
)
|
)
|
||||||
await kclManager.updateAst(modifiedAst, false)
|
await kclManager.updateAst(modifiedAst, false)
|
||||||
sceneInfra.camControls.syncDirection = 'clientToEngine'
|
sceneInfra.camControls.syncDirection = 'clientToEngine'
|
||||||
const quat = await getSketchQuaternion(pathToNode, data.zAxis)
|
await letEngineAnimateAndSyncCamAfter(
|
||||||
await sceneInfra.camControls.tweenCameraToQuaternion(quat)
|
engineCommandManager,
|
||||||
|
data.planeId
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
sketchPathToNode: pathToNode,
|
sketchPathToNode: pathToNode,
|
||||||
zAxis: data.zAxis,
|
zAxis: data.zAxis,
|
||||||
@ -481,13 +562,26 @@ export const ModelingMachineProvider = ({
|
|||||||
},
|
},
|
||||||
'Get horizontal info': async ({
|
'Get horizontal info': async ({
|
||||||
selectionRanges,
|
selectionRanges,
|
||||||
|
sketchDetails,
|
||||||
}): Promise<SetSelections> => {
|
}): Promise<SetSelections> => {
|
||||||
const { modifiedAst, pathToNodeMap } =
|
const { modifiedAst, pathToNodeMap } =
|
||||||
await applyConstraintHorzVertDistance({
|
await applyConstraintHorzVertDistance({
|
||||||
constraint: 'setHorzDistance',
|
constraint: 'setHorzDistance',
|
||||||
selectionRanges,
|
selectionRanges,
|
||||||
})
|
})
|
||||||
await kclManager.updateAst(modifiedAst, true)
|
const _modifiedAst = parse(recast(modifiedAst))
|
||||||
|
if (!sketchDetails) throw new Error('No sketch details')
|
||||||
|
const updatedPathToNode = updatePathToNodeFromMap(
|
||||||
|
sketchDetails.sketchPathToNode,
|
||||||
|
pathToNodeMap
|
||||||
|
)
|
||||||
|
await sceneEntitiesManager.updateAstAndRejigSketch(
|
||||||
|
updatedPathToNode,
|
||||||
|
_modifiedAst,
|
||||||
|
sketchDetails.zAxis,
|
||||||
|
sketchDetails.yAxis,
|
||||||
|
sketchDetails.origin
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
selectionType: 'completeSelection',
|
selectionType: 'completeSelection',
|
||||||
selection: pathMapToSelections(
|
selection: pathMapToSelections(
|
||||||
@ -495,17 +589,31 @@ export const ModelingMachineProvider = ({
|
|||||||
selectionRanges,
|
selectionRanges,
|
||||||
pathToNodeMap
|
pathToNodeMap
|
||||||
),
|
),
|
||||||
|
updatedPathToNode,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
'Get vertical info': async ({
|
'Get vertical info': async ({
|
||||||
selectionRanges,
|
selectionRanges,
|
||||||
|
sketchDetails,
|
||||||
}): Promise<SetSelections> => {
|
}): Promise<SetSelections> => {
|
||||||
const { modifiedAst, pathToNodeMap } =
|
const { modifiedAst, pathToNodeMap } =
|
||||||
await applyConstraintHorzVertDistance({
|
await applyConstraintHorzVertDistance({
|
||||||
constraint: 'setVertDistance',
|
constraint: 'setVertDistance',
|
||||||
selectionRanges,
|
selectionRanges,
|
||||||
})
|
})
|
||||||
await kclManager.updateAst(modifiedAst, true)
|
const _modifiedAst = parse(recast(modifiedAst))
|
||||||
|
if (!sketchDetails) throw new Error('No sketch details')
|
||||||
|
const updatedPathToNode = updatePathToNodeFromMap(
|
||||||
|
sketchDetails.sketchPathToNode,
|
||||||
|
pathToNodeMap
|
||||||
|
)
|
||||||
|
await sceneEntitiesManager.updateAstAndRejigSketch(
|
||||||
|
updatedPathToNode,
|
||||||
|
_modifiedAst,
|
||||||
|
sketchDetails.zAxis,
|
||||||
|
sketchDetails.yAxis,
|
||||||
|
sketchDetails.origin
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
selectionType: 'completeSelection',
|
selectionType: 'completeSelection',
|
||||||
selection: pathMapToSelections(
|
selection: pathMapToSelections(
|
||||||
@ -513,10 +621,12 @@ export const ModelingMachineProvider = ({
|
|||||||
selectionRanges,
|
selectionRanges,
|
||||||
pathToNodeMap
|
pathToNodeMap
|
||||||
),
|
),
|
||||||
|
updatedPathToNode,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
'Get angle info': async ({
|
'Get angle info': async ({
|
||||||
selectionRanges,
|
selectionRanges,
|
||||||
|
sketchDetails,
|
||||||
}): Promise<SetSelections> => {
|
}): Promise<SetSelections> => {
|
||||||
const { modifiedAst, pathToNodeMap } = await (angleBetweenInfo({
|
const { modifiedAst, pathToNodeMap } = await (angleBetweenInfo({
|
||||||
selectionRanges,
|
selectionRanges,
|
||||||
@ -528,22 +638,48 @@ export const ModelingMachineProvider = ({
|
|||||||
selectionRanges,
|
selectionRanges,
|
||||||
angleOrLength: 'setAngle',
|
angleOrLength: 'setAngle',
|
||||||
}))
|
}))
|
||||||
await kclManager.updateAst(modifiedAst, true)
|
const _modifiedAst = parse(recast(modifiedAst))
|
||||||
|
if (!sketchDetails) throw new Error('No sketch details')
|
||||||
|
const updatedPathToNode = updatePathToNodeFromMap(
|
||||||
|
sketchDetails.sketchPathToNode,
|
||||||
|
pathToNodeMap
|
||||||
|
)
|
||||||
|
await sceneEntitiesManager.updateAstAndRejigSketch(
|
||||||
|
updatedPathToNode,
|
||||||
|
_modifiedAst,
|
||||||
|
sketchDetails.zAxis,
|
||||||
|
sketchDetails.yAxis,
|
||||||
|
sketchDetails.origin
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
selectionType: 'completeSelection',
|
selectionType: 'completeSelection',
|
||||||
selection: pathMapToSelections(
|
selection: pathMapToSelections(
|
||||||
kclManager.ast,
|
_modifiedAst,
|
||||||
selectionRanges,
|
selectionRanges,
|
||||||
pathToNodeMap
|
pathToNodeMap
|
||||||
),
|
),
|
||||||
|
updatedPathToNode,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
'Get length info': async ({
|
'Get length info': async ({
|
||||||
selectionRanges,
|
selectionRanges,
|
||||||
|
sketchDetails,
|
||||||
}): Promise<SetSelections> => {
|
}): Promise<SetSelections> => {
|
||||||
const { modifiedAst, pathToNodeMap } =
|
const { modifiedAst, pathToNodeMap } =
|
||||||
await applyConstraintAngleLength({ selectionRanges })
|
await applyConstraintAngleLength({ selectionRanges })
|
||||||
await kclManager.updateAst(modifiedAst, true)
|
const _modifiedAst = parse(recast(modifiedAst))
|
||||||
|
if (!sketchDetails) throw new Error('No sketch details')
|
||||||
|
const updatedPathToNode = updatePathToNodeFromMap(
|
||||||
|
sketchDetails.sketchPathToNode,
|
||||||
|
pathToNodeMap
|
||||||
|
)
|
||||||
|
await sceneEntitiesManager.updateAstAndRejigSketch(
|
||||||
|
updatedPathToNode,
|
||||||
|
_modifiedAst,
|
||||||
|
sketchDetails.zAxis,
|
||||||
|
sketchDetails.yAxis,
|
||||||
|
sketchDetails.origin
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
selectionType: 'completeSelection',
|
selectionType: 'completeSelection',
|
||||||
selection: pathMapToSelections(
|
selection: pathMapToSelections(
|
||||||
@ -551,17 +687,31 @@ export const ModelingMachineProvider = ({
|
|||||||
selectionRanges,
|
selectionRanges,
|
||||||
pathToNodeMap
|
pathToNodeMap
|
||||||
),
|
),
|
||||||
|
updatedPathToNode,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
'Get perpendicular distance info': async ({
|
'Get perpendicular distance info': async ({
|
||||||
selectionRanges,
|
selectionRanges,
|
||||||
|
sketchDetails,
|
||||||
}): Promise<SetSelections> => {
|
}): Promise<SetSelections> => {
|
||||||
const { modifiedAst, pathToNodeMap } = await applyConstraintIntersect(
|
const { modifiedAst, pathToNodeMap } = await applyConstraintIntersect(
|
||||||
{
|
{
|
||||||
selectionRanges,
|
selectionRanges,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
await kclManager.updateAst(modifiedAst, true)
|
const _modifiedAst = parse(recast(modifiedAst))
|
||||||
|
if (!sketchDetails) throw new Error('No sketch details')
|
||||||
|
const updatedPathToNode = updatePathToNodeFromMap(
|
||||||
|
sketchDetails.sketchPathToNode,
|
||||||
|
pathToNodeMap
|
||||||
|
)
|
||||||
|
await sceneEntitiesManager.updateAstAndRejigSketch(
|
||||||
|
updatedPathToNode,
|
||||||
|
_modifiedAst,
|
||||||
|
sketchDetails.zAxis,
|
||||||
|
sketchDetails.yAxis,
|
||||||
|
sketchDetails.origin
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
selectionType: 'completeSelection',
|
selectionType: 'completeSelection',
|
||||||
selection: pathMapToSelections(
|
selection: pathMapToSelections(
|
||||||
@ -569,17 +719,31 @@ export const ModelingMachineProvider = ({
|
|||||||
selectionRanges,
|
selectionRanges,
|
||||||
pathToNodeMap
|
pathToNodeMap
|
||||||
),
|
),
|
||||||
|
updatedPathToNode,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
'Get ABS X info': async ({
|
'Get ABS X info': async ({
|
||||||
selectionRanges,
|
selectionRanges,
|
||||||
|
sketchDetails,
|
||||||
}): Promise<SetSelections> => {
|
}): Promise<SetSelections> => {
|
||||||
const { modifiedAst, pathToNodeMap } =
|
const { modifiedAst, pathToNodeMap } =
|
||||||
await applyConstraintAbsDistance({
|
await applyConstraintAbsDistance({
|
||||||
constraint: 'xAbs',
|
constraint: 'xAbs',
|
||||||
selectionRanges,
|
selectionRanges,
|
||||||
})
|
})
|
||||||
await kclManager.updateAst(modifiedAst, true)
|
const _modifiedAst = parse(recast(modifiedAst))
|
||||||
|
if (!sketchDetails) throw new Error('No sketch details')
|
||||||
|
const updatedPathToNode = updatePathToNodeFromMap(
|
||||||
|
sketchDetails.sketchPathToNode,
|
||||||
|
pathToNodeMap
|
||||||
|
)
|
||||||
|
await sceneEntitiesManager.updateAstAndRejigSketch(
|
||||||
|
updatedPathToNode,
|
||||||
|
_modifiedAst,
|
||||||
|
sketchDetails.zAxis,
|
||||||
|
sketchDetails.yAxis,
|
||||||
|
sketchDetails.origin
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
selectionType: 'completeSelection',
|
selectionType: 'completeSelection',
|
||||||
selection: pathMapToSelections(
|
selection: pathMapToSelections(
|
||||||
@ -587,17 +751,31 @@ export const ModelingMachineProvider = ({
|
|||||||
selectionRanges,
|
selectionRanges,
|
||||||
pathToNodeMap
|
pathToNodeMap
|
||||||
),
|
),
|
||||||
|
updatedPathToNode,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
'Get ABS Y info': async ({
|
'Get ABS Y info': async ({
|
||||||
selectionRanges,
|
selectionRanges,
|
||||||
|
sketchDetails,
|
||||||
}): Promise<SetSelections> => {
|
}): Promise<SetSelections> => {
|
||||||
const { modifiedAst, pathToNodeMap } =
|
const { modifiedAst, pathToNodeMap } =
|
||||||
await applyConstraintAbsDistance({
|
await applyConstraintAbsDistance({
|
||||||
constraint: 'yAbs',
|
constraint: 'yAbs',
|
||||||
selectionRanges,
|
selectionRanges,
|
||||||
})
|
})
|
||||||
await kclManager.updateAst(modifiedAst, true)
|
const _modifiedAst = parse(recast(modifiedAst))
|
||||||
|
if (!sketchDetails) throw new Error('No sketch details')
|
||||||
|
const updatedPathToNode = updatePathToNodeFromMap(
|
||||||
|
sketchDetails.sketchPathToNode,
|
||||||
|
pathToNodeMap
|
||||||
|
)
|
||||||
|
await sceneEntitiesManager.updateAstAndRejigSketch(
|
||||||
|
updatedPathToNode,
|
||||||
|
_modifiedAst,
|
||||||
|
sketchDetails.zAxis,
|
||||||
|
sketchDetails.yAxis,
|
||||||
|
sketchDetails.origin
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
selectionType: 'completeSelection',
|
selectionType: 'completeSelection',
|
||||||
selection: pathMapToSelections(
|
selection: pathMapToSelections(
|
||||||
@ -605,6 +783,7 @@ export const ModelingMachineProvider = ({
|
|||||||
selectionRanges,
|
selectionRanges,
|
||||||
pathToNodeMap
|
pathToNodeMap
|
||||||
),
|
),
|
||||||
|
updatedPathToNode,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
'Get convert to variable info': async ({ sketchDetails }, { data }) => {
|
'Get convert to variable info': async ({ sketchDetails }, { data }) => {
|
||||||
@ -658,6 +837,19 @@ export const ModelingMachineProvider = ({
|
|||||||
editorManager.selectionRanges = modelingState.context.selectionRanges
|
editorManager.selectionRanges = modelingState.context.selectionRanges
|
||||||
}, [modelingState.context.selectionRanges])
|
}, [modelingState.context.selectionRanges])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const offlineCallback = () => {
|
||||||
|
// If we are in sketch mode we need to exit it.
|
||||||
|
// TODO: how do i check if we are in a sketch mode, I only want to call
|
||||||
|
// this then.
|
||||||
|
modelingSend({ type: 'Cancel' })
|
||||||
|
}
|
||||||
|
window.addEventListener('offline', offlineCallback)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('offline', offlineCallback)
|
||||||
|
}
|
||||||
|
}, [modelingSend])
|
||||||
|
|
||||||
useStateMachineCommands({
|
useStateMachineCommands({
|
||||||
machineId: 'modeling',
|
machineId: 'modeling',
|
||||||
state: modelingState,
|
state: modelingState,
|
||||||
|
@ -2,7 +2,7 @@
|
|||||||
@apply relative z-0 rounded-r max-w-full h-full flex-1;
|
@apply relative z-0 rounded-r max-w-full h-full flex-1;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-rows: auto 1fr;
|
grid-template-rows: auto 1fr;
|
||||||
@apply bg-chalkboard-10/50 backdrop-blur-sm border border-chalkboard-20;
|
@apply bg-chalkboard-10/50 focus-within:bg-chalkboard-10/90 backdrop-blur-sm border border-chalkboard-20;
|
||||||
scroll-margin-block-start: 41px;
|
scroll-margin-block-start: 41px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -12,7 +12,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
:global(.dark) .panel {
|
:global(.dark) .panel {
|
||||||
@apply bg-chalkboard-100/50 backdrop-blur-[3px] border-chalkboard-80;
|
@apply bg-chalkboard-100/50 focus-within:bg-chalkboard-100/90 backdrop-blur-[3px] border-chalkboard-80;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header {
|
.header {
|
||||||
|
@ -46,7 +46,11 @@ export const ModelingPane = ({
|
|||||||
data-testid={detailsTestId}
|
data-testid={detailsTestId}
|
||||||
id={id}
|
id={id}
|
||||||
className={
|
className={
|
||||||
pointerEventsCssClass + styles.panel + ' group ' + (className || '')
|
'group-focus-within:border-primary dark:group-focus-within:border-chalkboard-50 ' +
|
||||||
|
pointerEventsCssClass +
|
||||||
|
styles.panel +
|
||||||
|
' group ' +
|
||||||
|
(className || '')
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<ModelingPaneHeader title={title} Menu={Menu} />
|
<ModelingPaneHeader title={title} Menu={Menu} />
|
||||||
|
@ -123,70 +123,73 @@ function ModelingSidebarSection({
|
|||||||
}, [showDebugPanel.current, togglePane, openPanes])
|
}, [showDebugPanel.current, togglePane, openPanes])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tab.Group
|
<div className="group contents">
|
||||||
vertical
|
<Tab.Group
|
||||||
selectedIndex={
|
vertical
|
||||||
currentPane === 'none' ? 0 : paneIds.indexOf(currentPane) + 1
|
selectedIndex={
|
||||||
}
|
currentPane === 'none' ? 0 : paneIds.indexOf(currentPane) + 1
|
||||||
onChange={(index) => {
|
|
||||||
const newPane = index === 0 ? 'none' : paneIds[index - 1]
|
|
||||||
togglePane(newPane)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Tab.List
|
|
||||||
className={
|
|
||||||
'pointer-events-auto ' +
|
|
||||||
(alignButtons === 'start'
|
|
||||||
? 'justify-start self-start'
|
|
||||||
: 'justify-end self-end') +
|
|
||||||
(currentPane === 'none'
|
|
||||||
? ' rounded-r focus-within:!border-primary/50'
|
|
||||||
: ' border-r-0') +
|
|
||||||
' p-2 col-start-1 col-span-1 h-fit w-fit flex flex-col items-start gap-2 bg-chalkboard-10 border border-solid border-chalkboard-20 dark:bg-chalkboard-90 dark:border-chalkboard-80 ' +
|
|
||||||
(openPanes.length === 1 && currentPane === 'none' ? 'pr-0.5' : '')
|
|
||||||
}
|
}
|
||||||
|
onChange={(index) => {
|
||||||
|
const newPane = index === 0 ? 'none' : paneIds[index - 1]
|
||||||
|
togglePane(newPane)
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Tab key="none" className="sr-only">
|
<Tab.List
|
||||||
No panes open
|
className={
|
||||||
</Tab>
|
'pointer-events-auto ' +
|
||||||
{filteredPanes.map((pane) => (
|
(alignButtons === 'start'
|
||||||
<ModelingPaneButton
|
? 'justify-start self-start'
|
||||||
key={pane.id}
|
: 'justify-end self-end') +
|
||||||
paneConfig={pane}
|
(currentPane === 'none'
|
||||||
currentPane={currentPane}
|
? ' rounded-r focus-within:!border-primary/50'
|
||||||
togglePane={() => togglePane(pane.id)}
|
: ' border-r-0') +
|
||||||
/>
|
' p-2 col-start-1 col-span-1 h-fit w-fit flex flex-col items-start gap-2 ' +
|
||||||
))}
|
'bg-chalkboard-10 border border-solid border-chalkboard-20 dark:bg-chalkboard-90 dark:border-chalkboard-80 group-focus-within:border-primary dark:group-focus-within:border-chalkboard-50 ' +
|
||||||
</Tab.List>
|
(openPanes.length === 1 && currentPane === 'none' ? 'pr-0.5' : '')
|
||||||
<Tab.Panels
|
}
|
||||||
as="article"
|
>
|
||||||
className={
|
<Tab key="none" className="sr-only">
|
||||||
'col-start-2 col-span-1 ' +
|
No panes open
|
||||||
(openPanes.length === 1
|
</Tab>
|
||||||
? currentPane !== 'none'
|
{filteredPanes.map((pane) => (
|
||||||
? `row-start-1 row-end-3`
|
<ModelingPaneButton
|
||||||
: `hidden`
|
key={pane.id}
|
||||||
: ``)
|
paneConfig={pane}
|
||||||
}
|
currentPane={currentPane}
|
||||||
>
|
togglePane={() => togglePane(pane.id)}
|
||||||
<Tab.Panel key="none" />
|
/>
|
||||||
{filteredPanes.map((pane) => (
|
))}
|
||||||
<Tab.Panel key={pane.id} className="h-full">
|
</Tab.List>
|
||||||
<ModelingPane
|
<Tab.Panels
|
||||||
id={`${pane.id}-pane`}
|
as="article"
|
||||||
title={pane.title}
|
className={
|
||||||
Menu={pane.Menu}
|
'col-start-2 col-span-1 ' +
|
||||||
>
|
(openPanes.length === 1
|
||||||
{pane.Content instanceof Function ? (
|
? currentPane !== 'none'
|
||||||
<pane.Content />
|
? `row-start-1 row-end-3`
|
||||||
) : (
|
: `hidden`
|
||||||
pane.Content
|
: ``)
|
||||||
)}
|
}
|
||||||
</ModelingPane>
|
>
|
||||||
</Tab.Panel>
|
<Tab.Panel key="none" />
|
||||||
))}
|
{filteredPanes.map((pane) => (
|
||||||
</Tab.Panels>
|
<Tab.Panel key={pane.id} className="h-full">
|
||||||
</Tab.Group>
|
<ModelingPane
|
||||||
|
id={`${pane.id}-pane`}
|
||||||
|
title={pane.title}
|
||||||
|
Menu={pane.Menu}
|
||||||
|
>
|
||||||
|
{pane.Content instanceof Function ? (
|
||||||
|
<pane.Content />
|
||||||
|
) : (
|
||||||
|
pane.Content
|
||||||
|
)}
|
||||||
|
</ModelingPane>
|
||||||
|
</Tab.Panel>
|
||||||
|
))}
|
||||||
|
</Tab.Panels>
|
||||||
|
</Tab.Group>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -5,8 +5,8 @@ import { CommandBarProvider } from './CommandBar/CommandBarProvider'
|
|||||||
import {
|
import {
|
||||||
NETWORK_HEALTH_TEXT,
|
NETWORK_HEALTH_TEXT,
|
||||||
NetworkHealthIndicator,
|
NetworkHealthIndicator,
|
||||||
NetworkHealthState,
|
|
||||||
} from './NetworkHealthIndicator'
|
} from './NetworkHealthIndicator'
|
||||||
|
import { NetworkHealthState } from 'hooks/useNetworkStatus'
|
||||||
|
|
||||||
function TestWrap({ children }: { children: React.ReactNode }) {
|
function TestWrap({ children }: { children: React.ReactNode }) {
|
||||||
// wrap in router and xState context
|
// wrap in router and xState context
|
||||||
@ -19,6 +19,7 @@ function TestWrap({ children }: { children: React.ReactNode }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Our Playwright tests for this are much more comprehensive.
|
||||||
describe('NetworkHealthIndicator tests', () => {
|
describe('NetworkHealthIndicator tests', () => {
|
||||||
test('Renders the network indicator', () => {
|
test('Renders the network indicator', () => {
|
||||||
render(
|
render(
|
||||||
@ -29,21 +30,7 @@ describe('NetworkHealthIndicator tests', () => {
|
|||||||
|
|
||||||
fireEvent.click(screen.getByTestId('network-toggle'))
|
fireEvent.click(screen.getByTestId('network-toggle'))
|
||||||
|
|
||||||
expect(screen.getByTestId('network')).toHaveTextContent(
|
// Starts as disconnected
|
||||||
NETWORK_HEALTH_TEXT[NetworkHealthState.Ok]
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('Responds to network changes', () => {
|
|
||||||
render(
|
|
||||||
<TestWrap>
|
|
||||||
<NetworkHealthIndicator />
|
|
||||||
</TestWrap>
|
|
||||||
)
|
|
||||||
|
|
||||||
fireEvent.offline(window)
|
|
||||||
fireEvent.click(screen.getByTestId('network-toggle'))
|
|
||||||
|
|
||||||
expect(screen.getByTestId('network')).toHaveTextContent(
|
expect(screen.getByTestId('network')).toHaveTextContent(
|
||||||
NETWORK_HEALTH_TEXT[NetworkHealthState.Disconnected]
|
NETWORK_HEALTH_TEXT[NetworkHealthState.Disconnected]
|
||||||
)
|
)
|
||||||
|
@ -1,26 +1,13 @@
|
|||||||
import { Popover } from '@headlessui/react'
|
import { Popover } from '@headlessui/react'
|
||||||
import { useEffect, useState } from 'react'
|
|
||||||
import { ActionIcon, ActionIconProps } from './ActionIcon'
|
import { ActionIcon, ActionIconProps } from './ActionIcon'
|
||||||
import {
|
|
||||||
ConnectingType,
|
|
||||||
ConnectingTypeGroup,
|
|
||||||
DisconnectingType,
|
|
||||||
EngineConnectionState,
|
|
||||||
EngineConnectionStateType,
|
|
||||||
ErrorType,
|
|
||||||
initialConnectingTypeGroupState,
|
|
||||||
} from '../lang/std/engineConnection'
|
|
||||||
import { engineCommandManager } from '../lib/singletons'
|
|
||||||
import Tooltip from './Tooltip'
|
import Tooltip from './Tooltip'
|
||||||
|
import { ConnectingTypeGroup } from '../lang/std/engineConnection'
|
||||||
export enum NetworkHealthState {
|
import { useNetworkContext } from '../hooks/useNetworkContext'
|
||||||
Ok,
|
import { NetworkHealthState } from '../hooks/useNetworkStatus'
|
||||||
Issue,
|
|
||||||
Disconnected,
|
|
||||||
}
|
|
||||||
|
|
||||||
export const NETWORK_HEALTH_TEXT: Record<NetworkHealthState, string> = {
|
export const NETWORK_HEALTH_TEXT: Record<NetworkHealthState, string> = {
|
||||||
[NetworkHealthState.Ok]: 'Connected',
|
[NetworkHealthState.Ok]: 'Connected',
|
||||||
|
[NetworkHealthState.Weak]: 'Weak',
|
||||||
[NetworkHealthState.Issue]: 'Problem',
|
[NetworkHealthState.Issue]: 'Problem',
|
||||||
[NetworkHealthState.Disconnected]: 'Offline',
|
[NetworkHealthState.Disconnected]: 'Offline',
|
||||||
}
|
}
|
||||||
@ -61,6 +48,10 @@ const overallConnectionStateColor: Record<NetworkHealthState, IconColorConfig> =
|
|||||||
icon: 'text-succeed-80 dark:text-succeed-10',
|
icon: 'text-succeed-80 dark:text-succeed-10',
|
||||||
bg: 'bg-succeed-10/30 dark:bg-succeed-80/50',
|
bg: 'bg-succeed-10/30 dark:bg-succeed-80/50',
|
||||||
},
|
},
|
||||||
|
[NetworkHealthState.Weak]: {
|
||||||
|
icon: 'text-warn-80 dark:text-warn-10',
|
||||||
|
bg: 'bg-warn-10 dark:bg-warn-80/80',
|
||||||
|
},
|
||||||
[NetworkHealthState.Issue]: {
|
[NetworkHealthState.Issue]: {
|
||||||
icon: 'text-destroy-80 dark:text-destroy-10',
|
icon: 'text-destroy-80 dark:text-destroy-10',
|
||||||
bg: 'bg-destroy-10 dark:bg-destroy-80/80',
|
bg: 'bg-destroy-10 dark:bg-destroy-80/80',
|
||||||
@ -76,125 +67,11 @@ const overallConnectionStateIcon: Record<
|
|||||||
ActionIconProps['icon']
|
ActionIconProps['icon']
|
||||||
> = {
|
> = {
|
||||||
[NetworkHealthState.Ok]: 'network',
|
[NetworkHealthState.Ok]: 'network',
|
||||||
|
[NetworkHealthState.Weak]: 'network',
|
||||||
[NetworkHealthState.Issue]: 'networkCrossedOut',
|
[NetworkHealthState.Issue]: 'networkCrossedOut',
|
||||||
[NetworkHealthState.Disconnected]: 'networkCrossedOut',
|
[NetworkHealthState.Disconnected]: 'networkCrossedOut',
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useNetworkStatus() {
|
|
||||||
const [steps, setSteps] = useState(initialConnectingTypeGroupState)
|
|
||||||
const [internetConnected, setInternetConnected] = useState<boolean>(true)
|
|
||||||
const [overallState, setOverallState] = useState<NetworkHealthState>(
|
|
||||||
NetworkHealthState.Ok
|
|
||||||
)
|
|
||||||
const [hasCopied, setHasCopied] = useState<boolean>(false)
|
|
||||||
|
|
||||||
const [error, setError] = useState<ErrorType | undefined>(undefined)
|
|
||||||
|
|
||||||
const issues: Record<ConnectingTypeGroup, boolean> = {
|
|
||||||
[ConnectingTypeGroup.WebSocket]: steps[ConnectingTypeGroup.WebSocket].some(
|
|
||||||
(a: [ConnectingType, boolean | undefined]) => a[1] === false
|
|
||||||
),
|
|
||||||
[ConnectingTypeGroup.ICE]: steps[ConnectingTypeGroup.ICE].some(
|
|
||||||
(a: [ConnectingType, boolean | undefined]) => a[1] === false
|
|
||||||
),
|
|
||||||
[ConnectingTypeGroup.WebRTC]: steps[ConnectingTypeGroup.WebRTC].some(
|
|
||||||
(a: [ConnectingType, boolean | undefined]) => a[1] === false
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasIssues: boolean =
|
|
||||||
issues[ConnectingTypeGroup.WebSocket] ||
|
|
||||||
issues[ConnectingTypeGroup.ICE] ||
|
|
||||||
issues[ConnectingTypeGroup.WebRTC]
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setOverallState(
|
|
||||||
!internetConnected
|
|
||||||
? NetworkHealthState.Disconnected
|
|
||||||
: hasIssues
|
|
||||||
? NetworkHealthState.Issue
|
|
||||||
: NetworkHealthState.Ok
|
|
||||||
)
|
|
||||||
}, [hasIssues, internetConnected])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const onlineCallback = () => {
|
|
||||||
setSteps(initialConnectingTypeGroupState)
|
|
||||||
setInternetConnected(true)
|
|
||||||
}
|
|
||||||
const offlineCallback = () => {
|
|
||||||
setInternetConnected(false)
|
|
||||||
}
|
|
||||||
window.addEventListener('online', onlineCallback)
|
|
||||||
window.addEventListener('offline', offlineCallback)
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener('online', onlineCallback)
|
|
||||||
window.removeEventListener('offline', offlineCallback)
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
engineCommandManager.onConnectionStateChange(
|
|
||||||
(engineConnectionState: EngineConnectionState) => {
|
|
||||||
let hasSetAStep = false
|
|
||||||
|
|
||||||
if (
|
|
||||||
engineConnectionState.type === EngineConnectionStateType.Connecting
|
|
||||||
) {
|
|
||||||
const groups = Object.values(steps)
|
|
||||||
for (let group of groups) {
|
|
||||||
for (let step of group) {
|
|
||||||
if (step[0] !== engineConnectionState.value.type) continue
|
|
||||||
step[1] = true
|
|
||||||
hasSetAStep = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
engineConnectionState.type === EngineConnectionStateType.Disconnecting
|
|
||||||
) {
|
|
||||||
const groups = Object.values(steps)
|
|
||||||
for (let group of groups) {
|
|
||||||
for (let step of group) {
|
|
||||||
if (
|
|
||||||
engineConnectionState.value.type === DisconnectingType.Error
|
|
||||||
) {
|
|
||||||
if (
|
|
||||||
engineConnectionState.value.value.lastConnectingValue
|
|
||||||
?.type === step[0]
|
|
||||||
) {
|
|
||||||
step[1] = false
|
|
||||||
hasSetAStep = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (engineConnectionState.value.type === DisconnectingType.Error) {
|
|
||||||
setError(engineConnectionState.value.value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasSetAStep) {
|
|
||||||
setSteps(steps)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return {
|
|
||||||
hasIssues,
|
|
||||||
overallState,
|
|
||||||
internetConnected,
|
|
||||||
steps,
|
|
||||||
issues,
|
|
||||||
error,
|
|
||||||
setHasCopied,
|
|
||||||
hasCopied,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const NetworkHealthIndicator = () => {
|
export const NetworkHealthIndicator = () => {
|
||||||
const {
|
const {
|
||||||
hasIssues,
|
hasIssues,
|
||||||
@ -205,7 +82,7 @@ export const NetworkHealthIndicator = () => {
|
|||||||
error,
|
error,
|
||||||
setHasCopied,
|
setHasCopied,
|
||||||
hasCopied,
|
hasCopied,
|
||||||
} = useNetworkStatus()
|
} = useNetworkContext()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover className="relative">
|
<Popover className="relative">
|
||||||
@ -259,18 +136,18 @@ export const NetworkHealthIndicator = () => {
|
|||||||
size="lg"
|
size="lg"
|
||||||
icon={
|
icon={
|
||||||
hasIssueToIcon[
|
hasIssueToIcon[
|
||||||
issues[name as ConnectingTypeGroup].toString()
|
String(issues[name as ConnectingTypeGroup])
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
iconClassName={
|
iconClassName={
|
||||||
hasIssueToIconColors[
|
hasIssueToIconColors[
|
||||||
issues[name as ConnectingTypeGroup].toString()
|
String(issues[name as ConnectingTypeGroup])
|
||||||
].icon
|
].icon
|
||||||
}
|
}
|
||||||
bgClassName={
|
bgClassName={
|
||||||
'rounded-sm ' +
|
'rounded-sm ' +
|
||||||
hasIssueToIconColors[
|
hasIssueToIconColors[
|
||||||
issues[name as ConnectingTypeGroup].toString()
|
String(issues[name as ConnectingTypeGroup])
|
||||||
].bg
|
].bg
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
@ -1,8 +1,7 @@
|
|||||||
import { fireEvent, render, screen } from '@testing-library/react'
|
import { render, screen } from '@testing-library/react'
|
||||||
import { BrowserRouter } from 'react-router-dom'
|
import { BrowserRouter } from 'react-router-dom'
|
||||||
import ProjectSidebarMenu from './ProjectSidebarMenu'
|
import ProjectSidebarMenu from './ProjectSidebarMenu'
|
||||||
import { SettingsAuthProviderJest } from './SettingsAuthProvider'
|
import { SettingsAuthProviderJest } from './SettingsAuthProvider'
|
||||||
import { APP_NAME } from 'lib/constants'
|
|
||||||
import { CommandBarProvider } from './CommandBar/CommandBarProvider'
|
import { CommandBarProvider } from './CommandBar/CommandBarProvider'
|
||||||
import { Project } from 'wasm-lib/kcl/bindings/Project'
|
import { Project } from 'wasm-lib/kcl/bindings/Project'
|
||||||
|
|
||||||
@ -31,43 +30,6 @@ const projectWellFormed = {
|
|||||||
} satisfies Project
|
} satisfies Project
|
||||||
|
|
||||||
describe('ProjectSidebarMenu tests', () => {
|
describe('ProjectSidebarMenu tests', () => {
|
||||||
test('Renders the project name', () => {
|
|
||||||
render(
|
|
||||||
<BrowserRouter>
|
|
||||||
<CommandBarProvider>
|
|
||||||
<SettingsAuthProviderJest>
|
|
||||||
<ProjectSidebarMenu project={projectWellFormed} enableMenu={true} />
|
|
||||||
</SettingsAuthProviderJest>
|
|
||||||
</CommandBarProvider>
|
|
||||||
</BrowserRouter>
|
|
||||||
)
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByTestId('project-sidebar-toggle'))
|
|
||||||
|
|
||||||
expect(screen.getByTestId('projectName')).toHaveTextContent(
|
|
||||||
projectWellFormed.name
|
|
||||||
)
|
|
||||||
expect(screen.getByTestId('createdAt')).toHaveTextContent(
|
|
||||||
`Created ${now.toLocaleDateString()}`
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('Renders app name if given no project', () => {
|
|
||||||
render(
|
|
||||||
<BrowserRouter>
|
|
||||||
<CommandBarProvider>
|
|
||||||
<SettingsAuthProviderJest>
|
|
||||||
<ProjectSidebarMenu enableMenu={true} />
|
|
||||||
</SettingsAuthProviderJest>
|
|
||||||
</CommandBarProvider>
|
|
||||||
</BrowserRouter>
|
|
||||||
)
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByTestId('project-sidebar-toggle'))
|
|
||||||
|
|
||||||
expect(screen.getByTestId('projectName')).toHaveTextContent(APP_NAME)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('Disables popover menu by default', () => {
|
test('Disables popover menu by default', () => {
|
||||||
render(
|
render(
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
|
@ -5,7 +5,6 @@ import { paths } from 'lib/paths'
|
|||||||
import { isTauri } from '../lib/isTauri'
|
import { isTauri } from '../lib/isTauri'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { Fragment } from 'react'
|
import { Fragment } from 'react'
|
||||||
import { FileTree } from './FileTree'
|
|
||||||
import { sep } from '@tauri-apps/api/path'
|
import { sep } from '@tauri-apps/api/path'
|
||||||
import { Logo } from './Logo'
|
import { Logo } from './Logo'
|
||||||
import { APP_NAME } from 'lib/constants'
|
import { APP_NAME } from 'lib/constants'
|
||||||
@ -138,41 +137,7 @@ function ProjectMenuPopover({
|
|||||||
>
|
>
|
||||||
{({ close }) => (
|
{({ close }) => (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center gap-4 px-4 py-3">
|
<div className="flex flex-col gap-2 p-4">
|
||||||
<div>
|
|
||||||
<p className="m-0 text-mono" data-testid="projectName">
|
|
||||||
{project?.name ? project.name : APP_NAME}
|
|
||||||
</p>
|
|
||||||
{project?.metadata && project.metadata.created && (
|
|
||||||
<p
|
|
||||||
className="m-0 text-xs text-chalkboard-80 dark:text-chalkboard-40"
|
|
||||||
data-testid="createdAt"
|
|
||||||
>
|
|
||||||
Created{' '}
|
|
||||||
{new Date(project.metadata.created).toLocaleDateString()}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{isTauri() ? (
|
|
||||||
<FileTree
|
|
||||||
file={file}
|
|
||||||
className="overflow-hidden border-0 border-y border-chalkboard-30 dark:border-chalkboard-80"
|
|
||||||
onNavigateToFile={close}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="flex-1 p-4 text-sm overflow-hidden">
|
|
||||||
<p>
|
|
||||||
In the browser version of Modeling App you can only have one
|
|
||||||
part, and the code is stored in your browser's storage.
|
|
||||||
</p>
|
|
||||||
<p className="my-6">
|
|
||||||
Please save any code you want to keep more permanently, as
|
|
||||||
your browser's storage is not guaranteed to be permanent.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="flex flex-col gap-2 p-4 dark:bg-chalkboard-90">
|
|
||||||
<ActionButton
|
<ActionButton
|
||||||
Element="button"
|
Element="button"
|
||||||
iconStart={{ icon: 'exportFile', className: 'p-1' }}
|
iconStart={{ icon: 'exportFile', className: 'p-1' }}
|
||||||
|
@ -24,9 +24,9 @@ export function RefreshButton() {
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={refresh}
|
onClick={refresh}
|
||||||
className="p-1 m-0 bg-chalkboard-10/80 dark:bg-chalkboard-100/50 hover:bg-chalkboard-10 dark:hover:bg-chalkboard-100 rounded-full border border-solid border-chalkboard-10 dark:border-chalkboard-100"
|
className="p-1 m-0 bg-chalkboard-10/80 dark:bg-chalkboard-100/50 hover:bg-chalkboard-10 dark:hover:bg-chalkboard-100 rounded-full border border-solid border-chalkboard-20 dark:border-chalkboard-90"
|
||||||
>
|
>
|
||||||
<CustomIcon name="arrowRotateRight" className="w-5 h-5" />
|
<CustomIcon name="exclamationMark" className="w-5 h-5" />
|
||||||
<Tooltip position="bottom-right">
|
<Tooltip position="bottom-right">
|
||||||
<span>Refresh and report</span>
|
<span>Refresh and report</span>
|
||||||
<br />
|
<br />
|
||||||
|
87
src/components/Settings/AllKeybindingsFields.tsx
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
import {
|
||||||
|
InteractionMapItem,
|
||||||
|
interactionMap,
|
||||||
|
sortInteractionMapByCategory,
|
||||||
|
} from 'lib/settings/initialKeybindings'
|
||||||
|
import { ForwardedRef, forwardRef } from 'react'
|
||||||
|
import { useLocation } from 'react-router-dom'
|
||||||
|
|
||||||
|
interface AllKeybindingsFieldsProps {}
|
||||||
|
|
||||||
|
export const AllKeybindingsFields = forwardRef(
|
||||||
|
(
|
||||||
|
props: AllKeybindingsFieldsProps,
|
||||||
|
scrollRef: ForwardedRef<HTMLDivElement>
|
||||||
|
) => {
|
||||||
|
// This is how we will get the interaction map from the context
|
||||||
|
// in the future whene franknoirot/editable-hotkeys is merged.
|
||||||
|
// const { state } = useInteractionMapContext()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative overflow-y-auto pb-16">
|
||||||
|
<div ref={scrollRef} className="flex flex-col gap-12">
|
||||||
|
{Object.entries(interactionMap)
|
||||||
|
.sort(sortInteractionMapByCategory)
|
||||||
|
.map(([category, categoryItems]) => (
|
||||||
|
<div className="flex flex-col gap-4 px-2 pr-4">
|
||||||
|
<h2
|
||||||
|
id={`category-${category}`}
|
||||||
|
className="text-xl mt-6 first-of-type:mt-0 capitalize font-bold"
|
||||||
|
>
|
||||||
|
{category}
|
||||||
|
</h2>
|
||||||
|
{categoryItems.map((item) => (
|
||||||
|
<KeybindingField
|
||||||
|
key={category + '-' + item.name}
|
||||||
|
category={category}
|
||||||
|
item={item}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function KeybindingField({
|
||||||
|
item,
|
||||||
|
category,
|
||||||
|
}: {
|
||||||
|
item: InteractionMapItem
|
||||||
|
category: string
|
||||||
|
}) {
|
||||||
|
const location = useLocation()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
'flex gap-16 justify-between items-start py-1 px-2 -my-1 -mx-2 ' +
|
||||||
|
(location.hash === `#${item.name}`
|
||||||
|
? 'bg-primary/5 dark:bg-chalkboard-90'
|
||||||
|
: '')
|
||||||
|
}
|
||||||
|
id={item.name}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-normal capitalize tracking-wide">
|
||||||
|
{item.title}
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-chalkboard-60 dark:text-chalkboard-50">
|
||||||
|
{item.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 flex flex-wrap justify-end gap-3">
|
||||||
|
{item.sequence.split(' ').map((chord, i) => (
|
||||||
|
<kbd
|
||||||
|
key={`${category}-${item.name}-${chord}-${i}`}
|
||||||
|
className="py-0.5 px-1.5 rounded bg-primary/10 dark:bg-chalkboard-80"
|
||||||
|
>
|
||||||
|
{chord}
|
||||||
|
</kbd>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
238
src/components/Settings/AllSettingsFields.tsx
Normal file
@ -0,0 +1,238 @@
|
|||||||
|
import decamelize from 'decamelize'
|
||||||
|
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
|
||||||
|
import { Setting } from 'lib/settings/initialSettings'
|
||||||
|
import { SetEventTypes, SettingsLevel } from 'lib/settings/settingsTypes'
|
||||||
|
import {
|
||||||
|
shouldHideSetting,
|
||||||
|
shouldShowSettingInput,
|
||||||
|
} from 'lib/settings/settingsUtils'
|
||||||
|
import { Fragment } from 'react/jsx-runtime'
|
||||||
|
import { SettingsSection } from './SettingsSection'
|
||||||
|
import { useLocation, useNavigate } from 'react-router-dom'
|
||||||
|
import { isTauri } from 'lib/isTauri'
|
||||||
|
import { ActionButton } from 'components/ActionButton'
|
||||||
|
import { SettingsFieldInput } from './SettingsFieldInput'
|
||||||
|
import { getInitialDefaultDir, showInFolder } from 'lib/tauri'
|
||||||
|
import toast from 'react-hot-toast'
|
||||||
|
import { APP_VERSION } from 'routes/Settings'
|
||||||
|
import { createAndOpenNewProject, getSettingsFolderPaths } from 'lib/tauriFS'
|
||||||
|
import { paths } from 'lib/paths'
|
||||||
|
import { useDotDotSlash } from 'hooks/useDotDotSlash'
|
||||||
|
import { sep } from '@tauri-apps/api/path'
|
||||||
|
import { ForwardedRef, forwardRef } from 'react'
|
||||||
|
|
||||||
|
interface AllSettingsFieldsProps {
|
||||||
|
searchParamTab: SettingsLevel
|
||||||
|
isFileSettings: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AllSettingsFields = forwardRef(
|
||||||
|
(
|
||||||
|
{ searchParamTab, isFileSettings }: AllSettingsFieldsProps,
|
||||||
|
scrollRef: ForwardedRef<HTMLDivElement>
|
||||||
|
) => {
|
||||||
|
const location = useLocation()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const dotDotSlash = useDotDotSlash()
|
||||||
|
const {
|
||||||
|
settings: { send, context },
|
||||||
|
} = useSettingsAuthContext()
|
||||||
|
|
||||||
|
const projectPath =
|
||||||
|
isFileSettings && isTauri()
|
||||||
|
? decodeURI(
|
||||||
|
location.pathname
|
||||||
|
.replace(paths.FILE + '/', '')
|
||||||
|
.replace(paths.SETTINGS, '')
|
||||||
|
.slice(0, decodeURI(location.pathname).lastIndexOf(sep()))
|
||||||
|
)
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
function restartOnboarding() {
|
||||||
|
send({
|
||||||
|
type: `set.app.onboardingStatus`,
|
||||||
|
data: { level: 'user', value: '' },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (isFileSettings) {
|
||||||
|
navigate(dotDotSlash(1) + paths.ONBOARDING.INDEX)
|
||||||
|
} else {
|
||||||
|
createAndOpenNewProject(navigate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative overflow-y-auto">
|
||||||
|
<div ref={scrollRef} className="flex flex-col gap-4 px-2">
|
||||||
|
{Object.entries(context)
|
||||||
|
.filter(([_, categorySettings]) =>
|
||||||
|
// Filter out categories that don't have any non-hidden settings
|
||||||
|
Object.values(categorySettings).some(
|
||||||
|
(setting) => !shouldHideSetting(setting, searchParamTab)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.map(([category, categorySettings]) => (
|
||||||
|
<Fragment key={category}>
|
||||||
|
<h2
|
||||||
|
id={`category-${category}`}
|
||||||
|
className="text-xl mt-6 first-of-type:mt-0 capitalize font-bold"
|
||||||
|
>
|
||||||
|
{decamelize(category, { separator: ' ' })}
|
||||||
|
</h2>
|
||||||
|
{Object.entries(categorySettings)
|
||||||
|
.filter(
|
||||||
|
// Filter out settings that don't have a Component or inputType
|
||||||
|
// or are hidden on the current level or the current platform
|
||||||
|
(item: [string, Setting<unknown>]) =>
|
||||||
|
shouldShowSettingInput(item[1], searchParamTab)
|
||||||
|
)
|
||||||
|
.map(([settingName, s]) => {
|
||||||
|
const setting = s as Setting
|
||||||
|
const parentValue =
|
||||||
|
setting[setting.getParentLevel(searchParamTab)]
|
||||||
|
return (
|
||||||
|
<SettingsSection
|
||||||
|
title={decamelize(settingName, {
|
||||||
|
separator: ' ',
|
||||||
|
})}
|
||||||
|
id={settingName}
|
||||||
|
className={
|
||||||
|
location.hash === `#${settingName}`
|
||||||
|
? 'bg-primary/5 dark:bg-chalkboard-90'
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
key={`${category}-${settingName}-${searchParamTab}`}
|
||||||
|
description={setting.description}
|
||||||
|
settingHasChanged={
|
||||||
|
setting[searchParamTab] !== undefined &&
|
||||||
|
setting[searchParamTab] !==
|
||||||
|
setting.getFallback(searchParamTab)
|
||||||
|
}
|
||||||
|
parentLevel={setting.getParentLevel(searchParamTab)}
|
||||||
|
onFallback={() =>
|
||||||
|
send({
|
||||||
|
type: `set.${category}.${settingName}`,
|
||||||
|
data: {
|
||||||
|
level: searchParamTab,
|
||||||
|
value:
|
||||||
|
parentValue !== undefined
|
||||||
|
? parentValue
|
||||||
|
: setting.getFallback(searchParamTab),
|
||||||
|
},
|
||||||
|
} as SetEventTypes)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SettingsFieldInput
|
||||||
|
category={category}
|
||||||
|
settingName={settingName}
|
||||||
|
settingsLevel={searchParamTab}
|
||||||
|
setting={setting}
|
||||||
|
/>
|
||||||
|
</SettingsSection>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
<h2 id="settings-resets" className="text-2xl mt-6 font-bold">
|
||||||
|
Resets
|
||||||
|
</h2>
|
||||||
|
<SettingsSection
|
||||||
|
title="Onboarding"
|
||||||
|
description="Replay the onboarding process"
|
||||||
|
>
|
||||||
|
<ActionButton
|
||||||
|
Element="button"
|
||||||
|
onClick={restartOnboarding}
|
||||||
|
iconStart={{
|
||||||
|
icon: 'refresh',
|
||||||
|
size: 'sm',
|
||||||
|
className: 'p-1',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Replay Onboarding
|
||||||
|
</ActionButton>
|
||||||
|
</SettingsSection>
|
||||||
|
<SettingsSection
|
||||||
|
title="Reset settings"
|
||||||
|
description={`Restore settings to their default values. Your settings are saved in
|
||||||
|
${
|
||||||
|
isTauri()
|
||||||
|
? ' a file in the app data folder for your OS.'
|
||||||
|
: " your browser's local storage."
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-start gap-4">
|
||||||
|
{isTauri() && (
|
||||||
|
<ActionButton
|
||||||
|
Element="button"
|
||||||
|
onClick={async () => {
|
||||||
|
const paths = await getSettingsFolderPaths(
|
||||||
|
projectPath ? decodeURIComponent(projectPath) : undefined
|
||||||
|
)
|
||||||
|
showInFolder(paths[searchParamTab])
|
||||||
|
}}
|
||||||
|
iconStart={{
|
||||||
|
icon: 'folder',
|
||||||
|
size: 'sm',
|
||||||
|
className: 'p-1',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Show in folder
|
||||||
|
</ActionButton>
|
||||||
|
)}
|
||||||
|
<ActionButton
|
||||||
|
Element="button"
|
||||||
|
onClick={async () => {
|
||||||
|
const defaultDirectory = await getInitialDefaultDir()
|
||||||
|
send({
|
||||||
|
type: 'Reset settings',
|
||||||
|
defaultDirectory,
|
||||||
|
})
|
||||||
|
toast.success('Settings restored to default')
|
||||||
|
}}
|
||||||
|
iconStart={{
|
||||||
|
icon: 'refresh',
|
||||||
|
size: 'sm',
|
||||||
|
className: 'p-1 text-chalkboard-10',
|
||||||
|
bgClassName: 'bg-destroy-70',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Restore default settings
|
||||||
|
</ActionButton>
|
||||||
|
</div>
|
||||||
|
</SettingsSection>
|
||||||
|
<h2 id="settings-about" className="text-2xl mt-6 font-bold">
|
||||||
|
About Modeling App
|
||||||
|
</h2>
|
||||||
|
<div className="text-sm mb-12">
|
||||||
|
<p>
|
||||||
|
{/* This uses a Vite plugin, set in vite.config.ts
|
||||||
|
to inject the version from package.json */}
|
||||||
|
App version {APP_VERSION}.{' '}
|
||||||
|
<a
|
||||||
|
href={`https://github.com/KittyCAD/modeling-app/releases/tag/v${APP_VERSION}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
View release on GitHub
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<p className="max-w-2xl mt-6">
|
||||||
|
Don't see the feature you want? Check to see if it's on{' '}
|
||||||
|
<a
|
||||||
|
href="https://github.com/KittyCAD/modeling-app/discussions"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
our roadmap
|
||||||
|
</a>
|
||||||
|
, and start a discussion if you don't see it! Your feedback will
|
||||||
|
help us prioritize what to build next.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
35
src/components/Settings/KeybindingsSectionsList.tsx
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import {
|
||||||
|
interactionMap,
|
||||||
|
sortInteractionMapByCategory,
|
||||||
|
} from 'lib/settings/initialKeybindings'
|
||||||
|
|
||||||
|
interface KeybindingSectionsListProps {
|
||||||
|
scrollRef: React.RefObject<HTMLDivElement>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function KeybindingsSectionsList({
|
||||||
|
scrollRef,
|
||||||
|
}: KeybindingSectionsListProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex w-32 flex-col gap-3 pr-2 py-1 border-0 border-r border-r-chalkboard-20 dark:border-r-chalkboard-90">
|
||||||
|
{Object.entries(interactionMap)
|
||||||
|
.sort(sortInteractionMapByCategory)
|
||||||
|
.map(([category]) => (
|
||||||
|
<button
|
||||||
|
key={category}
|
||||||
|
onClick={() =>
|
||||||
|
scrollRef.current
|
||||||
|
?.querySelector(`#category-${category}`)
|
||||||
|
?.scrollIntoView({
|
||||||
|
block: 'center',
|
||||||
|
behavior: 'smooth',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="capitalize text-left border-none px-1"
|
||||||
|
>
|
||||||
|
{category}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
@ -3,11 +3,23 @@ import { CustomIcon } from 'components/CustomIcon'
|
|||||||
import decamelize from 'decamelize'
|
import decamelize from 'decamelize'
|
||||||
import Fuse from 'fuse.js'
|
import Fuse from 'fuse.js'
|
||||||
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
|
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
|
||||||
|
import { interactionMap } from 'lib/settings/initialKeybindings'
|
||||||
import { Setting } from 'lib/settings/initialSettings'
|
import { Setting } from 'lib/settings/initialSettings'
|
||||||
|
import { SettingsLevel } from 'lib/settings/settingsTypes'
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { useHotkeys } from 'react-hotkeys-hook'
|
import { useHotkeys } from 'react-hotkeys-hook'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
|
||||||
|
type ExtendedSettingsLevel = SettingsLevel | 'keybindings'
|
||||||
|
|
||||||
|
export type SettingsSearchItem = {
|
||||||
|
name: string
|
||||||
|
displayName: string
|
||||||
|
description: string
|
||||||
|
category: string
|
||||||
|
level: ExtendedSettingsLevel
|
||||||
|
}
|
||||||
|
|
||||||
export function SettingsSearchBar() {
|
export function SettingsSearchBar() {
|
||||||
const inputRef = useRef<HTMLInputElement>(null)
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
useHotkeys(
|
useHotkeys(
|
||||||
@ -21,29 +33,40 @@ export function SettingsSearchBar() {
|
|||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [query, setQuery] = useState('')
|
const [query, setQuery] = useState('')
|
||||||
const { settings } = useSettingsAuthContext()
|
const { settings } = useSettingsAuthContext()
|
||||||
const settingsAsSearchable = useMemo(
|
const settingsAsSearchable: SettingsSearchItem[] = useMemo(
|
||||||
() =>
|
() => [
|
||||||
Object.entries(settings.state.context).flatMap(
|
...Object.entries(settings.state.context).flatMap(
|
||||||
([category, categorySettings]) =>
|
([category, categorySettings]) =>
|
||||||
Object.entries(categorySettings).flatMap(([settingName, setting]) => {
|
Object.entries(categorySettings).flatMap(([settingName, setting]) => {
|
||||||
const s = setting as Setting
|
const s = setting as Setting
|
||||||
return ['project', 'user']
|
return (['project', 'user'] satisfies SettingsLevel[])
|
||||||
.filter((l) => s.hideOnLevel !== l)
|
.filter((l) => s.hideOnLevel !== l)
|
||||||
.map((l) => ({
|
.map((l) => ({
|
||||||
category: decamelize(category, { separator: ' ' }),
|
category: decamelize(category, { separator: ' ' }),
|
||||||
settingName: settingName,
|
name: settingName,
|
||||||
settingNameDisplay: decamelize(settingName, { separator: ' ' }),
|
description: s.description ?? '',
|
||||||
setting: s,
|
displayName: decamelize(settingName, { separator: ' ' }),
|
||||||
level: l,
|
level: l as ExtendedSettingsLevel,
|
||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
),
|
),
|
||||||
|
...Object.entries(interactionMap).flatMap(
|
||||||
|
([category, categoryKeybindings]) =>
|
||||||
|
categoryKeybindings.map((keybinding) => ({
|
||||||
|
name: keybinding.name,
|
||||||
|
displayName: keybinding.title,
|
||||||
|
description: keybinding.description,
|
||||||
|
category: category,
|
||||||
|
level: 'keybindings' as ExtendedSettingsLevel,
|
||||||
|
}))
|
||||||
|
),
|
||||||
|
],
|
||||||
[settings.state.context]
|
[settings.state.context]
|
||||||
)
|
)
|
||||||
const [searchResults, setSearchResults] = useState(settingsAsSearchable)
|
const [searchResults, setSearchResults] = useState(settingsAsSearchable)
|
||||||
|
|
||||||
const fuse = new Fuse(settingsAsSearchable, {
|
const fuse = new Fuse(settingsAsSearchable, {
|
||||||
keys: ['category', 'settingNameDisplay', 'setting.description'],
|
keys: ['category', 'displayName', 'description'],
|
||||||
includeScore: true,
|
includeScore: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -52,16 +75,8 @@ export function SettingsSearchBar() {
|
|||||||
setSearchResults(query.length > 0 ? results : settingsAsSearchable)
|
setSearchResults(query.length > 0 ? results : settingsAsSearchable)
|
||||||
}, [query])
|
}, [query])
|
||||||
|
|
||||||
function handleSelection({
|
function handleSelection({ level, name }: SettingsSearchItem) {
|
||||||
level,
|
navigate(`?tab=${level}#${name}`)
|
||||||
settingName,
|
|
||||||
}: {
|
|
||||||
category: string
|
|
||||||
settingName: string
|
|
||||||
setting: Setting<unknown>
|
|
||||||
level: string
|
|
||||||
}) {
|
|
||||||
navigate(`?tab=${level}#${settingName}`)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -87,18 +102,18 @@ export function SettingsSearchBar() {
|
|||||||
<Combobox.Options className="absolute top-full mt-2 right-0 w-80 overflow-y-auto z-50 max-h-96 cursor-pointer bg-chalkboard-10 dark:bg-chalkboard-100 border border-solid border-primary dark:border-chalkboard-30 rounded">
|
<Combobox.Options className="absolute top-full mt-2 right-0 w-80 overflow-y-auto z-50 max-h-96 cursor-pointer bg-chalkboard-10 dark:bg-chalkboard-100 border border-solid border-primary dark:border-chalkboard-30 rounded">
|
||||||
{searchResults?.map((option) => (
|
{searchResults?.map((option) => (
|
||||||
<Combobox.Option
|
<Combobox.Option
|
||||||
key={`${option.category}-${option.settingName}-${option.level}`}
|
key={`${option.category}-${option.name}-${option.level}`}
|
||||||
value={option}
|
value={option}
|
||||||
className="flex flex-col items-start gap-2 px-4 py-2 ui-active:bg-primary/10 dark:ui-active:bg-chalkboard-90"
|
className="flex flex-col items-start gap-2 px-4 py-2 ui-active:bg-primary/10 dark:ui-active:bg-chalkboard-90"
|
||||||
>
|
>
|
||||||
<p className="flex-grow text-base capitalize m-0 leading-none">
|
<p className="flex-grow text-base capitalize m-0 leading-none">
|
||||||
{option.level} ·{' '}
|
{option.level} ·{' '}
|
||||||
{decamelize(option.category, { separator: ' ' })} ·{' '}
|
{decamelize(option.category, { separator: ' ' })} ·{' '}
|
||||||
{option.settingNameDisplay}
|
{option.displayName}
|
||||||
</p>
|
</p>
|
||||||
{option.setting.description && (
|
{option.description && (
|
||||||
<p className="text-xs leading-tight text-chalkboard-70 dark:text-chalkboard-50">
|
<p className="text-xs leading-tight text-chalkboard-70 dark:text-chalkboard-50">
|
||||||
{option.setting.description}
|
{option.description}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</Combobox.Option>
|
</Combobox.Option>
|
||||||
|
68
src/components/Settings/SettingsSectionsList.tsx
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
import decamelize from 'decamelize'
|
||||||
|
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
|
||||||
|
import { Setting } from 'lib/settings/initialSettings'
|
||||||
|
import { SettingsLevel } from 'lib/settings/settingsTypes'
|
||||||
|
import { shouldHideSetting } from 'lib/settings/settingsUtils'
|
||||||
|
|
||||||
|
interface SettingsSectionsListProps {
|
||||||
|
searchParamTab: SettingsLevel
|
||||||
|
scrollRef: React.RefObject<HTMLDivElement>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SettingsSectionsList({
|
||||||
|
searchParamTab,
|
||||||
|
scrollRef,
|
||||||
|
}: SettingsSectionsListProps) {
|
||||||
|
const {
|
||||||
|
settings: { context },
|
||||||
|
} = useSettingsAuthContext()
|
||||||
|
return (
|
||||||
|
<div className="flex w-32 flex-col gap-3 pr-2 py-1 border-0 border-r border-r-chalkboard-20 dark:border-r-chalkboard-90">
|
||||||
|
{Object.entries(context)
|
||||||
|
.filter(([_, categorySettings]) =>
|
||||||
|
// Filter out categories that don't have any non-hidden settings
|
||||||
|
Object.values(categorySettings).some(
|
||||||
|
(setting: Setting) => !shouldHideSetting(setting, searchParamTab)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.map(([category]) => (
|
||||||
|
<button
|
||||||
|
key={category}
|
||||||
|
onClick={() =>
|
||||||
|
scrollRef.current
|
||||||
|
?.querySelector(`#category-${category}`)
|
||||||
|
?.scrollIntoView({
|
||||||
|
block: 'center',
|
||||||
|
behavior: 'smooth',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="capitalize text-left border-none px-1"
|
||||||
|
>
|
||||||
|
{decamelize(category, { separator: ' ' })}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
scrollRef.current?.querySelector(`#settings-resets`)?.scrollIntoView({
|
||||||
|
block: 'center',
|
||||||
|
behavior: 'smooth',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="capitalize text-left border-none px-1"
|
||||||
|
>
|
||||||
|
Resets
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
scrollRef.current?.querySelector(`#settings-about`)?.scrollIntoView({
|
||||||
|
block: 'center',
|
||||||
|
behavior: 'smooth',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="capitalize text-left border-none px-1"
|
||||||
|
>
|
||||||
|
About
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
@ -34,6 +34,15 @@ export function SettingsTabs({
|
|||||||
)}
|
)}
|
||||||
</RadioGroup.Option>
|
</RadioGroup.Option>
|
||||||
)}
|
)}
|
||||||
|
<RadioGroup.Option value="keybindings">
|
||||||
|
{({ checked }) => (
|
||||||
|
<SettingsTabButton
|
||||||
|
checked={checked}
|
||||||
|
icon="keyboard"
|
||||||
|
text="Keybindings"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</RadioGroup.Option>
|
||||||
</RadioGroup>
|
</RadioGroup>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -171,7 +171,9 @@ export const SettingsAuthProviderBase = ({
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
'Execute AST': () => kclManager.executeCode(true, true),
|
'Execute AST': () => kclManager.executeCode(true, true),
|
||||||
persistSettings: (context) =>
|
},
|
||||||
|
services: {
|
||||||
|
'Persist settings': (context) =>
|
||||||
saveSettings(context, loadedProject?.project?.path),
|
saveSettings(context, loadedProject?.project?.path),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
@ -4,8 +4,9 @@ import { getNormalisedCoordinates } from '../lib/utils'
|
|||||||
import Loading from './Loading'
|
import Loading from './Loading'
|
||||||
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
|
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
|
||||||
import { useModelingContext } from 'hooks/useModelingContext'
|
import { useModelingContext } from 'hooks/useModelingContext'
|
||||||
|
import { useNetworkContext } from 'hooks/useNetworkContext'
|
||||||
|
import { NetworkHealthState } from 'hooks/useNetworkStatus'
|
||||||
import { ClientSideScene } from 'clientSideScene/ClientSideSceneComp'
|
import { ClientSideScene } from 'clientSideScene/ClientSideSceneComp'
|
||||||
import { NetworkHealthState, useNetworkStatus } from './NetworkHealthIndicator'
|
|
||||||
import { butName } from 'lib/cameraControls'
|
import { butName } from 'lib/cameraControls'
|
||||||
import { sendSelectEventToEngine } from 'lib/selections'
|
import { sendSelectEventToEngine } from 'lib/selections'
|
||||||
|
|
||||||
@ -28,8 +29,43 @@ export const Stream = ({ className = '' }: { className?: string }) => {
|
|||||||
}))
|
}))
|
||||||
const { settings } = useSettingsAuthContext()
|
const { settings } = useSettingsAuthContext()
|
||||||
const { state } = useModelingContext()
|
const { state } = useModelingContext()
|
||||||
const { overallState } = useNetworkStatus()
|
const { overallState } = useNetworkContext()
|
||||||
const isNetworkOkay = overallState === NetworkHealthState.Ok
|
|
||||||
|
const isNetworkOkay =
|
||||||
|
overallState === NetworkHealthState.Ok ||
|
||||||
|
overallState === NetworkHealthState.Weak
|
||||||
|
|
||||||
|
// Linux has a default behavior to paste text on middle mouse up
|
||||||
|
// This adds a listener to block that pasting if the click target
|
||||||
|
// is not a text input, so users can move in the 3D scene with
|
||||||
|
// middle mouse drag with a text input focused without pasting.
|
||||||
|
useEffect(() => {
|
||||||
|
const handlePaste = (e: ClipboardEvent) => {
|
||||||
|
const isHtmlElement = e.target && e.target instanceof HTMLElement
|
||||||
|
const isEditable =
|
||||||
|
(isHtmlElement && !('explicitOriginalTarget' in e)) ||
|
||||||
|
('explicitOriginalTarget' in e &&
|
||||||
|
((e.explicitOriginalTarget as HTMLElement).contentEditable ===
|
||||||
|
'true' ||
|
||||||
|
['INPUT', 'TEXTAREA'].some(
|
||||||
|
(tagName) =>
|
||||||
|
tagName === (e.explicitOriginalTarget as HTMLElement).tagName
|
||||||
|
)))
|
||||||
|
if (!isEditable) {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
e.stopImmediatePropagation()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
globalThis?.window?.document?.addEventListener('paste', handlePaste, {
|
||||||
|
capture: true,
|
||||||
|
})
|
||||||
|
return () =>
|
||||||
|
globalThis?.window?.document?.removeEventListener('paste', handlePaste, {
|
||||||
|
capture: true,
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
@ -43,6 +79,7 @@ export const Stream = ({ className = '' }: { className?: string }) => {
|
|||||||
}, [mediaStream])
|
}, [mediaStream])
|
||||||
|
|
||||||
const handleMouseDown: MouseEventHandler<HTMLDivElement> = (e) => {
|
const handleMouseDown: MouseEventHandler<HTMLDivElement> = (e) => {
|
||||||
|
if (!isNetworkOkay) return
|
||||||
if (!videoRef.current) return
|
if (!videoRef.current) return
|
||||||
if (state.matches('Sketch')) return
|
if (state.matches('Sketch')) return
|
||||||
if (state.matches('Sketch no face')) return
|
if (state.matches('Sketch no face')) return
|
||||||
@ -58,6 +95,7 @@ export const Stream = ({ className = '' }: { className?: string }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleMouseUp: MouseEventHandler<HTMLDivElement> = (e) => {
|
const handleMouseUp: MouseEventHandler<HTMLDivElement> = (e) => {
|
||||||
|
if (!isNetworkOkay) return
|
||||||
if (!videoRef.current) return
|
if (!videoRef.current) return
|
||||||
setButtonDownInStream(undefined)
|
setButtonDownInStream(undefined)
|
||||||
if (state.matches('Sketch')) return
|
if (state.matches('Sketch')) return
|
||||||
@ -72,6 +110,7 @@ export const Stream = ({ className = '' }: { className?: string }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleMouseMove: MouseEventHandler<HTMLVideoElement> = (e) => {
|
const handleMouseMove: MouseEventHandler<HTMLVideoElement> = (e) => {
|
||||||
|
if (!isNetworkOkay) return
|
||||||
if (state.matches('Sketch')) return
|
if (state.matches('Sketch')) return
|
||||||
if (state.matches('Sketch no face')) return
|
if (state.matches('Sketch no face')) return
|
||||||
if (!clickCoords) return
|
if (!clickCoords) return
|
||||||
@ -87,8 +126,8 @@ export const Stream = ({ className = '' }: { className?: string }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
id="stream"
|
className="absolute inset-0 z-0"
|
||||||
className={className}
|
data-testid="stream"
|
||||||
onMouseUp={handleMouseUp}
|
onMouseUp={handleMouseUp}
|
||||||
onMouseDown={handleMouseDown}
|
onMouseDown={handleMouseDown}
|
||||||
onContextMenu={(e) => e.preventDefault()}
|
onContextMenu={(e) => e.preventDefault()}
|
||||||
@ -103,7 +142,6 @@ export const Stream = ({ className = '' }: { className?: string }) => {
|
|||||||
onMouseMoveCapture={handleMouseMove}
|
onMouseMoveCapture={handleMouseMove}
|
||||||
className="w-full cursor-pointer h-full"
|
className="w-full cursor-pointer h-full"
|
||||||
disablePictureInPicture
|
disablePictureInPicture
|
||||||
style={{ transitionDuration: '200ms', transitionProperty: 'filter' }}
|
|
||||||
id="video-stream"
|
id="video-stream"
|
||||||
/>
|
/>
|
||||||
<ClientSideScene
|
<ClientSideScene
|
||||||
@ -112,7 +150,7 @@ export const Stream = ({ className = '' }: { className?: string }) => {
|
|||||||
{!isNetworkOkay && !isLoading && (
|
{!isNetworkOkay && !isLoading && (
|
||||||
<div className="text-center absolute inset-0">
|
<div className="text-center absolute inset-0">
|
||||||
<Loading>
|
<Loading>
|
||||||
<span data-testid="loading-stream">Stream disconnected</span>
|
<span data-testid="loading-stream">Stream disconnected...</span>
|
||||||
</Loading>
|
</Loading>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
@ -140,7 +140,11 @@ export async function applyConstraintIntersect({
|
|||||||
value: valueUsedInTransform,
|
value: valueUsedInTransform,
|
||||||
initialVariableName: 'offset',
|
initialVariableName: 'offset',
|
||||||
})
|
})
|
||||||
if (segName === tagInfo?.tag && Number(value) === valueUsedInTransform) {
|
if (
|
||||||
|
!variableName &&
|
||||||
|
segName === tagInfo?.tag &&
|
||||||
|
Number(value) === valueUsedInTransform
|
||||||
|
) {
|
||||||
return {
|
return {
|
||||||
modifiedAst,
|
modifiedAst,
|
||||||
pathToNodeMap,
|
pathToNodeMap,
|
||||||
@ -169,6 +173,10 @@ export async function applyConstraintIntersect({
|
|||||||
createVariableDeclaration(variableName, valueNode)
|
createVariableDeclaration(variableName, valueNode)
|
||||||
)
|
)
|
||||||
_modifiedAst.body = newBody
|
_modifiedAst.body = newBody
|
||||||
|
Object.values(_pathToNodeMap).forEach((pathToNode) => {
|
||||||
|
const index = pathToNode.findIndex((a) => a[0] === 'body') + 1
|
||||||
|
pathToNode[index][0] = Number(pathToNode[index][0]) + 1
|
||||||
|
})
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
modifiedAst: _modifiedAst,
|
modifiedAst: _modifiedAst,
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { toolTips } from '../../useStore'
|
import { toolTips } from '../../useStore'
|
||||||
import { Selections } from 'lib/selections'
|
import { Selection, Selections } from 'lib/selections'
|
||||||
import { Program, Value } from '../../lang/wasm'
|
import { PathToNode, Program, Value } from '../../lang/wasm'
|
||||||
import {
|
import {
|
||||||
getNodePathFromSourceRange,
|
getNodePathFromSourceRange,
|
||||||
getNodeFromPath,
|
getNodeFromPath,
|
||||||
@ -14,15 +14,30 @@ import { kclManager } from 'lib/singletons'
|
|||||||
|
|
||||||
export function removeConstrainingValuesInfo({
|
export function removeConstrainingValuesInfo({
|
||||||
selectionRanges,
|
selectionRanges,
|
||||||
|
pathToNodes,
|
||||||
}: {
|
}: {
|
||||||
selectionRanges: Selections
|
selectionRanges: Selections
|
||||||
|
pathToNodes?: Array<PathToNode>
|
||||||
}) {
|
}) {
|
||||||
const paths = selectionRanges.codeBasedSelections.map(({ range }) =>
|
const paths =
|
||||||
getNodePathFromSourceRange(kclManager.ast, range)
|
pathToNodes ||
|
||||||
)
|
selectionRanges.codeBasedSelections.map(({ range }) =>
|
||||||
|
getNodePathFromSourceRange(kclManager.ast, range)
|
||||||
|
)
|
||||||
const nodes = paths.map(
|
const nodes = paths.map(
|
||||||
(pathToNode) => getNodeFromPath<Value>(kclManager.ast, pathToNode).node
|
(pathToNode) => getNodeFromPath<Value>(kclManager.ast, pathToNode).node
|
||||||
)
|
)
|
||||||
|
const updatedSelectionRanges = pathToNodes
|
||||||
|
? {
|
||||||
|
otherSelections: [],
|
||||||
|
codeBasedSelections: nodes.map(
|
||||||
|
(node): Selection => ({
|
||||||
|
range: [node.start, node.end],
|
||||||
|
type: 'default',
|
||||||
|
})
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: selectionRanges
|
||||||
const isAllTooltips = nodes.every(
|
const isAllTooltips = nodes.every(
|
||||||
(node) =>
|
(node) =>
|
||||||
node?.type === 'CallExpression' &&
|
node?.type === 'CallExpression' &&
|
||||||
@ -31,31 +46,36 @@ export function removeConstrainingValuesInfo({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const transforms = getRemoveConstraintsTransforms(
|
const transforms = getRemoveConstraintsTransforms(
|
||||||
selectionRanges,
|
updatedSelectionRanges,
|
||||||
kclManager.ast,
|
kclManager.ast,
|
||||||
'removeConstrainingValues'
|
'removeConstrainingValues'
|
||||||
)
|
)
|
||||||
|
|
||||||
const enabled = isAllTooltips && transforms.every(Boolean)
|
const enabled = isAllTooltips && transforms.every(Boolean)
|
||||||
return { enabled, transforms }
|
return { enabled, transforms, updatedSelectionRanges }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e)
|
console.error(e)
|
||||||
return { enabled: false, transforms: [] }
|
return { enabled: false, transforms: [], updatedSelectionRanges }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function applyRemoveConstrainingValues({
|
export function applyRemoveConstrainingValues({
|
||||||
selectionRanges,
|
selectionRanges,
|
||||||
|
pathToNodes,
|
||||||
}: {
|
}: {
|
||||||
selectionRanges: Selections
|
selectionRanges: Selections
|
||||||
|
pathToNodes?: Array<PathToNode>
|
||||||
}): {
|
}): {
|
||||||
modifiedAst: Program
|
modifiedAst: Program
|
||||||
pathToNodeMap: PathToNodeMap
|
pathToNodeMap: PathToNodeMap
|
||||||
} {
|
} {
|
||||||
const { transforms } = removeConstrainingValuesInfo({ selectionRanges })
|
const { transforms, updatedSelectionRanges } = removeConstrainingValuesInfo({
|
||||||
|
selectionRanges,
|
||||||
|
pathToNodes,
|
||||||
|
})
|
||||||
return transformAstSketchLines({
|
return transformAstSketchLines({
|
||||||
ast: kclManager.ast,
|
ast: kclManager.ast,
|
||||||
selectionRanges,
|
selectionRanges: updatedSelectionRanges,
|
||||||
transformInfos: transforms,
|
transformInfos: transforms,
|
||||||
programMemory: kclManager.programMemory,
|
programMemory: kclManager.programMemory,
|
||||||
referenceSegName: '',
|
referenceSegName: '',
|
||||||
|
@ -120,6 +120,10 @@ export async function applyConstraintAbsDistance({
|
|||||||
createVariableDeclaration(variableName, valueNode)
|
createVariableDeclaration(variableName, valueNode)
|
||||||
)
|
)
|
||||||
_modifiedAst.body = newBody
|
_modifiedAst.body = newBody
|
||||||
|
Object.values(pathToNodeMap).forEach((pathToNode) => {
|
||||||
|
const index = pathToNode.findIndex((a) => a[0] === 'body') + 1
|
||||||
|
pathToNode[index][0] = Number(pathToNode[index][0]) + 1
|
||||||
|
})
|
||||||
}
|
}
|
||||||
return { modifiedAst: _modifiedAst, pathToNodeMap }
|
return { modifiedAst: _modifiedAst, pathToNodeMap }
|
||||||
}
|
}
|
||||||
|
@ -98,7 +98,11 @@ export async function applyConstraintAngleBetween({
|
|||||||
value: valueUsedInTransform,
|
value: valueUsedInTransform,
|
||||||
initialVariableName: 'angle',
|
initialVariableName: 'angle',
|
||||||
} as any)
|
} as any)
|
||||||
if (segName === tagInfo?.tag && Number(value) === valueUsedInTransform) {
|
if (
|
||||||
|
segName === tagInfo?.tag &&
|
||||||
|
Number(value) === valueUsedInTransform &&
|
||||||
|
!variableName
|
||||||
|
) {
|
||||||
return {
|
return {
|
||||||
modifiedAst,
|
modifiedAst,
|
||||||
pathToNodeMap,
|
pathToNodeMap,
|
||||||
@ -128,6 +132,10 @@ export async function applyConstraintAngleBetween({
|
|||||||
createVariableDeclaration(variableName, valueNode)
|
createVariableDeclaration(variableName, valueNode)
|
||||||
)
|
)
|
||||||
_modifiedAst.body = newBody
|
_modifiedAst.body = newBody
|
||||||
|
Object.values(_pathToNodeMap).forEach((pathToNode) => {
|
||||||
|
const index = pathToNode.findIndex((a) => a[0] === 'body') + 1
|
||||||
|
pathToNode[index][0] = Number(pathToNode[index][0]) + 1
|
||||||
|
})
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
modifiedAst: _modifiedAst,
|
modifiedAst: _modifiedAst,
|
||||||
|
@ -106,7 +106,11 @@ export async function applyConstraintHorzVertDistance({
|
|||||||
value: valueUsedInTransform,
|
value: valueUsedInTransform,
|
||||||
initialVariableName: constraint === 'setHorzDistance' ? 'xDis' : 'yDis',
|
initialVariableName: constraint === 'setHorzDistance' ? 'xDis' : 'yDis',
|
||||||
} as any)
|
} as any)
|
||||||
if (segName === tagInfo?.tag && Number(value) === valueUsedInTransform) {
|
if (
|
||||||
|
!variableName &&
|
||||||
|
segName === tagInfo?.tag &&
|
||||||
|
Number(value) === valueUsedInTransform
|
||||||
|
) {
|
||||||
return {
|
return {
|
||||||
modifiedAst,
|
modifiedAst,
|
||||||
pathToNodeMap,
|
pathToNodeMap,
|
||||||
@ -133,6 +137,10 @@ export async function applyConstraintHorzVertDistance({
|
|||||||
createVariableDeclaration(variableName, valueNode)
|
createVariableDeclaration(variableName, valueNode)
|
||||||
)
|
)
|
||||||
_modifiedAst.body = newBody
|
_modifiedAst.body = newBody
|
||||||
|
Object.values(pathToNodeMap).forEach((pathToNode) => {
|
||||||
|
const index = pathToNode.findIndex((a) => a[0] === 'body') + 1
|
||||||
|
pathToNode[index][0] = Number(pathToNode[index][0]) + 1
|
||||||
|
})
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
modifiedAst: _modifiedAst,
|
modifiedAst: _modifiedAst,
|
||||||
|
@ -138,13 +138,17 @@ export async function applyConstraintAngleLength({
|
|||||||
createVariableDeclaration(variableName, valueNode)
|
createVariableDeclaration(variableName, valueNode)
|
||||||
)
|
)
|
||||||
_modifiedAst.body = newBody
|
_modifiedAst.body = newBody
|
||||||
|
Object.values(pathToNodeMap).forEach((pathToNode) => {
|
||||||
|
const index = pathToNode.findIndex((a) => a[0] === 'body') + 1
|
||||||
|
pathToNode[index][0] = Number(pathToNode[index][0]) + 1
|
||||||
|
})
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
modifiedAst: _modifiedAst,
|
modifiedAst: _modifiedAst,
|
||||||
pathToNodeMap,
|
pathToNodeMap,
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log('erorr', e)
|
console.log('error', e)
|
||||||
throw e
|
throw e
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -11,30 +11,8 @@
|
|||||||
--_p-inline: calc(50% + calc(var(--isRTL) * var(--_triangle-width) / 2));
|
--_p-inline: calc(50% + calc(var(--isRTL) * var(--_triangle-width) / 2));
|
||||||
--_p-block: 4px;
|
--_p-block: 4px;
|
||||||
--_bg: var(--chalkboard-10);
|
--_bg: var(--chalkboard-10);
|
||||||
--_shadow-alpha: 5%;
|
--_shadow-alpha: 8%;
|
||||||
--_theme-alpha: 0.15;
|
--_theme-alpha: 0.15;
|
||||||
--_theme-outline: drop-shadow(
|
|
||||||
0 1px 0
|
|
||||||
oklch(
|
|
||||||
var(--primary-lightness) var(--primary-chroma) var(--primary-hue) /
|
|
||||||
var(--_theme-alpha)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
drop-shadow(
|
|
||||||
0 -1px 0 oklch(var(--primary-lightness) var(--primary-chroma)
|
|
||||||
var(--primary-hue) / var(--_theme-alpha))
|
|
||||||
)
|
|
||||||
drop-shadow(
|
|
||||||
1px 0 0
|
|
||||||
oklch(
|
|
||||||
var(--primary-lightness) var(--primary-chroma) var(--primary-hue) /
|
|
||||||
var(--_theme-alpha)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
drop-shadow(
|
|
||||||
-1px 0 0 oklch(var(--primary-lightness) var(--primary-chroma)
|
|
||||||
var(--primary-hue) / var(--_theme-alpha))
|
|
||||||
);
|
|
||||||
|
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
@ -61,16 +39,15 @@
|
|||||||
background: var(--_bg);
|
background: var(--_bg);
|
||||||
@apply text-chalkboard-110;
|
@apply text-chalkboard-110;
|
||||||
will-change: filter;
|
will-change: filter;
|
||||||
filter: drop-shadow(0 1px 3px hsl(0 0% 0% / var(--_shadow-alpha)))
|
filter: drop-shadow(0 1px 2px hsl(0 0% 0% / var(--_shadow-alpha)))
|
||||||
drop-shadow(0 4px 8px hsl(0 0% 0% / var(--_shadow-alpha)))
|
drop-shadow(0 4px 6px hsl(0 0% 0% / calc(var(--_shadow-alpha) / 2)));
|
||||||
var(--_theme-outline);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(.dark) .tooltip {
|
:global(.dark) .tooltip {
|
||||||
--_bg: var(--chalkboard-110);
|
--_bg: var(--chalkboard-90);
|
||||||
--_theme-alpha: 40%;
|
--_theme-alpha: 40%;
|
||||||
|
--_shadow-alpha: 16%;
|
||||||
@apply text-chalkboard-10;
|
@apply text-chalkboard-10;
|
||||||
filter: var(--_theme-outline);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.tooltip:dir(rtl) {
|
.tooltip:dir(rtl) {
|
||||||
@ -109,7 +86,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Sometimes there's no visible label,
|
/* Sometimes there's no visible label,
|
||||||
* so we'll use the tooltip as the label
|
* so we'll use the tooltip as the label
|
||||||
*/
|
*/
|
||||||
.tooltip:only-child::before {
|
.tooltip:only-child::before {
|
||||||
content: 'Tooltip:';
|
content: 'Tooltip:';
|
||||||
|
@ -39,7 +39,7 @@ const UserSidebarMenu = ({ user }: { user?: User }) => {
|
|||||||
<Popover className="relative">
|
<Popover className="relative">
|
||||||
{user?.image && !imageLoadFailed ? (
|
{user?.image && !imageLoadFailed ? (
|
||||||
<Popover.Button
|
<Popover.Button
|
||||||
className="border-0 rounded-full w-fit min-w-max p-0 group"
|
className="relative border-0 rounded-full w-fit min-w-max p-0 group"
|
||||||
data-testid="user-sidebar-toggle"
|
data-testid="user-sidebar-toggle"
|
||||||
>
|
>
|
||||||
<div className="rounded-full border overflow-hidden">
|
<div className="rounded-full border overflow-hidden">
|
||||||
@ -51,6 +51,9 @@ const UserSidebarMenu = ({ user }: { user?: User }) => {
|
|||||||
onError={() => setImageLoadFailed(true)}
|
onError={() => setImageLoadFailed(true)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<Tooltip position="bottom-right" delay={1000}>
|
||||||
|
User menu
|
||||||
|
</Tooltip>
|
||||||
</Popover.Button>
|
</Popover.Button>
|
||||||
) : (
|
) : (
|
||||||
<ActionButton
|
<ActionButton
|
||||||
@ -59,7 +62,7 @@ const UserSidebarMenu = ({ user }: { user?: User }) => {
|
|||||||
className="border-transparent !px-0"
|
className="border-transparent !px-0"
|
||||||
data-testid="user-sidebar-toggle"
|
data-testid="user-sidebar-toggle"
|
||||||
>
|
>
|
||||||
<Tooltip position="left" delay={1000}>
|
<Tooltip position="bottom-right" delay={1000}>
|
||||||
User menu
|
User menu
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</ActionButton>
|
</ActionButton>
|
||||||
|
@ -7,7 +7,11 @@ import { Selections, processCodeMirrorRanges, Selection } from 'lib/selections'
|
|||||||
import { undo, redo } from '@codemirror/commands'
|
import { undo, redo } from '@codemirror/commands'
|
||||||
import { CommandBarMachineEvent } from 'machines/commandBarMachine'
|
import { CommandBarMachineEvent } from 'machines/commandBarMachine'
|
||||||
import { addLineHighlight } from './highlightextension'
|
import { addLineHighlight } from './highlightextension'
|
||||||
import { setDiagnostics, Diagnostic } from '@codemirror/lint'
|
import { forEachDiagnostic, setDiagnostics, Diagnostic } from '@codemirror/lint'
|
||||||
|
|
||||||
|
function diagnosticIsEqual(d1: Diagnostic, d2: Diagnostic): boolean {
|
||||||
|
return d1.from === d2.from && d1.to === d2.to && d1.message === d2.message
|
||||||
|
}
|
||||||
|
|
||||||
export default class EditorManager {
|
export default class EditorManager {
|
||||||
private _editorView: EditorView | null = null
|
private _editorView: EditorView | null = null
|
||||||
@ -91,11 +95,38 @@ export default class EditorManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
clearDiagnostics(): void {
|
||||||
|
if (!this.editorView) return
|
||||||
|
this.editorView.dispatch(setDiagnostics(this.editorView.state, []))
|
||||||
|
}
|
||||||
|
|
||||||
setDiagnostics(diagnostics: Diagnostic[]): void {
|
setDiagnostics(diagnostics: Diagnostic[]): void {
|
||||||
if (!this.editorView) return
|
if (!this.editorView) return
|
||||||
this.editorView.dispatch(setDiagnostics(this.editorView.state, diagnostics))
|
this.editorView.dispatch(setDiagnostics(this.editorView.state, diagnostics))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
addDiagnostics(diagnostics: Diagnostic[]): void {
|
||||||
|
if (!this.editorView) return
|
||||||
|
|
||||||
|
forEachDiagnostic(this.editorView.state, function (diag) {
|
||||||
|
diagnostics.push(diag)
|
||||||
|
})
|
||||||
|
|
||||||
|
const uniqueDiagnostics = new Set<Diagnostic>()
|
||||||
|
diagnostics.forEach((diagnostic) => {
|
||||||
|
for (const knownDiagnostic of uniqueDiagnostics.values()) {
|
||||||
|
if (diagnosticIsEqual(diagnostic, knownDiagnostic)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
uniqueDiagnostics.add(diagnostic)
|
||||||
|
})
|
||||||
|
|
||||||
|
this.editorView.dispatch(
|
||||||
|
setDiagnostics(this.editorView.state, [...uniqueDiagnostics])
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
undo() {
|
undo() {
|
||||||
if (this._editorView) {
|
if (this._editorView) {
|
||||||
undo(this._editorView)
|
undo(this._editorView)
|
||||||
|
@ -474,19 +474,13 @@ const completionRequester = (client: LanguageServerClient) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const copilotPlugin = (options: LanguageServerOptions): Extension => {
|
export const copilotPlugin = (options: LanguageServerOptions): Extension => {
|
||||||
let plugin: LanguageServerPlugin | null = null
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
documentUri.of(options.documentUri),
|
documentUri.of(options.documentUri),
|
||||||
languageId.of('kcl'),
|
languageId.of('kcl'),
|
||||||
workspaceFolders.of(options.workspaceFolders),
|
workspaceFolders.of(options.workspaceFolders),
|
||||||
ViewPlugin.define(
|
ViewPlugin.define(
|
||||||
(view) =>
|
(view) =>
|
||||||
(plugin = new LanguageServerPlugin(
|
new LanguageServerPlugin(options.client, view, options.allowHTMLContent)
|
||||||
options.client,
|
|
||||||
view,
|
|
||||||
options.allowHTMLContent
|
|
||||||
))
|
|
||||||
),
|
),
|
||||||
completionDecoration,
|
completionDecoration,
|
||||||
Prec.highest(completionPlugin(options.client)),
|
Prec.highest(completionPlugin(options.client)),
|
||||||
|
@ -382,9 +382,14 @@ export class LanguageServerPlugin implements PluginValue {
|
|||||||
try {
|
try {
|
||||||
switch (notification.method) {
|
switch (notification.method) {
|
||||||
case 'textDocument/publishDiagnostics':
|
case 'textDocument/publishDiagnostics':
|
||||||
//const params = notification.params as PublishDiagnosticsParams
|
console.log(
|
||||||
|
'[lsp] [window/publishDiagnostics]',
|
||||||
|
this.client.getName(),
|
||||||
|
notification.params
|
||||||
|
)
|
||||||
|
const params = notification.params as PublishDiagnosticsParams
|
||||||
// this is sometimes slower than our actual typing.
|
// this is sometimes slower than our actual typing.
|
||||||
//this.processDiagnostics(params)
|
this.processDiagnostics(params)
|
||||||
break
|
break
|
||||||
case 'window/logMessage':
|
case 'window/logMessage':
|
||||||
console.log(
|
console.log(
|
||||||
|
25
src/hooks/useNetworkContext.tsx
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import { createContext, useContext } from 'react'
|
||||||
|
import {
|
||||||
|
ConnectingTypeGroup,
|
||||||
|
initialConnectingTypeGroupState,
|
||||||
|
} from '../lang/std/engineConnection'
|
||||||
|
import { NetworkStatus, NetworkHealthState } from './useNetworkStatus'
|
||||||
|
|
||||||
|
export const NetworkContext = createContext<NetworkStatus>({
|
||||||
|
hasIssues: undefined,
|
||||||
|
overallState: NetworkHealthState.Disconnected,
|
||||||
|
internetConnected: true,
|
||||||
|
steps: structuredClone(initialConnectingTypeGroupState),
|
||||||
|
issues: {
|
||||||
|
[ConnectingTypeGroup.WebSocket]: undefined,
|
||||||
|
[ConnectingTypeGroup.ICE]: undefined,
|
||||||
|
[ConnectingTypeGroup.WebRTC]: undefined,
|
||||||
|
},
|
||||||
|
error: undefined,
|
||||||
|
setHasCopied: (b: boolean) => {},
|
||||||
|
hasCopied: false,
|
||||||
|
pingPongHealth: undefined,
|
||||||
|
} as NetworkStatus)
|
||||||
|
export const useNetworkContext = () => {
|
||||||
|
return useContext(NetworkContext)
|
||||||
|
}
|
228
src/hooks/useNetworkStatus.tsx
Normal file
@ -0,0 +1,228 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import {
|
||||||
|
ConnectingType,
|
||||||
|
ConnectingTypeGroup,
|
||||||
|
DisconnectingType,
|
||||||
|
EngineCommandManagerEvents,
|
||||||
|
EngineConnectionEvents,
|
||||||
|
EngineConnectionStateType,
|
||||||
|
ErrorType,
|
||||||
|
initialConnectingTypeGroupState,
|
||||||
|
} from '../lang/std/engineConnection'
|
||||||
|
import { engineCommandManager } from '../lib/singletons'
|
||||||
|
|
||||||
|
export enum NetworkHealthState {
|
||||||
|
Ok,
|
||||||
|
Weak,
|
||||||
|
Issue,
|
||||||
|
Disconnected,
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NetworkStatus {
|
||||||
|
hasIssues: boolean | undefined
|
||||||
|
overallState: NetworkHealthState
|
||||||
|
internetConnected: boolean
|
||||||
|
steps: typeof initialConnectingTypeGroupState
|
||||||
|
issues: Record<ConnectingTypeGroup, boolean | undefined>
|
||||||
|
error: ErrorType | undefined
|
||||||
|
setHasCopied: (b: boolean) => void
|
||||||
|
hasCopied: boolean
|
||||||
|
pingPongHealth: undefined | 'OK' | 'TIMEOUT'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Must be called from one place in the application.
|
||||||
|
// We've chosen the <Router /> component for this.
|
||||||
|
export function useNetworkStatus() {
|
||||||
|
const [steps, setSteps] = useState(
|
||||||
|
structuredClone(initialConnectingTypeGroupState)
|
||||||
|
)
|
||||||
|
const [internetConnected, setInternetConnected] = useState<boolean>(true)
|
||||||
|
const [overallState, setOverallState] = useState<NetworkHealthState>(
|
||||||
|
NetworkHealthState.Disconnected
|
||||||
|
)
|
||||||
|
const [pingPongHealth, setPingPongHealth] = useState<
|
||||||
|
undefined | 'OK' | 'TIMEOUT'
|
||||||
|
>(undefined)
|
||||||
|
const [hasCopied, setHasCopied] = useState<boolean>(false)
|
||||||
|
|
||||||
|
const [error, setError] = useState<ErrorType | undefined>(undefined)
|
||||||
|
|
||||||
|
const hasIssue = (i: [ConnectingType, boolean | undefined]) =>
|
||||||
|
i[1] === undefined ? i[1] : !i[1]
|
||||||
|
|
||||||
|
const [issues, setIssues] = useState<
|
||||||
|
Record<ConnectingTypeGroup, boolean | undefined>
|
||||||
|
>({
|
||||||
|
[ConnectingTypeGroup.WebSocket]: undefined,
|
||||||
|
[ConnectingTypeGroup.ICE]: undefined,
|
||||||
|
[ConnectingTypeGroup.WebRTC]: undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
const [hasIssues, setHasIssues] = useState<boolean | undefined>(undefined)
|
||||||
|
useEffect(() => {
|
||||||
|
setOverallState(
|
||||||
|
!internetConnected
|
||||||
|
? NetworkHealthState.Disconnected
|
||||||
|
: hasIssues || hasIssues === undefined
|
||||||
|
? NetworkHealthState.Issue
|
||||||
|
: pingPongHealth === 'TIMEOUT'
|
||||||
|
? NetworkHealthState.Weak
|
||||||
|
: NetworkHealthState.Ok
|
||||||
|
)
|
||||||
|
}, [hasIssues, internetConnected, pingPongHealth])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onlineCallback = () => {
|
||||||
|
setInternetConnected(true)
|
||||||
|
}
|
||||||
|
const offlineCallback = () => {
|
||||||
|
setInternetConnected(false)
|
||||||
|
setSteps(structuredClone(initialConnectingTypeGroupState))
|
||||||
|
}
|
||||||
|
window.addEventListener('online', onlineCallback)
|
||||||
|
window.addEventListener('offline', offlineCallback)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('online', onlineCallback)
|
||||||
|
window.removeEventListener('offline', offlineCallback)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const issues = {
|
||||||
|
[ConnectingTypeGroup.WebSocket]: steps[
|
||||||
|
ConnectingTypeGroup.WebSocket
|
||||||
|
].reduce(
|
||||||
|
(acc: boolean | undefined, a) =>
|
||||||
|
acc === true || acc === undefined ? acc : hasIssue(a),
|
||||||
|
false
|
||||||
|
),
|
||||||
|
[ConnectingTypeGroup.ICE]: steps[ConnectingTypeGroup.ICE].reduce(
|
||||||
|
(acc: boolean | undefined, a) =>
|
||||||
|
acc === true || acc === undefined ? acc : hasIssue(a),
|
||||||
|
false
|
||||||
|
),
|
||||||
|
[ConnectingTypeGroup.WebRTC]: steps[ConnectingTypeGroup.WebRTC].reduce(
|
||||||
|
(acc: boolean | undefined, a) =>
|
||||||
|
acc === true || acc === undefined ? acc : hasIssue(a),
|
||||||
|
false
|
||||||
|
),
|
||||||
|
}
|
||||||
|
setIssues(issues)
|
||||||
|
}, [steps])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setHasIssues(
|
||||||
|
issues[ConnectingTypeGroup.WebSocket] ||
|
||||||
|
issues[ConnectingTypeGroup.ICE] ||
|
||||||
|
issues[ConnectingTypeGroup.WebRTC]
|
||||||
|
)
|
||||||
|
}, [issues])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onPingPongChange = ({ detail: state }: CustomEvent) => {
|
||||||
|
setPingPongHealth(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onConnectionStateChange = ({
|
||||||
|
detail: engineConnectionState,
|
||||||
|
}: CustomEvent) => {
|
||||||
|
setSteps((steps) => {
|
||||||
|
let nextSteps = structuredClone(steps)
|
||||||
|
|
||||||
|
if (
|
||||||
|
engineConnectionState.type === EngineConnectionStateType.Connecting
|
||||||
|
) {
|
||||||
|
const groups = Object.values(nextSteps)
|
||||||
|
for (let group of groups) {
|
||||||
|
for (let step of group) {
|
||||||
|
if (step[0] !== engineConnectionState.value.type) continue
|
||||||
|
step[1] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
engineConnectionState.type === EngineConnectionStateType.Disconnecting
|
||||||
|
) {
|
||||||
|
const groups = Object.values(nextSteps)
|
||||||
|
for (let group of groups) {
|
||||||
|
for (let step of group) {
|
||||||
|
if (
|
||||||
|
engineConnectionState.value.type === DisconnectingType.Error
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
engineConnectionState.value.value.lastConnectingValue
|
||||||
|
?.type === step[0]
|
||||||
|
) {
|
||||||
|
step[1] = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (engineConnectionState.value.type === DisconnectingType.Error) {
|
||||||
|
setError(engineConnectionState.value.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset the state of all steps if we have disconnected.
|
||||||
|
if (
|
||||||
|
engineConnectionState.type === EngineConnectionStateType.Disconnected
|
||||||
|
) {
|
||||||
|
return structuredClone(initialConnectingTypeGroupState)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nextSteps
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const onEngineAvailable = ({ detail: engineConnection }: CustomEvent) => {
|
||||||
|
engineConnection.addEventListener(
|
||||||
|
EngineConnectionEvents.PingPongChanged,
|
||||||
|
onPingPongChange as EventListener
|
||||||
|
)
|
||||||
|
engineConnection.addEventListener(
|
||||||
|
EngineConnectionEvents.ConnectionStateChanged,
|
||||||
|
onConnectionStateChange as EventListener
|
||||||
|
)
|
||||||
|
|
||||||
|
// Tell EngineConnection to start firing events.
|
||||||
|
window.dispatchEvent(new CustomEvent('use-network-status-ready', {}))
|
||||||
|
}
|
||||||
|
|
||||||
|
engineCommandManager.addEventListener(
|
||||||
|
EngineCommandManagerEvents.EngineAvailable,
|
||||||
|
onEngineAvailable as EventListener
|
||||||
|
)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
engineCommandManager.removeEventListener(
|
||||||
|
EngineCommandManagerEvents.EngineAvailable,
|
||||||
|
onEngineAvailable as EventListener
|
||||||
|
)
|
||||||
|
|
||||||
|
// When the component is unmounted these should be assigned, but it's possible
|
||||||
|
// the component mounts and unmounts before engine is available.
|
||||||
|
engineCommandManager.engineConnection?.addEventListener(
|
||||||
|
EngineConnectionEvents.PingPongChanged,
|
||||||
|
onPingPongChange as EventListener
|
||||||
|
)
|
||||||
|
engineCommandManager.engineConnection?.addEventListener(
|
||||||
|
EngineConnectionEvents.ConnectionStateChanged,
|
||||||
|
onConnectionStateChange as EventListener
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return {
|
||||||
|
hasIssues,
|
||||||
|
overallState,
|
||||||
|
internetConnected,
|
||||||
|
steps,
|
||||||
|
issues,
|
||||||
|
error,
|
||||||
|
setHasCopied,
|
||||||
|
hasCopied,
|
||||||
|
pingPongHealth,
|
||||||
|
}
|
||||||
|
}
|
@ -43,7 +43,7 @@ export function useSetupEngineManager(
|
|||||||
engineCommandManager.pool = settings.pool
|
engineCommandManager.pool = settings.pool
|
||||||
}
|
}
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
const startEngineInstance = () => {
|
||||||
// Load the engine command manager once with the initial width and height,
|
// Load the engine command manager once with the initial width and height,
|
||||||
// then we do not want to reload it.
|
// then we do not want to reload it.
|
||||||
const { width: quadWidth, height: quadHeight } = getDimensions(
|
const { width: quadWidth, height: quadHeight } = getDimensions(
|
||||||
@ -73,7 +73,12 @@ export function useSetupEngineManager(
|
|||||||
})
|
})
|
||||||
hasSetNonZeroDimensions.current = true
|
hasSetNonZeroDimensions.current = true
|
||||||
}
|
}
|
||||||
}, [streamRef?.current?.offsetWidth, streamRef?.current?.offsetHeight])
|
}
|
||||||
|
|
||||||
|
useLayoutEffect(startEngineInstance, [
|
||||||
|
streamRef?.current?.offsetWidth,
|
||||||
|
streamRef?.current?.offsetHeight,
|
||||||
|
])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleResize = deferExecution(() => {
|
const handleResize = deferExecution(() => {
|
||||||
@ -96,8 +101,20 @@ export function useSetupEngineManager(
|
|||||||
}
|
}
|
||||||
}, 500)
|
}, 500)
|
||||||
|
|
||||||
|
const onOnline = () => {
|
||||||
|
startEngineInstance()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onOffline = () => {
|
||||||
|
engineCommandManager.tearDown()
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('online', onOnline)
|
||||||
|
window.addEventListener('offline', onOffline)
|
||||||
window.addEventListener('resize', handleResize)
|
window.addEventListener('resize', handleResize)
|
||||||
return () => {
|
return () => {
|
||||||
|
window.removeEventListener('online', onOnline)
|
||||||
|
window.removeEventListener('offline', onOffline)
|
||||||
window.removeEventListener('resize', handleResize)
|
window.removeEventListener('resize', handleResize)
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
@ -7,12 +7,10 @@ import { authMachine } from 'machines/authMachine'
|
|||||||
import { settingsMachine } from 'machines/settingsMachine'
|
import { settingsMachine } from 'machines/settingsMachine'
|
||||||
import { homeMachine } from 'machines/homeMachine'
|
import { homeMachine } from 'machines/homeMachine'
|
||||||
import { Command, CommandSetConfig, CommandSetSchema } from 'lib/commandTypes'
|
import { Command, CommandSetConfig, CommandSetSchema } from 'lib/commandTypes'
|
||||||
import {
|
|
||||||
NetworkHealthState,
|
|
||||||
useNetworkStatus,
|
|
||||||
} from 'components/NetworkHealthIndicator'
|
|
||||||
import { useKclContext } from 'lang/KclProvider'
|
import { useKclContext } from 'lang/KclProvider'
|
||||||
import { useStore } from 'useStore'
|
import { useStore } from 'useStore'
|
||||||
|
import { useNetworkContext } from 'hooks/useNetworkContext'
|
||||||
|
import { NetworkHealthState } from 'hooks/useNetworkStatus'
|
||||||
|
|
||||||
// This might not be necessary, AnyStateMachine from xstate is working
|
// This might not be necessary, AnyStateMachine from xstate is working
|
||||||
export type AllMachines =
|
export type AllMachines =
|
||||||
@ -47,7 +45,7 @@ export default function useStateMachineCommands<
|
|||||||
onCancel,
|
onCancel,
|
||||||
}: UseStateMachineCommandsArgs<T, S>) {
|
}: UseStateMachineCommandsArgs<T, S>) {
|
||||||
const { commandBarSend } = useCommandsContext()
|
const { commandBarSend } = useCommandsContext()
|
||||||
const { overallState } = useNetworkStatus()
|
const { overallState } = useNetworkContext()
|
||||||
const { isExecuting } = useKclContext()
|
const { isExecuting } = useKclContext()
|
||||||
const { isStreamReady } = useStore((s) => ({
|
const { isStreamReady } = useStore((s) => ({
|
||||||
isStreamReady: s.isStreamReady,
|
isStreamReady: s.isStreamReady,
|
||||||
@ -55,7 +53,10 @@ export default function useStateMachineCommands<
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const disableAllButtons =
|
const disableAllButtons =
|
||||||
overallState !== NetworkHealthState.Ok || isExecuting || !isStreamReady
|
(overallState !== NetworkHealthState.Ok &&
|
||||||
|
overallState !== NetworkHealthState.Weak) ||
|
||||||
|
isExecuting ||
|
||||||
|
!isStreamReady
|
||||||
const newCommands = state.nextEvents
|
const newCommands = state.nextEvents
|
||||||
.filter((_) => !allCommandsRequireNetwork || !disableAllButtons)
|
.filter((_) => !allCommandsRequireNetwork || !disableAllButtons)
|
||||||
.filter((e) => !['done.', 'error.'].some((n) => e.includes(n)))
|
.filter((e) => !['done.', 'error.'].some((n) => e.includes(n)))
|
||||||
|
@ -147,15 +147,33 @@ code {
|
|||||||
|
|
||||||
#code-mirror-override .cm-activeLine,
|
#code-mirror-override .cm-activeLine,
|
||||||
#code-mirror-override .cm-activeLineGutter {
|
#code-mirror-override .cm-activeLineGutter {
|
||||||
@apply bg-primary/10;
|
@apply bg-primary/5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark #code-mirror-override .cm-activeLine,
|
.dark #code-mirror-override .cm-activeLine,
|
||||||
.dark #code-mirror-override .cm-activeLineGutter {
|
.dark #code-mirror-override .cm-activeLineGutter {
|
||||||
@apply bg-primary/20;
|
@apply bg-chalkboard-70/20;
|
||||||
mix-blend-mode: lighten;
|
mix-blend-mode: lighten;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#code-mirror-override .cm-focused .cm-activeLine,
|
||||||
|
#code-mirror-override .cm-focused .cm-activeLineGutter {
|
||||||
|
@apply bg-primary/10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark #code-mirror-override .cm-focused .cm-activeLine,
|
||||||
|
.dark #code-mirror-override .cm-focused .cm-activeLineGutter {
|
||||||
|
@apply bg-chalkboard-70/40;
|
||||||
|
}
|
||||||
|
|
||||||
|
#code-mirror-override .cm-matchingBracket {
|
||||||
|
@apply bg-primary/20;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark #code-mirror-override .cm-matchingBracket {
|
||||||
|
@apply bg-chalkboard-70/60;
|
||||||
|
}
|
||||||
|
|
||||||
#code-mirror-override .cm-gutters {
|
#code-mirror-override .cm-gutters {
|
||||||
@apply bg-chalkboard-10/30;
|
@apply bg-chalkboard-10/30;
|
||||||
}
|
}
|
||||||
@ -171,22 +189,8 @@ code {
|
|||||||
@apply caret-chalkboard-10;
|
@apply caret-chalkboard-10;
|
||||||
}
|
}
|
||||||
|
|
||||||
#code-mirror-override .cm-focused .cm-cursor {
|
#code-mirror-override .cm-focused {
|
||||||
width: 0px;
|
outline: none;
|
||||||
}
|
|
||||||
#code-mirror-override .cm-cursor {
|
|
||||||
display: block;
|
|
||||||
width: 1ch;
|
|
||||||
@apply mix-blend-multiply;
|
|
||||||
@apply border-l-primary;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark #code-mirror-override .cm-cursor {
|
|
||||||
@apply border-l-chalkboard-10;
|
|
||||||
}
|
|
||||||
|
|
||||||
#code-mirror-override.blink .cm-cursor {
|
|
||||||
animation: blink 1200ms ease-out infinite;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes blink {
|
@keyframes blink {
|
||||||
@ -249,3 +253,10 @@ code {
|
|||||||
.cm-ghostText * {
|
.cm-ghostText * {
|
||||||
color: rgb(120, 120, 120, 0.8) !important;
|
color: rgb(120, 120, 120, 0.8) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
kbd.hotkey {
|
||||||
|
@apply font-mono text-xs inline-block px-1 py-0.5 rounded-sm;
|
||||||
|
@apply bg-chalkboard-20 dark:bg-chalkboard-90;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -41,7 +41,10 @@ export class KclManager {
|
|||||||
engineCommandManager: EngineCommandManager
|
engineCommandManager: EngineCommandManager
|
||||||
private _defferer = deferExecution((code: string) => {
|
private _defferer = deferExecution((code: string) => {
|
||||||
const ast = this.safeParse(code)
|
const ast = this.safeParse(code)
|
||||||
if (!ast) return
|
if (!ast) {
|
||||||
|
this.clearAst()
|
||||||
|
return
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const fmtAndStringify = (ast: Program) =>
|
const fmtAndStringify = (ast: Program) =>
|
||||||
JSON.stringify(parse(recast(ast)))
|
JSON.stringify(parse(recast(ast)))
|
||||||
@ -91,7 +94,7 @@ export class KclManager {
|
|||||||
set kclErrors(kclErrors) {
|
set kclErrors(kclErrors) {
|
||||||
this._kclErrors = kclErrors
|
this._kclErrors = kclErrors
|
||||||
let diagnostics = kclErrorsToDiagnostics(kclErrors)
|
let diagnostics = kclErrorsToDiagnostics(kclErrors)
|
||||||
editorManager.setDiagnostics(diagnostics)
|
editorManager.addDiagnostics(diagnostics)
|
||||||
this._kclErrorsCallBack(kclErrors)
|
this._kclErrorsCallBack(kclErrors)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -145,6 +148,18 @@ export class KclManager {
|
|||||||
this._executeCallback = callback
|
this._executeCallback = callback
|
||||||
}
|
}
|
||||||
|
|
||||||
|
clearAst() {
|
||||||
|
this._ast = {
|
||||||
|
body: [],
|
||||||
|
start: 0,
|
||||||
|
end: 0,
|
||||||
|
nonCodeMeta: {
|
||||||
|
nonCodeNodes: {},
|
||||||
|
start: [],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
safeParse(code: string): Program | null {
|
safeParse(code: string): Program | null {
|
||||||
try {
|
try {
|
||||||
const ast = parse(code)
|
const ast = parse(code)
|
||||||
@ -185,6 +200,11 @@ export class KclManager {
|
|||||||
const currentExecutionId = executionId || Date.now()
|
const currentExecutionId = executionId || Date.now()
|
||||||
this._cancelTokens.set(currentExecutionId, false)
|
this._cancelTokens.set(currentExecutionId, false)
|
||||||
|
|
||||||
|
// here we're going to clear diagnostics since we're the first
|
||||||
|
// one in. We're the only location where diagnostics are cleared;
|
||||||
|
// everything from here on out should be *appending*.
|
||||||
|
editorManager.clearDiagnostics()
|
||||||
|
|
||||||
this.isExecuting = true
|
this.isExecuting = true
|
||||||
await this.ensureWasmInit()
|
await this.ensureWasmInit()
|
||||||
const { logs, errors, programMemory } = await executeAst({
|
const { logs, errors, programMemory } = await executeAst({
|
||||||
@ -234,6 +254,7 @@ export class KclManager {
|
|||||||
} = { updates: 'none' }
|
} = { updates: 'none' }
|
||||||
) {
|
) {
|
||||||
await this.ensureWasmInit()
|
await this.ensureWasmInit()
|
||||||
|
|
||||||
const newCode = recast(ast)
|
const newCode = recast(ast)
|
||||||
const newAst = this.safeParse(newCode)
|
const newAst = this.safeParse(newCode)
|
||||||
if (!newAst) return
|
if (!newAst) return
|
||||||
@ -243,6 +264,11 @@ export class KclManager {
|
|||||||
await this?.engineCommandManager?.waitForReady
|
await this?.engineCommandManager?.waitForReady
|
||||||
this._ast = { ...newAst }
|
this._ast = { ...newAst }
|
||||||
|
|
||||||
|
// here we're going to clear diagnostics since we're the first
|
||||||
|
// one in. We're the only location where diagnostics are cleared;
|
||||||
|
// everything from here on out should be *appending*.
|
||||||
|
editorManager.clearDiagnostics()
|
||||||
|
|
||||||
const { logs, errors, programMemory } = await executeAst({
|
const { logs, errors, programMemory } = await executeAst({
|
||||||
ast: newAst,
|
ast: newAst,
|
||||||
engineCommandManager: this.engineCommandManager,
|
engineCommandManager: this.engineCommandManager,
|
||||||
@ -281,14 +307,20 @@ export class KclManager {
|
|||||||
if (!force) return this._defferer(codeManager.code)
|
if (!force) return this._defferer(codeManager.code)
|
||||||
|
|
||||||
const ast = this.safeParse(codeManager.code)
|
const ast = this.safeParse(codeManager.code)
|
||||||
if (!ast) return
|
if (!ast) {
|
||||||
|
this.clearAst()
|
||||||
|
return
|
||||||
|
}
|
||||||
this.ast = { ...ast }
|
this.ast = { ...ast }
|
||||||
return this.executeAst(ast, zoomToFit)
|
return this.executeAst(ast, zoomToFit)
|
||||||
}
|
}
|
||||||
format() {
|
format() {
|
||||||
const originalCode = codeManager.code
|
const originalCode = codeManager.code
|
||||||
const ast = this.safeParse(originalCode)
|
const ast = this.safeParse(originalCode)
|
||||||
if (!ast) return
|
if (!ast) {
|
||||||
|
this.clearAst()
|
||||||
|
return
|
||||||
|
}
|
||||||
const code = recast(ast)
|
const code = recast(ast)
|
||||||
if (originalCode === code) return
|
if (originalCode === code) return
|
||||||
|
|
||||||
@ -352,25 +384,55 @@ export class KclManager {
|
|||||||
return this?.engineCommandManager?.defaultPlanes
|
return this?.engineCommandManager?.defaultPlanes
|
||||||
}
|
}
|
||||||
|
|
||||||
showPlanes() {
|
showPlanes(all = false) {
|
||||||
if (!this.defaultPlanes) return
|
if (!this.defaultPlanes) return Promise.all([])
|
||||||
void this.engineCommandManager.setPlaneHidden(this.defaultPlanes.xy, false)
|
const thePromises = [
|
||||||
void this.engineCommandManager.setPlaneHidden(this.defaultPlanes.yz, false)
|
this.engineCommandManager.setPlaneHidden(this.defaultPlanes.xy, false),
|
||||||
void this.engineCommandManager.setPlaneHidden(this.defaultPlanes.xz, false)
|
this.engineCommandManager.setPlaneHidden(this.defaultPlanes.yz, false),
|
||||||
|
this.engineCommandManager.setPlaneHidden(this.defaultPlanes.xz, false),
|
||||||
|
]
|
||||||
|
if (all) {
|
||||||
|
thePromises.push(
|
||||||
|
this.engineCommandManager.setPlaneHidden(
|
||||||
|
this.defaultPlanes.negXy,
|
||||||
|
false
|
||||||
|
)
|
||||||
|
)
|
||||||
|
thePromises.push(
|
||||||
|
this.engineCommandManager.setPlaneHidden(
|
||||||
|
this.defaultPlanes.negYz,
|
||||||
|
false
|
||||||
|
)
|
||||||
|
)
|
||||||
|
thePromises.push(
|
||||||
|
this.engineCommandManager.setPlaneHidden(
|
||||||
|
this.defaultPlanes.negXz,
|
||||||
|
false
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return Promise.all(thePromises)
|
||||||
}
|
}
|
||||||
|
|
||||||
hidePlanes() {
|
hidePlanes(all = false) {
|
||||||
if (!this.defaultPlanes) return
|
if (!this.defaultPlanes) return Promise.all([])
|
||||||
void this.engineCommandManager.setPlaneHidden(this.defaultPlanes.xy, true)
|
const thePromises = [
|
||||||
void this.engineCommandManager.setPlaneHidden(this.defaultPlanes.yz, true)
|
this.engineCommandManager.setPlaneHidden(this.defaultPlanes.xy, true),
|
||||||
void this.engineCommandManager.setPlaneHidden(this.defaultPlanes.xz, true)
|
this.engineCommandManager.setPlaneHidden(this.defaultPlanes.yz, true),
|
||||||
}
|
this.engineCommandManager.setPlaneHidden(this.defaultPlanes.xz, true),
|
||||||
exitEditMode() {
|
]
|
||||||
this.engineCommandManager.sendSceneCommand({
|
if (all) {
|
||||||
type: 'modeling_cmd_req',
|
thePromises.push(
|
||||||
cmd_id: uuidv4(),
|
this.engineCommandManager.setPlaneHidden(this.defaultPlanes.negXy, true)
|
||||||
cmd: { type: 'edit_mode_exit' },
|
)
|
||||||
})
|
thePromises.push(
|
||||||
|
this.engineCommandManager.setPlaneHidden(this.defaultPlanes.negYz, true)
|
||||||
|
)
|
||||||
|
thePromises.push(
|
||||||
|
this.engineCommandManager.setPlaneHidden(this.defaultPlanes.negXz, true)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return Promise.all(thePromises)
|
||||||
}
|
}
|
||||||
defaultSelectionFilter() {
|
defaultSelectionFilter() {
|
||||||
defaultSelectionFilter(this.programMemory, this.engineCommandManager)
|
defaultSelectionFilter(this.programMemory, this.engineCommandManager)
|
||||||
@ -386,24 +448,11 @@ function defaultSelectionFilter(
|
|||||||
) as SketchGroup | ExtrudeGroup
|
) as SketchGroup | ExtrudeGroup
|
||||||
firstSketchOrExtrudeGroup &&
|
firstSketchOrExtrudeGroup &&
|
||||||
engineCommandManager.sendSceneCommand({
|
engineCommandManager.sendSceneCommand({
|
||||||
type: 'modeling_cmd_batch_req',
|
type: 'modeling_cmd_req',
|
||||||
batch_id: uuidv4(),
|
cmd_id: uuidv4(),
|
||||||
responses: false,
|
cmd: {
|
||||||
requests: [
|
type: 'set_selection_filter',
|
||||||
{
|
filter: ['face', 'edge', 'solid2d', 'curve'],
|
||||||
cmd_id: uuidv4(),
|
},
|
||||||
cmd: {
|
|
||||||
type: 'edit_mode_enter',
|
|
||||||
target: firstSketchOrExtrudeGroup.id,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
cmd_id: uuidv4(),
|
|
||||||
cmd: {
|
|
||||||
type: 'set_selection_filter',
|
|
||||||
filter: ['face', 'edge', 'solid2d'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
@ -22,7 +22,7 @@ export default class CodeManager {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const storedCode = safeLSGetItem(PERSIST_CODE_TOKEN) || ''
|
const storedCode = safeLSGetItem(PERSIST_CODE_TOKEN)
|
||||||
// TODO #819 remove zustand persistence logic in a few months
|
// TODO #819 remove zustand persistence logic in a few months
|
||||||
// short term migration, shouldn't make a difference for tauri app users
|
// short term migration, shouldn't make a difference for tauri app users
|
||||||
// anyway since that's filesystem based.
|
// anyway since that's filesystem based.
|
||||||
@ -68,7 +68,9 @@ export default class CodeManager {
|
|||||||
this._currentFilePath = path
|
this._currentFilePath = path
|
||||||
}
|
}
|
||||||
|
|
||||||
// This updates the code state and calls the updateState function.
|
/**
|
||||||
|
* This updates the code state and calls the updateState function.
|
||||||
|
*/
|
||||||
updateCodeState(code: string): void {
|
updateCodeState(code: string): void {
|
||||||
if (this._code !== code) {
|
if (this._code !== code) {
|
||||||
this.code = code
|
this.code = code
|
||||||
@ -76,7 +78,9 @@ export default class CodeManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update the code in the editor.
|
/**
|
||||||
|
* Update the code in the editor.
|
||||||
|
*/
|
||||||
updateCodeEditor(code: string): void {
|
updateCodeEditor(code: string): void {
|
||||||
this.code = code
|
this.code = code
|
||||||
if (editorManager.editorView) {
|
if (editorManager.editorView) {
|
||||||
@ -90,7 +94,9 @@ export default class CodeManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update the code, state, and the code the code mirror editor sees.
|
/**
|
||||||
|
* Update the code, state, and the code the code mirror editor sees.
|
||||||
|
*/
|
||||||
updateCodeStateEditor(code: string): void {
|
updateCodeStateEditor(code: string): void {
|
||||||
if (this._code !== code) {
|
if (this._code !== code) {
|
||||||
this.code = code
|
this.code = code
|
||||||
|