Compare commits
142 Commits
v0.21.7
...
achalmers/
Author | SHA1 | Date | |
---|---|---|---|
eda736a85e | |||
abbfdae7d2 | |||
ddbdd9094c | |||
7954b6da96 | |||
bdb84ab3c1 | |||
54e160e8d2 | |||
2c5a8d439f | |||
47a5e1f6d3 | |||
d85211c5a4 | |||
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 | |||
1b72c7df85 | |||
062abd148f | |||
c93ed0f306 | |||
27e2518dde | |||
dc6505acaf | |||
6ff3284eca | |||
4cb6ceb043 | |||
1db3e1b5e4 | |||
d797d20d50 | |||
cf52e151fb | |||
87c551b869 | |||
2001262494 | |||
777b225066 | |||
ae6373e4f5 | |||
87979b17cf | |||
4be63e7331 | |||
56d930c4f2 | |||
d48eb0c66c | |||
a69d7d03d0 | |||
00a8273173 | |||
51868f892b | |||
8e9286a747 | |||
023ed1a687 | |||
5b7d707b26 | |||
5b95194aa7 | |||
6080a99e73 | |||
5106c49e21 | |||
25f18845c7 | |||
0a7f1a41fc | |||
1625b58577 | |||
ab6115c4e2 | |||
fe621240c3 | |||
97faf5ae2b |
@ -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)
|
||||||
|
311
docs/kcl/shell.md
Normal file
10087
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'
|
||||||
@ -20,6 +20,7 @@ test.beforeEach(async ({ page }) => {
|
|||||||
localStorage.setItem('TOKEN_PERSIST_KEY', token)
|
localStorage.setItem('TOKEN_PERSIST_KEY', token)
|
||||||
localStorage.setItem('persistCode', ``)
|
localStorage.setItem('persistCode', ``)
|
||||||
localStorage.setItem(settingsKey, settings)
|
localStorage.setItem(settingsKey, settings)
|
||||||
|
localStorage.setItem('playwright', 'true')
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
token: secrets.token,
|
token: secrets.token,
|
||||||
@ -44,7 +45,7 @@ test.setTimeout(60_000)
|
|||||||
test('exports of each format should work', async ({ page, context }) => {
|
test('exports of each format should work', async ({ page, context }) => {
|
||||||
// FYI this test doesn't work with only engine running locally
|
// FYI this test doesn't work with only engine running locally
|
||||||
// And you will need to have the KittyCAD CLI installed
|
// And you will need to have the KittyCAD CLI installed
|
||||||
const u = getUtils(page)
|
const u = await getUtils(page)
|
||||||
await context.addInitScript(async () => {
|
await context.addInitScript(async () => {
|
||||||
;(window as any).playwrightSkipFilePicker = true
|
;(window as any).playwrightSkipFilePicker = true
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
@ -98,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',
|
||||||
@ -185,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
|
||||||
@ -369,7 +328,7 @@ const extrudeDefaultPlane = async (context: any, page: any, plane: string) => {
|
|||||||
localStorage.setItem('persistCode', code)
|
localStorage.setItem('persistCode', code)
|
||||||
})
|
})
|
||||||
|
|
||||||
const u = getUtils(page)
|
const u = await getUtils(page)
|
||||||
await page.setViewportSize({ width: 1200, height: 500 })
|
await page.setViewportSize({ width: 1200, height: 500 })
|
||||||
await page.goto('/')
|
await page.goto('/')
|
||||||
await u.waitForAuthSkipAppStart()
|
await u.waitForAuthSkipAppStart()
|
||||||
@ -424,7 +383,7 @@ test.describe('extrude on default planes should be stable', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('Draft segments should look right', async ({ page, context }) => {
|
test('Draft segments should look right', async ({ page, context }) => {
|
||||||
const u = getUtils(page)
|
const u = await getUtils(page)
|
||||||
await page.setViewportSize({ width: 1200, height: 500 })
|
await page.setViewportSize({ width: 1200, height: 500 })
|
||||||
const PUR = 400 / 37.5 //pixeltoUnitRatio
|
const PUR = 400 / 37.5 //pixeltoUnitRatio
|
||||||
await page.goto('/')
|
await page.goto('/')
|
||||||
@ -446,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()
|
||||||
@ -468,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()
|
||||||
|
|
||||||
@ -483,7 +440,7 @@ test('Draft segments should look right', async ({ page, context }) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('Draft rectangles should look right', async ({ page, context }) => {
|
test('Draft rectangles should look right', async ({ page, context }) => {
|
||||||
const u = getUtils(page)
|
const u = await getUtils(page)
|
||||||
await page.setViewportSize({ width: 1200, height: 500 })
|
await page.setViewportSize({ width: 1200, height: 500 })
|
||||||
const PUR = 400 / 37.5 //pixeltoUnitRatio
|
const PUR = 400 / 37.5 //pixeltoUnitRatio
|
||||||
await page.goto('/')
|
await page.goto('/')
|
||||||
@ -506,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
|
||||||
@ -530,7 +487,7 @@ test('Draft rectangles should look right', async ({ page, context }) => {
|
|||||||
|
|
||||||
test.describe('Client side scene scale should match engine scale', () => {
|
test.describe('Client side scene scale should match engine scale', () => {
|
||||||
test('Inch scale', async ({ page }) => {
|
test('Inch scale', async ({ page }) => {
|
||||||
const u = getUtils(page)
|
const u = await getUtils(page)
|
||||||
await page.setViewportSize({ width: 1200, height: 500 })
|
await page.setViewportSize({ width: 1200, height: 500 })
|
||||||
const PUR = 400 / 37.5 //pixeltoUnitRatio
|
const PUR = 400 / 37.5 //pixeltoUnitRatio
|
||||||
await page.goto('/')
|
await page.goto('/')
|
||||||
@ -554,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()
|
||||||
@ -572,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()
|
||||||
@ -633,7 +586,7 @@ test.describe('Client side scene scale should match engine scale', () => {
|
|||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
const u = getUtils(page)
|
const u = await getUtils(page)
|
||||||
await page.setViewportSize({ width: 1200, height: 500 })
|
await page.setViewportSize({ width: 1200, height: 500 })
|
||||||
const PUR = 400 / 37.5 //pixeltoUnitRatio
|
const PUR = 400 / 37.5 //pixeltoUnitRatio
|
||||||
await page.goto('/')
|
await page.goto('/')
|
||||||
@ -657,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()
|
||||||
@ -675,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)
|
||||||
@ -719,7 +668,7 @@ test.describe('Client side scene scale should match engine scale', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('Sketch on face with none z-up', async ({ page, context }) => {
|
test('Sketch on face with none z-up', async ({ page, context }) => {
|
||||||
const u = getUtils(page)
|
const u = await getUtils(page)
|
||||||
await context.addInitScript(async (KCL_DEFAULT_LENGTH) => {
|
await context.addInitScript(async (KCL_DEFAULT_LENGTH) => {
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
'persistCode',
|
'persistCode',
|
||||||
@ -773,3 +722,76 @@ const part002 = startSketchOn(part001, 'seg01')
|
|||||||
maxDiffPixels: 100,
|
maxDiffPixels: 100,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('Zoom to fit on load - solid 2d', async ({ page, context }) => {
|
||||||
|
const u = await getUtils(page)
|
||||||
|
await context.addInitScript(async () => {
|
||||||
|
localStorage.setItem(
|
||||||
|
'persistCode',
|
||||||
|
`const part001 = startSketchOn('XY')
|
||||||
|
|> startProfileAt([-10, -10], %)
|
||||||
|
|> line([20, 0], %)
|
||||||
|
|> line([0, 20], %)
|
||||||
|
|> line([-20, 0], %)
|
||||||
|
|> close(%)
|
||||||
|
`
|
||||||
|
)
|
||||||
|
}, KCL_DEFAULT_LENGTH)
|
||||||
|
|
||||||
|
await page.setViewportSize({ width: 1200, height: 500 })
|
||||||
|
await page.goto('/')
|
||||||
|
await u.waitForAuthSkipAppStart()
|
||||||
|
|
||||||
|
await u.openDebugPanel()
|
||||||
|
// wait for execution done
|
||||||
|
await expect(
|
||||||
|
page.locator('[data-message-type="execution-done"]')
|
||||||
|
).toHaveCount(2)
|
||||||
|
await u.closeDebugPanel()
|
||||||
|
|
||||||
|
// Wait for the second extrusion to appear
|
||||||
|
// TODO: Find a way to truly know that the objects have finished
|
||||||
|
// rendering, because an execution-done message is not sufficient.
|
||||||
|
await page.waitForTimeout(1000)
|
||||||
|
|
||||||
|
await expect(page).toHaveScreenshot({
|
||||||
|
maxDiffPixels: 100,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Zoom to fit on load - solid 3d', async ({ page, context }) => {
|
||||||
|
const u = await getUtils(page)
|
||||||
|
await context.addInitScript(async () => {
|
||||||
|
localStorage.setItem(
|
||||||
|
'persistCode',
|
||||||
|
`const part001 = startSketchOn('XY')
|
||||||
|
|> startProfileAt([-10, -10], %)
|
||||||
|
|> line([20, 0], %)
|
||||||
|
|> line([0, 20], %)
|
||||||
|
|> line([-20, 0], %)
|
||||||
|
|> close(%)
|
||||||
|
|> extrude(10, %)
|
||||||
|
`
|
||||||
|
)
|
||||||
|
}, KCL_DEFAULT_LENGTH)
|
||||||
|
|
||||||
|
await page.setViewportSize({ width: 1200, height: 500 })
|
||||||
|
await page.goto('/')
|
||||||
|
await u.waitForAuthSkipAppStart()
|
||||||
|
|
||||||
|
await u.openDebugPanel()
|
||||||
|
// wait for execution done
|
||||||
|
await expect(
|
||||||
|
page.locator('[data-message-type="execution-done"]')
|
||||||
|
).toHaveCount(2)
|
||||||
|
await u.closeDebugPanel()
|
||||||
|
|
||||||
|
// Wait for the second extrusion to appear
|
||||||
|
// TODO: Find a way to truly know that the objects have finished
|
||||||
|
// rendering, because an execution-done message is not sufficient.
|
||||||
|
await page.waitForTimeout(1000)
|
||||||
|
|
||||||
|
await expect(page).toHaveScreenshot({
|
||||||
|
maxDiffPixels: 100,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 42 KiB |
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 42 KiB |
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 49 KiB |
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 44 KiB |
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 31 KiB |
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 33 KiB |
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 36 KiB |
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 66 KiB |
After Width: | Height: | Size: 71 KiB |
After Width: | Height: | Size: 75 KiB |
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 44 KiB |
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 46 KiB |
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 44 KiB |
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 44 KiB |
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 47 KiB |
Before Width: | Height: | Size: 47 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,21 +1,27 @@
|
|||||||
import { 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 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)
|
||||||
@ -24,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) {
|
||||||
@ -93,7 +99,80 @@ async function waitForCmdReceive(page: Page, commandType: string) {
|
|||||||
.waitFor()
|
.waitFor()
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getUtils(page: Page) {
|
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) {
|
||||||
|
// Chrome devtools protocol session only works in Chromium
|
||||||
|
const browserType = page.context().browser()?.browserType().name()
|
||||||
|
const cdpSession =
|
||||||
|
browserType !== 'chromium' ? null : await page.context().newCDPSession(page)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
waitForAuthSkipAppStart: () => waitForPageLoad(page),
|
waitForAuthSkipAppStart: () => waitForPageLoad(page),
|
||||||
removeCurrentCode: () => removeCurrentCode(page),
|
removeCurrentCode: () => removeCurrentCode(page),
|
||||||
@ -124,6 +203,30 @@ export 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) =>
|
||||||
|
page
|
||||||
|
.locator(locator)
|
||||||
|
.boundingBox()
|
||||||
|
.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,
|
||||||
@ -139,6 +242,30 @@ export 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({
|
||||||
@ -180,6 +307,17 @@ export function getUtils(page: Page) {
|
|||||||
}
|
}
|
||||||
}, 50)
|
}, 50)
|
||||||
}),
|
}),
|
||||||
|
emulateNetworkConditions: async (
|
||||||
|
networkOptions: Protocol.Network.emulateNetworkConditionsParameters
|
||||||
|
) => {
|
||||||
|
// Skip on non-Chromium browsers, since we need to use the CDP.
|
||||||
|
test.skip(
|
||||||
|
cdpSession === null,
|
||||||
|
'Network emulation is only supported in Chromium'
|
||||||
|
)
|
||||||
|
|
||||||
|
cdpSession?.send('Network.emulateNetworkConditions', networkOptions)
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -255,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";
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
19
package.json
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "untitled-app",
|
"name": "untitled-app",
|
||||||
"version": "0.21.7",
|
"version": "0.22.3",
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/autocomplete": "^6.16.0",
|
"@codemirror/autocomplete": "^6.16.0",
|
||||||
@ -10,12 +10,12 @@
|
|||||||
"@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.60",
|
"@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": "^1.2.6",
|
"@react-hook/resize-observer": "^2.0.1",
|
||||||
"@replit/codemirror-interact": "^6.3.1",
|
"@replit/codemirror-interact": "^6.3.1",
|
||||||
"@tauri-apps/api": "2.0.0-beta.8",
|
"@tauri-apps/api": "2.0.0-beta.12",
|
||||||
"@tauri-apps/plugin-dialog": "^2.0.0-beta.2",
|
"@tauri-apps/plugin-dialog": "^2.0.0-beta.2",
|
||||||
"@tauri-apps/plugin-fs": "^2.0.0-beta.3",
|
"@tauri-apps/plugin-fs": "^2.0.0-beta.3",
|
||||||
"@tauri-apps/plugin-http": "^2.0.0-beta.2",
|
"@tauri-apps/plugin-http": "^2.0.0-beta.2",
|
||||||
@ -37,7 +37,7 @@
|
|||||||
"crypto-js": "^4.2.0",
|
"crypto-js": "^4.2.0",
|
||||||
"debounce-promise": "^3.1.2",
|
"debounce-promise": "^3.1.2",
|
||||||
"decamelize": "^6.0.0",
|
"decamelize": "^6.0.0",
|
||||||
"formik": "^2.4.5",
|
"formik": "^2.4.6",
|
||||||
"fuse.js": "^7.0.0",
|
"fuse.js": "^7.0.0",
|
||||||
"html2canvas-pro": "^1.4.3",
|
"html2canvas-pro": "^1.4.3",
|
||||||
"http-server": "^14.1.1",
|
"http-server": "^14.1.1",
|
||||||
@ -61,11 +61,11 @@
|
|||||||
"ua-parser-js": "^1.0.37",
|
"ua-parser-js": "^1.0.37",
|
||||||
"uuid": "^9.0.1",
|
"uuid": "^9.0.1",
|
||||||
"vitest": "^1.6.0",
|
"vitest": "^1.6.0",
|
||||||
"vscode-jsonrpc": "^8.1.0",
|
"vscode-jsonrpc": "^8.2.1",
|
||||||
"vscode-languageserver-protocol": "^3.17.5",
|
"vscode-languageserver-protocol": "^3.17.5",
|
||||||
"wasm-pack": "^0.12.1",
|
"wasm-pack": "^0.12.1",
|
||||||
"web-vitals": "^3.5.2",
|
"web-vitals": "^3.5.2",
|
||||||
"ws": "^8.16.0",
|
"ws": "^8.17.0",
|
||||||
"xstate": "^4.38.2",
|
"xstate": "^4.38.2",
|
||||||
"zustand": "^4.5.2"
|
"zustand": "^4.5.2"
|
||||||
},
|
},
|
||||||
@ -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",
|
||||||
@ -133,7 +134,7 @@
|
|||||||
"@types/wait-on": "^5.3.4",
|
"@types/wait-on": "^5.3.4",
|
||||||
"@types/wicg-file-system-access": "^2023.10.5",
|
"@types/wicg-file-system-access": "^2023.10.5",
|
||||||
"@types/ws": "^8.5.10",
|
"@types/ws": "^8.5.10",
|
||||||
"@vitejs/plugin-react": "^4.2.1",
|
"@vitejs/plugin-react": "^4.3.0",
|
||||||
"@vitest/web-worker": "^1.5.0",
|
"@vitest/web-worker": "^1.5.0",
|
||||||
"@wdio/cli": "^8.24.3",
|
"@wdio/cli": "^8.24.3",
|
||||||
"@wdio/globals": "^8.36.0",
|
"@wdio/globals": "^8.36.0",
|
||||||
|
@ -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,
|
||||||
},
|
},
|
||||||
|
1688
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" }
|
||||||
@ -28,6 +28,7 @@ tauri-plugin-fs = { version = "2.0.0-beta.6" }
|
|||||||
tauri-plugin-http = { version = "2.0.0-beta.6" }
|
tauri-plugin-http = { version = "2.0.0-beta.6" }
|
||||||
tauri-plugin-log = { version = "2.0.0-beta.4" }
|
tauri-plugin-log = { version = "2.0.0-beta.4" }
|
||||||
tauri-plugin-os = { version = "2.0.0-beta.2" }
|
tauri-plugin-os = { version = "2.0.0-beta.2" }
|
||||||
|
tauri-plugin-persisted-scope = { version = "2.0.0-beta.7" }
|
||||||
tauri-plugin-process = { version = "2.0.0-beta.2" }
|
tauri-plugin-process = { version = "2.0.0-beta.2" }
|
||||||
tauri-plugin-shell = { version = "2.0.0-beta.2" }
|
tauri-plugin-shell = { version = "2.0.0-beta.2" }
|
||||||
tauri-plugin-updater = { version = "2.0.0-beta.4" }
|
tauri-plugin-updater = { version = "2.0.0-beta.4" }
|
||||||
|
@ -32,6 +32,15 @@
|
|||||||
{
|
{
|
||||||
"identifier": "fs:scope",
|
"identifier": "fs:scope",
|
||||||
"allow": [
|
"allow": [
|
||||||
|
{
|
||||||
|
"path": "$TEMP"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "$TEMP/**/*"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "$HOME"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "$HOME/**/*"
|
"path": "$HOME/**/*"
|
||||||
},
|
},
|
||||||
@ -56,6 +65,33 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"shell:allow-open",
|
"shell:allow-open",
|
||||||
|
{
|
||||||
|
"identifier": "shell:allow-execute",
|
||||||
|
"allow": [
|
||||||
|
{
|
||||||
|
"name": "open",
|
||||||
|
"cmd": "open",
|
||||||
|
"args": [
|
||||||
|
"-R",
|
||||||
|
{
|
||||||
|
"validator": "\\S+"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"sidecar": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "explorer",
|
||||||
|
"cmd": "explorer",
|
||||||
|
"args": [
|
||||||
|
"/select",
|
||||||
|
{
|
||||||
|
"validator": "\\S+"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"sidecar": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
"dialog:allow-open",
|
"dialog:allow-open",
|
||||||
"dialog:allow-save",
|
"dialog:allow-save",
|
||||||
"dialog:allow-message",
|
"dialog:allow-message",
|
||||||
|
@ -18,7 +18,6 @@ use oauth2::TokenResponse;
|
|||||||
use tauri::{ipc::InvokeError, Manager};
|
use tauri::{ipc::InvokeError, Manager};
|
||||||
use tauri_plugin_cli::CliExt;
|
use tauri_plugin_cli::CliExt;
|
||||||
use tauri_plugin_shell::ShellExt;
|
use tauri_plugin_shell::ShellExt;
|
||||||
use tokio::process::Command;
|
|
||||||
|
|
||||||
const DEFAULT_HOST: &str = "https://api.zoo.dev";
|
const DEFAULT_HOST: &str = "https://api.zoo.dev";
|
||||||
const SETTINGS_FILE_NAME: &str = "settings.toml";
|
const SETTINGS_FILE_NAME: &str = "settings.toml";
|
||||||
@ -268,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 {
|
||||||
@ -332,10 +339,20 @@ async fn get_user(token: &str, hostname: &str) -> Result<kittycad::types::User,
|
|||||||
/// From this GitHub comment: https://github.com/tauri-apps/tauri/issues/4062#issuecomment-1338048169
|
/// From this GitHub comment: https://github.com/tauri-apps/tauri/issues/4062#issuecomment-1338048169
|
||||||
/// But with the Linux support removed since we don't need it for now.
|
/// But with the Linux support removed since we don't need it for now.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn show_in_folder(path: &str) -> Result<(), InvokeError> {
|
fn show_in_folder(app: tauri::AppHandle, path: &str) -> Result<(), InvokeError> {
|
||||||
|
// Check if the file exists.
|
||||||
|
// If it doesn't, return an error.
|
||||||
|
if !Path::new(path).exists() {
|
||||||
|
return Err(InvokeError::from_anyhow(anyhow::anyhow!(
|
||||||
|
"The file `{}` does not exist",
|
||||||
|
path
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(not(unix))]
|
#[cfg(not(unix))]
|
||||||
{
|
{
|
||||||
Command::new("explorer")
|
app.shell()
|
||||||
|
.command("explorer")
|
||||||
.args(["/select,", path]) // The comma after select is not a typo
|
.args(["/select,", path]) // The comma after select is not a typo
|
||||||
.spawn()
|
.spawn()
|
||||||
.map_err(|e| InvokeError::from_anyhow(e.into()))?;
|
.map_err(|e| InvokeError::from_anyhow(e.into()))?;
|
||||||
@ -343,7 +360,8 @@ fn show_in_folder(path: &str) -> Result<(), InvokeError> {
|
|||||||
|
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
{
|
{
|
||||||
Command::new("open")
|
app.shell()
|
||||||
|
.command("open")
|
||||||
.args(["-R", path])
|
.args(["-R", path])
|
||||||
.spawn()
|
.spawn()
|
||||||
.map_err(|e| InvokeError::from_anyhow(e.into()))?;
|
.map_err(|e| InvokeError::from_anyhow(e.into()))?;
|
||||||
@ -415,6 +433,7 @@ fn main() -> Result<()> {
|
|||||||
.build(),
|
.build(),
|
||||||
)
|
)
|
||||||
.plugin(tauri_plugin_os::init())
|
.plugin(tauri_plugin_os::init())
|
||||||
|
.plugin(tauri_plugin_persisted_scope::init())
|
||||||
.plugin(tauri_plugin_process::init())
|
.plugin(tauri_plugin_process::init())
|
||||||
.plugin(tauri_plugin_shell::init())
|
.plugin(tauri_plugin_shell::init())
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
|
@ -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.7"
|
"version": "0.22.3"
|
||||||
}
|
}
|
||||||
|
@ -24,6 +24,7 @@ import { ModelingSidebar } from 'components/ModelingSidebar/ModelingSidebar'
|
|||||||
import { LowerRightControls } from 'components/LowerRightControls'
|
import { LowerRightControls } from 'components/LowerRightControls'
|
||||||
import ModalContainer from 'react-modal-promise'
|
import ModalContainer from 'react-modal-promise'
|
||||||
import useHotkeyWrapper from 'lib/hotkeyWrapper'
|
import useHotkeyWrapper from 'lib/hotkeyWrapper'
|
||||||
|
import Gizmo from 'components/Gizmo'
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
useRefreshSettings(paths.FILE + 'SETTINGS')
|
useRefreshSettings(paths.FILE + 'SETTINGS')
|
||||||
@ -126,9 +127,11 @@ export function App() {
|
|||||||
/>
|
/>
|
||||||
<ModalContainer />
|
<ModalContainer />
|
||||||
<ModelingSidebar paneOpacity={paneOpacity} />
|
<ModelingSidebar paneOpacity={paneOpacity} />
|
||||||
<Stream className="absolute inset-0 z-0" />
|
<Stream />
|
||||||
{/* <CamToggle /> */}
|
{/* <CamToggle /> */}
|
||||||
<LowerRightControls />
|
<LowerRightControls>
|
||||||
|
<Gizmo />
|
||||||
|
</LowerRightControls>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -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>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
112
src/Toolbar.tsx
@ -3,22 +3,22 @@ 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'
|
||||||
import Tooltip from 'components/Tooltip'
|
import Tooltip from 'components/Tooltip'
|
||||||
|
|
||||||
export const Toolbar = () => {
|
export function Toolbar({
|
||||||
const { commandBarSend } = useCommandsContext()
|
className = '',
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLElement>) {
|
||||||
const { state, send, context } = useModelingContext()
|
const { state, send, context } = useModelingContext()
|
||||||
const toolbarButtonsRef = useRef<HTMLUListElement>(null)
|
const { commandBarSend } = useCommandsContext()
|
||||||
const iconClassName =
|
const iconClassName =
|
||||||
'group-disabled:text-chalkboard-50 group-enabled:group-hover:!text-primary dark:group-enabled:group-hover:!text-inherit group-pressed:!text-chalkboard-10 group-ui-open:!text-chalkboard-10 dark:group-ui-open:!text-chalkboard-10'
|
'group-disabled:text-chalkboard-50 group-enabled:group-hover:!text-primary dark:group-enabled:group-hover:!text-inherit group-pressed:!text-chalkboard-10 group-ui-open:!text-chalkboard-10 dark:group-ui-open:!text-chalkboard-10'
|
||||||
const bgClassName =
|
const bgClassName =
|
||||||
@ -34,13 +34,18 @@ export const Toolbar = () => {
|
|||||||
context.selectionRanges
|
context.selectionRanges
|
||||||
)
|
)
|
||||||
}, [engineCommandManager.artifactMap, context.selectionRanges])
|
}, [engineCommandManager.artifactMap, context.selectionRanges])
|
||||||
const { overallState } = useNetworkStatus()
|
|
||||||
|
const toolbarButtonsRef = useRef<HTMLUListElement>(null)
|
||||||
|
const { overallState } = useNetworkContext()
|
||||||
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',
|
||||||
@ -100,12 +105,45 @@ export const Toolbar = () => {
|
|||||||
|
|
||||||
span.scrollLeft = span.scrollLeft += ev.deltaY
|
span.scrollLeft = span.scrollLeft += ev.deltaY
|
||||||
}
|
}
|
||||||
|
const nextEvents = useMemo(() => state.nextEvents, [state.nextEvents])
|
||||||
|
const splitMenuItems = useMemo(
|
||||||
|
() =>
|
||||||
|
nextEvents
|
||||||
|
.filter(
|
||||||
|
(eventName) =>
|
||||||
|
eventName.includes('Make segment') ||
|
||||||
|
eventName.includes('Constrain')
|
||||||
|
)
|
||||||
|
.sort((a, b) => {
|
||||||
|
const aisEnabled = nextEvents
|
||||||
|
.filter((event) => state.can(event as any))
|
||||||
|
.includes(a)
|
||||||
|
const bIsEnabled = nextEvents
|
||||||
|
.filter((event) => state.can(event as any))
|
||||||
|
.includes(b)
|
||||||
|
if (aisEnabled && !bIsEnabled) {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
if (!aisEnabled && bIsEnabled) {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
})
|
||||||
|
.map((eventName) => ({
|
||||||
|
label: eventName
|
||||||
|
.replace('Make segment ', '')
|
||||||
|
.replace('Constrain ', ''),
|
||||||
|
onClick: () => send(eventName),
|
||||||
|
disabled:
|
||||||
|
!nextEvents
|
||||||
|
.filter((event) => state.can(event as any))
|
||||||
|
.includes(eventName) || disableAllButtons,
|
||||||
|
})),
|
||||||
|
|
||||||
function ToolbarButtons({
|
[JSON.stringify(nextEvents), state]
|
||||||
className = '',
|
)
|
||||||
...props
|
return (
|
||||||
}: React.HTMLAttributes<HTMLElement>) {
|
<menu className="max-w-full whitespace-nowrap rounded px-1.5 py-0.5 backdrop-blur-sm bg-chalkboard-10/80 dark:bg-chalkboard-110/70 relative">
|
||||||
return (
|
|
||||||
<ul
|
<ul
|
||||||
{...props}
|
{...props}
|
||||||
ref={toolbarButtonsRef}
|
ref={toolbarButtonsRef}
|
||||||
@ -113,7 +151,7 @@ export const Toolbar = () => {
|
|||||||
className={'m-0 py-1 rounded-l-sm flex gap-2 items-center ' + className}
|
className={'m-0 py-1 rounded-l-sm flex gap-2 items-center ' + className}
|
||||||
style={{ scrollbarWidth: 'thin' }}
|
style={{ scrollbarWidth: 'thin' }}
|
||||||
>
|
>
|
||||||
{state.nextEvents.includes('Enter sketch') && (
|
{nextEvents.includes('Enter sketch') && (
|
||||||
<li className="contents">
|
<li className="contents">
|
||||||
<ActionButton
|
<ActionButton
|
||||||
className={buttonClassName}
|
className={buttonClassName}
|
||||||
@ -139,7 +177,7 @@ export const Toolbar = () => {
|
|||||||
</ActionButton>
|
</ActionButton>
|
||||||
</li>
|
</li>
|
||||||
)}
|
)}
|
||||||
{state.nextEvents.includes('Enter sketch') && pathId && (
|
{nextEvents.includes('Enter sketch') && pathId && (
|
||||||
<li className="contents">
|
<li className="contents">
|
||||||
<ActionButton
|
<ActionButton
|
||||||
className={buttonClassName}
|
className={buttonClassName}
|
||||||
@ -163,7 +201,7 @@ export const Toolbar = () => {
|
|||||||
</ActionButton>
|
</ActionButton>
|
||||||
</li>
|
</li>
|
||||||
)}
|
)}
|
||||||
{state.nextEvents.includes('Cancel') && !state.matches('idle') && (
|
{nextEvents.includes('Cancel') && !state.matches('idle') && (
|
||||||
<li className="contents">
|
<li className="contents">
|
||||||
<ActionButton
|
<ActionButton
|
||||||
className={buttonClassName}
|
className={buttonClassName}
|
||||||
@ -286,43 +324,13 @@ export const Toolbar = () => {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{state.matches('Sketch.SketchIdle') &&
|
{state.matches('Sketch.SketchIdle') &&
|
||||||
state.nextEvents.filter(
|
nextEvents.filter(
|
||||||
(eventName) =>
|
(eventName) =>
|
||||||
eventName.includes('Make segment') ||
|
eventName.includes('Make segment') ||
|
||||||
eventName.includes('Constrain')
|
eventName.includes('Constrain')
|
||||||
).length > 0 && (
|
).length > 0 && (
|
||||||
<ActionButtonDropdown
|
<ActionButtonDropdown
|
||||||
splitMenuItems={state.nextEvents
|
splitMenuItems={splitMenuItems}
|
||||||
.filter(
|
|
||||||
(eventName) =>
|
|
||||||
eventName.includes('Make segment') ||
|
|
||||||
eventName.includes('Constrain')
|
|
||||||
)
|
|
||||||
.sort((a, b) => {
|
|
||||||
const aisEnabled = state.nextEvents
|
|
||||||
.filter((event) => state.can(event as any))
|
|
||||||
.includes(a)
|
|
||||||
const bIsEnabled = state.nextEvents
|
|
||||||
.filter((event) => state.can(event as any))
|
|
||||||
.includes(b)
|
|
||||||
if (aisEnabled && !bIsEnabled) {
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
if (!aisEnabled && bIsEnabled) {
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
})
|
|
||||||
.map((eventName) => ({
|
|
||||||
label: eventName
|
|
||||||
.replace('Make segment ', '')
|
|
||||||
.replace('Constrain ', ''),
|
|
||||||
onClick: () => send(eventName),
|
|
||||||
disabled:
|
|
||||||
!state.nextEvents
|
|
||||||
.filter((event) => state.can(event as any))
|
|
||||||
.includes(eventName) || disableAllButtons,
|
|
||||||
}))}
|
|
||||||
className={buttonClassName}
|
className={buttonClassName}
|
||||||
Element="button"
|
Element="button"
|
||||||
iconStart={{
|
iconStart={{
|
||||||
@ -369,12 +377,6 @@ export const Toolbar = () => {
|
|||||||
</li>
|
</li>
|
||||||
)}
|
)}
|
||||||
</ul>
|
</ul>
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<menu className="max-w-full whitespace-nowrap rounded px-1.5 py-0.5 backdrop-blur-sm bg-chalkboard-10/80 dark:bg-chalkboard-110/70 relative">
|
|
||||||
<ToolbarButtons />
|
|
||||||
</menu>
|
</menu>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -20,6 +20,7 @@ import {
|
|||||||
EngineCommand,
|
EngineCommand,
|
||||||
Subscription,
|
Subscription,
|
||||||
EngineCommandManager,
|
EngineCommandManager,
|
||||||
|
UnreliableSubscription,
|
||||||
} from 'lang/std/engineConnection'
|
} from 'lang/std/engineConnection'
|
||||||
import { uuidv4 } from 'lib/utils'
|
import { uuidv4 } from 'lib/utils'
|
||||||
import { deg2Rad } from 'lib/utils2d'
|
import { deg2Rad } from 'lib/utils2d'
|
||||||
@ -47,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]
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -171,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,
|
||||||
@ -232,9 +200,19 @@ export class CameraControls {
|
|||||||
this.update()
|
this.update()
|
||||||
this._usePerspectiveCamera()
|
this._usePerspectiveCamera()
|
||||||
|
|
||||||
const cb: Subscription<
|
type CallBackParam = Parameters<
|
||||||
'default_camera_zoom' | 'camera_drag_end' | 'default_camera_get_settings'
|
(
|
||||||
>['callback'] = ({ data, type }) => {
|
| Subscription<
|
||||||
|
| 'default_camera_zoom'
|
||||||
|
| 'camera_drag_end'
|
||||||
|
| 'default_camera_get_settings'
|
||||||
|
| 'zoom_to_fit'
|
||||||
|
>
|
||||||
|
| UnreliableSubscription<'camera_drag_move'>
|
||||||
|
)['callback']
|
||||||
|
>[0]
|
||||||
|
|
||||||
|
const cb = ({ data, type }: CallBackParam) => {
|
||||||
const camSettings = data.settings
|
const camSettings = data.settings
|
||||||
this.camera.position.set(
|
this.camera.position.set(
|
||||||
camSettings.pos.x,
|
camSettings.pos.x,
|
||||||
@ -246,7 +224,13 @@ export class CameraControls {
|
|||||||
camSettings.center.y,
|
camSettings.center.y,
|
||||||
camSettings.center.z
|
camSettings.center.z
|
||||||
)
|
)
|
||||||
this.camera.up.set(camSettings.up.x, camSettings.up.y, camSettings.up.z)
|
const quat = new Quaternion(
|
||||||
|
camSettings.orientation.x,
|
||||||
|
camSettings.orientation.y,
|
||||||
|
camSettings.orientation.z,
|
||||||
|
camSettings.orientation.w
|
||||||
|
).invert()
|
||||||
|
this.camera.up.copy(new Vector3(0, 1, 0).applyQuaternion(quat))
|
||||||
if (this.camera instanceof PerspectiveCamera && camSettings.ortho) {
|
if (this.camera instanceof PerspectiveCamera && camSettings.ortho) {
|
||||||
this.useOrthographicCamera()
|
this.useOrthographicCamera()
|
||||||
}
|
}
|
||||||
@ -287,6 +271,14 @@ export class CameraControls {
|
|||||||
event: 'default_camera_get_settings',
|
event: 'default_camera_get_settings',
|
||||||
callback: cb,
|
callback: cb,
|
||||||
})
|
})
|
||||||
|
this.engineCommandManager.subscribeTo({
|
||||||
|
event: 'zoom_to_fit',
|
||||||
|
callback: cb,
|
||||||
|
})
|
||||||
|
this.engineCommandManager.subscribeToUnreliable({
|
||||||
|
event: 'camera_drag_move',
|
||||||
|
callback: cb,
|
||||||
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -417,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',
|
||||||
@ -429,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()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -507,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
|
||||||
@ -577,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) => {
|
||||||
@ -748,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(),
|
||||||
@ -919,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 {
|
||||||
@ -932,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),
|
||||||
@ -986,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({
|
||||||
@ -1005,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 {
|
||||||
@ -1018,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()
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { useRef, useEffect, useState } from 'react'
|
import { useRef, useEffect, useState, useMemo, Fragment } from 'react'
|
||||||
import { useModelingContext } from 'hooks/useModelingContext'
|
import { useModelingContext } from 'hooks/useModelingContext'
|
||||||
|
|
||||||
import { cameraMouseDragGuards } from 'lib/cameraControls'
|
import { cameraMouseDragGuards } from 'lib/cameraControls'
|
||||||
@ -6,12 +6,44 @@ import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
|
|||||||
import { ARROWHEAD, DEBUG_SHOW_BOTH_SCENES } from './sceneInfra'
|
import { ARROWHEAD, DEBUG_SHOW_BOTH_SCENES } from './sceneInfra'
|
||||||
import { ReactCameraProperties } from './CameraControls'
|
import { ReactCameraProperties } from './CameraControls'
|
||||||
import { throttle } from 'lib/utils'
|
import { throttle } from 'lib/utils'
|
||||||
import { sceneInfra } from 'lib/singletons'
|
import {
|
||||||
|
sceneInfra,
|
||||||
|
kclManager,
|
||||||
|
codeManager,
|
||||||
|
editorManager,
|
||||||
|
sceneEntitiesManager,
|
||||||
|
engineCommandManager,
|
||||||
|
} from 'lib/singletons'
|
||||||
import {
|
import {
|
||||||
EXTRA_SEGMENT_HANDLE,
|
EXTRA_SEGMENT_HANDLE,
|
||||||
PROFILE_START,
|
PROFILE_START,
|
||||||
getParentGroup,
|
getParentGroup,
|
||||||
} from './sceneEntities'
|
} from './sceneEntities'
|
||||||
|
import { SegmentOverlay, SketchDetails } from 'machines/modelingMachine'
|
||||||
|
import { findUsesOfTagInPipe, getNodeFromPath } from 'lang/queryAst'
|
||||||
|
import {
|
||||||
|
CallExpression,
|
||||||
|
PathToNode,
|
||||||
|
Program,
|
||||||
|
SourceRange,
|
||||||
|
Value,
|
||||||
|
parse,
|
||||||
|
recast,
|
||||||
|
} from 'lang/wasm'
|
||||||
|
import { CustomIcon, CustomIconName } from 'components/CustomIcon'
|
||||||
|
import { ConstrainInfo } from 'lang/std/stdTypes'
|
||||||
|
import { getConstraintInfo } from 'lang/std/sketch'
|
||||||
|
import { Dialog, Popover, Transition } from '@headlessui/react'
|
||||||
|
import { LineInputsType } from 'lang/std/sketchcombos'
|
||||||
|
import toast from 'react-hot-toast'
|
||||||
|
import { InstanceProps, create } from 'react-modal-promise'
|
||||||
|
import { executeAst } from 'useStore'
|
||||||
|
import {
|
||||||
|
deleteSegmentFromPipeExpression,
|
||||||
|
makeRemoveSingleConstraintInput,
|
||||||
|
removeSingleConstraintInfo,
|
||||||
|
} from 'lang/modifyAst'
|
||||||
|
import { ActionButton } from 'components/ActionButton'
|
||||||
|
|
||||||
function useShouldHideScene(): { hideClient: boolean; hideServer: boolean } {
|
function useShouldHideScene(): { hideClient: boolean; hideServer: boolean } {
|
||||||
const [isCamMoving, setIsCamMoving] = useState(false)
|
const [isCamMoving, setIsCamMoving] = useState(false)
|
||||||
@ -100,17 +132,535 @@ export const ClientSideScene = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<>
|
||||||
ref={canvasRef}
|
<div
|
||||||
style={{ cursor: cursor }}
|
ref={canvasRef}
|
||||||
className={`absolute inset-0 h-full w-full transition-all duration-300 ${
|
style={{ cursor: cursor }}
|
||||||
hideClient ? 'opacity-0' : 'opacity-100'
|
data-testid="client-side-scene"
|
||||||
} ${hideServer ? 'bg-chalkboard-10 dark:bg-chalkboard-100' : ''} ${
|
className={`absolute inset-0 h-full w-full transition-all duration-300 ${
|
||||||
!hideClient && !hideServer && state.matches('Sketch')
|
hideClient ? 'opacity-0' : 'opacity-100'
|
||||||
? 'bg-chalkboard-10/80 dark:bg-chalkboard-100/80'
|
} ${hideServer ? 'bg-chalkboard-10 dark:bg-chalkboard-100' : ''} ${
|
||||||
: ''
|
!hideClient && !hideServer && state.matches('Sketch')
|
||||||
}`}
|
? 'bg-chalkboard-10/80 dark:bg-chalkboard-100/80'
|
||||||
></div>
|
: ''
|
||||||
|
}`}
|
||||||
|
></div>
|
||||||
|
<Overlays />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const Overlays = () => {
|
||||||
|
const { context } = useModelingContext()
|
||||||
|
if (context.mouseState.type === 'isDragging') return null
|
||||||
|
return (
|
||||||
|
<div className="absolute inset-0 pointer-events-none">
|
||||||
|
{Object.entries(context.segmentOverlays)
|
||||||
|
.filter((a) => a[1].visible)
|
||||||
|
.map(([pathToNodeString, overlay], index) => {
|
||||||
|
return (
|
||||||
|
<Overlay
|
||||||
|
overlay={overlay}
|
||||||
|
key={pathToNodeString}
|
||||||
|
pathToNodeString={pathToNodeString}
|
||||||
|
overlayIndex={index}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const Overlay = ({
|
||||||
|
overlay,
|
||||||
|
overlayIndex,
|
||||||
|
pathToNodeString,
|
||||||
|
}: {
|
||||||
|
overlay: SegmentOverlay
|
||||||
|
overlayIndex: number
|
||||||
|
pathToNodeString: string
|
||||||
|
}) => {
|
||||||
|
const { context, send, state } = useModelingContext()
|
||||||
|
let xAlignment = overlay.angle < 0 ? '0%' : '-100%'
|
||||||
|
let yAlignment = overlay.angle < -90 || overlay.angle >= 90 ? '0%' : '-100%'
|
||||||
|
|
||||||
|
const callExpression = getNodeFromPath<CallExpression>(
|
||||||
|
kclManager.ast,
|
||||||
|
overlay.pathToNode,
|
||||||
|
'CallExpression'
|
||||||
|
).node
|
||||||
|
const constraints = getConstraintInfo(
|
||||||
|
callExpression,
|
||||||
|
codeManager.code,
|
||||||
|
overlay.pathToNode
|
||||||
|
)
|
||||||
|
|
||||||
|
const offset = 20 // px
|
||||||
|
// We could put a boolean in settings that
|
||||||
|
const offsetAngle = 90
|
||||||
|
|
||||||
|
const xOffset =
|
||||||
|
Math.cos(((overlay.angle + offsetAngle) * Math.PI) / 180) * offset
|
||||||
|
const yOffset =
|
||||||
|
Math.sin(((overlay.angle + offsetAngle) * Math.PI) / 180) * offset
|
||||||
|
|
||||||
|
const shouldShow =
|
||||||
|
overlay.visible &&
|
||||||
|
typeof context?.segmentHoverMap?.[pathToNodeString] === 'number' &&
|
||||||
|
!(
|
||||||
|
state.matches('Sketch.Line tool') ||
|
||||||
|
state.matches('Sketch.Tangential arc to') ||
|
||||||
|
state.matches('Sketch.Rectangle tool')
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`absolute w-0 h-0`}>
|
||||||
|
<div
|
||||||
|
data-testid="segment-overlay"
|
||||||
|
data-path-to-node={pathToNodeString}
|
||||||
|
data-overlay-index={overlayIndex}
|
||||||
|
data-overlay-angle={overlay.angle}
|
||||||
|
className="pointer-events-auto absolute w-0 h-0"
|
||||||
|
style={{
|
||||||
|
transform: `translate3d(${overlay.windowCoords[0]}px, ${overlay.windowCoords[1]}px, 0)`,
|
||||||
|
}}
|
||||||
|
></div>
|
||||||
|
{shouldShow && (
|
||||||
|
<div
|
||||||
|
className={`px-0 pointer-events-auto absolute flex gap-1`}
|
||||||
|
style={{
|
||||||
|
transform: `translate3d(calc(${
|
||||||
|
overlay.windowCoords[0] + xOffset
|
||||||
|
}px + ${xAlignment}), calc(${
|
||||||
|
overlay.windowCoords[1] - yOffset
|
||||||
|
}px + ${yAlignment}), 0)`,
|
||||||
|
}}
|
||||||
|
onMouseEnter={() =>
|
||||||
|
send({
|
||||||
|
type: 'Set mouse state',
|
||||||
|
data: {
|
||||||
|
type: 'isHovering',
|
||||||
|
on: overlay.group,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
onMouseLeave={() =>
|
||||||
|
send({
|
||||||
|
type: 'Set mouse state',
|
||||||
|
data: { type: 'idle' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{constraints &&
|
||||||
|
constraints.map((constraintInfo, i) => (
|
||||||
|
<ConstraintSymbol
|
||||||
|
constrainInfo={constraintInfo}
|
||||||
|
key={i}
|
||||||
|
verticalPosition={
|
||||||
|
overlay.windowCoords[1] > window.innerHeight / 2
|
||||||
|
? 'top'
|
||||||
|
: 'bottom'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<SegmentMenu
|
||||||
|
verticalPosition={
|
||||||
|
overlay.windowCoords[1] > window.innerHeight / 2
|
||||||
|
? 'top'
|
||||||
|
: 'bottom'
|
||||||
|
}
|
||||||
|
pathToNode={overlay.pathToNode}
|
||||||
|
stdLibFnName={constraints[0]?.stdLibFnName}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type ConfirmModalProps = InstanceProps<boolean, boolean> & { text: string }
|
||||||
|
|
||||||
|
export const ConfirmModal = ({
|
||||||
|
isOpen,
|
||||||
|
onResolve,
|
||||||
|
onReject,
|
||||||
|
text,
|
||||||
|
}: ConfirmModalProps) => {
|
||||||
|
return (
|
||||||
|
<Transition appear show={isOpen} as={Fragment}>
|
||||||
|
<Dialog
|
||||||
|
as="div"
|
||||||
|
className="relative z-10"
|
||||||
|
onClose={() => onResolve(false)}
|
||||||
|
>
|
||||||
|
<Transition.Child
|
||||||
|
as={Fragment}
|
||||||
|
enter="ease-out duration-300"
|
||||||
|
enterFrom="opacity-0"
|
||||||
|
enterTo="opacity-100"
|
||||||
|
leave="ease-in duration-200"
|
||||||
|
leaveFrom="opacity-100"
|
||||||
|
leaveTo="opacity-0"
|
||||||
|
>
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-25" />
|
||||||
|
</Transition.Child>
|
||||||
|
|
||||||
|
<div className="fixed inset-0 overflow-y-auto">
|
||||||
|
<div className="flex min-h-full items-center justify-center p-4 text-center">
|
||||||
|
<Transition.Child
|
||||||
|
as={Fragment}
|
||||||
|
enter="ease-out duration-300"
|
||||||
|
enterFrom="opacity-0 scale-95"
|
||||||
|
enterTo="opacity-100 scale-100"
|
||||||
|
leave="ease-in duration-200"
|
||||||
|
leaveFrom="opacity-100 scale-100"
|
||||||
|
leaveTo="opacity-0 scale-95"
|
||||||
|
>
|
||||||
|
<Dialog.Panel className="rounded relative mx-auto px-4 py-8 bg-chalkboard-10 dark:bg-chalkboard-100 border dark:border-chalkboard-70 max-w-xl w-full shadow-lg">
|
||||||
|
<div>{text}</div>
|
||||||
|
<div className="mt-8 flex justify-between">
|
||||||
|
<ActionButton
|
||||||
|
Element="button"
|
||||||
|
onClick={() => onResolve(true)}
|
||||||
|
>
|
||||||
|
Continue and unconstrain
|
||||||
|
</ActionButton>
|
||||||
|
<ActionButton
|
||||||
|
Element="button"
|
||||||
|
onClick={() => onReject(false)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</ActionButton>
|
||||||
|
</div>
|
||||||
|
</Dialog.Panel>
|
||||||
|
</Transition.Child>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
</Transition>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const confirmModal = create<ConfirmModalProps, boolean, boolean>(
|
||||||
|
ConfirmModal
|
||||||
|
)
|
||||||
|
|
||||||
|
export async function deleteSegment({
|
||||||
|
pathToNode,
|
||||||
|
sketchDetails,
|
||||||
|
}: {
|
||||||
|
pathToNode: PathToNode
|
||||||
|
sketchDetails: SketchDetails | null
|
||||||
|
}) {
|
||||||
|
let modifiedAst: Program = kclManager.ast
|
||||||
|
const dependentRanges = findUsesOfTagInPipe(modifiedAst, pathToNode)
|
||||||
|
|
||||||
|
const shouldContinueSegDelete = dependentRanges.length
|
||||||
|
? await confirmModal({
|
||||||
|
text: `At least ${dependentRanges.length} segment rely on the segment you're deleting.\nDo you want to continue and unconstrain these segments?`,
|
||||||
|
isOpen: true,
|
||||||
|
})
|
||||||
|
: true
|
||||||
|
|
||||||
|
if (!shouldContinueSegDelete) return
|
||||||
|
modifiedAst = deleteSegmentFromPipeExpression(
|
||||||
|
dependentRanges,
|
||||||
|
modifiedAst,
|
||||||
|
kclManager.programMemory,
|
||||||
|
codeManager.code,
|
||||||
|
pathToNode
|
||||||
|
)
|
||||||
|
|
||||||
|
const newCode = recast(modifiedAst)
|
||||||
|
modifiedAst = parse(newCode)
|
||||||
|
const testExecute = await executeAst({
|
||||||
|
ast: modifiedAst,
|
||||||
|
useFakeExecutor: true,
|
||||||
|
engineCommandManager: engineCommandManager,
|
||||||
|
})
|
||||||
|
if (testExecute.errors.length) {
|
||||||
|
toast.error('Segment tag used outside of current Sketch. Could not delete.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sketchDetails) return
|
||||||
|
sceneEntitiesManager.updateAstAndRejigSketch(
|
||||||
|
sketchDetails.sketchPathToNode,
|
||||||
|
modifiedAst,
|
||||||
|
sketchDetails.zAxis,
|
||||||
|
sketchDetails.yAxis,
|
||||||
|
sketchDetails.origin
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const SegmentMenu = ({
|
||||||
|
verticalPosition,
|
||||||
|
pathToNode,
|
||||||
|
stdLibFnName,
|
||||||
|
}: {
|
||||||
|
verticalPosition: 'top' | 'bottom'
|
||||||
|
pathToNode: PathToNode
|
||||||
|
stdLibFnName: string
|
||||||
|
}) => {
|
||||||
|
const { send } = useModelingContext()
|
||||||
|
const dependentSourceRanges = findUsesOfTagInPipe(kclManager.ast, pathToNode)
|
||||||
|
return (
|
||||||
|
<Popover className="relative">
|
||||||
|
{({ open }) => (
|
||||||
|
<>
|
||||||
|
<Popover.Button
|
||||||
|
data-testid="overlay-menu"
|
||||||
|
data-stdlib-fn-name={stdLibFnName}
|
||||||
|
className="bg-chalkboard-10 dark:bg-chalkboard-100 border !border-transparent hover:!border-chalkboard-40 dark:hover:!border-chalkboard-70 ui-open:!border-chalkboard-40 dark:ui-open:!border-chalkboard-70 h-[26px] w-[26px] rounded-sm p-0 m-0"
|
||||||
|
>
|
||||||
|
<CustomIcon name={'three-dots'} />
|
||||||
|
</Popover.Button>
|
||||||
|
<Popover.Panel
|
||||||
|
as="menu"
|
||||||
|
className={`absolute ${
|
||||||
|
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`}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
className="!border-transparent rounded-sm text-left p-1 text-nowrap"
|
||||||
|
onClick={() => {
|
||||||
|
send({ type: 'Constrain remove constraints', data: pathToNode })
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Remove constraints
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="!border-transparent rounded-sm text-left p-1 text-nowrap"
|
||||||
|
title={
|
||||||
|
dependentSourceRanges.length > 0
|
||||||
|
? `At least ${dependentSourceRanges.length} segment rely on this segment's tag.`
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
onClick={() => {
|
||||||
|
send({ type: 'Delete segment', data: pathToNode })
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Delete Segment
|
||||||
|
</button>
|
||||||
|
</Popover.Panel>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Popover>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ConstraintSymbol = ({
|
||||||
|
constrainInfo: { type: _type, isConstrained, value, pathToNode, argPosition },
|
||||||
|
verticalPosition,
|
||||||
|
}: {
|
||||||
|
constrainInfo: ConstrainInfo
|
||||||
|
verticalPosition: 'top' | 'bottom'
|
||||||
|
}) => {
|
||||||
|
const { context, send } = useModelingContext()
|
||||||
|
const varNameMap: {
|
||||||
|
[key in ConstrainInfo['type']]: {
|
||||||
|
varName: string
|
||||||
|
displayName: string
|
||||||
|
iconName: CustomIconName
|
||||||
|
implicitConstraintDesc?: string
|
||||||
|
}
|
||||||
|
} = {
|
||||||
|
xRelative: {
|
||||||
|
varName: 'xRel',
|
||||||
|
displayName: 'X Relative',
|
||||||
|
iconName: 'xRelative',
|
||||||
|
},
|
||||||
|
xAbsolute: {
|
||||||
|
varName: 'xAbs',
|
||||||
|
displayName: 'X Absolute',
|
||||||
|
iconName: 'xAbsolute',
|
||||||
|
},
|
||||||
|
yRelative: {
|
||||||
|
varName: 'yRel',
|
||||||
|
displayName: 'Y Relative',
|
||||||
|
iconName: 'yRelative',
|
||||||
|
},
|
||||||
|
yAbsolute: {
|
||||||
|
varName: 'yAbs',
|
||||||
|
displayName: 'Y Absolute',
|
||||||
|
iconName: 'yAbsolute',
|
||||||
|
},
|
||||||
|
angle: {
|
||||||
|
varName: 'angle',
|
||||||
|
displayName: 'Angle',
|
||||||
|
iconName: 'angle',
|
||||||
|
},
|
||||||
|
length: {
|
||||||
|
varName: 'len',
|
||||||
|
displayName: 'Length',
|
||||||
|
iconName: 'dimension',
|
||||||
|
},
|
||||||
|
intersectionOffset: {
|
||||||
|
varName: 'perpDist',
|
||||||
|
displayName: 'Intersection Offset',
|
||||||
|
iconName: 'intersection-offset',
|
||||||
|
},
|
||||||
|
|
||||||
|
// implicit constraints
|
||||||
|
vertical: {
|
||||||
|
varName: '',
|
||||||
|
displayName: '',
|
||||||
|
iconName: 'vertical',
|
||||||
|
implicitConstraintDesc: 'vertically',
|
||||||
|
},
|
||||||
|
horizontal: {
|
||||||
|
varName: '',
|
||||||
|
displayName: '',
|
||||||
|
iconName: 'horizontal',
|
||||||
|
implicitConstraintDesc: 'horizontally',
|
||||||
|
},
|
||||||
|
tangentialWithPrevious: {
|
||||||
|
varName: '',
|
||||||
|
displayName: '',
|
||||||
|
iconName: 'tangent',
|
||||||
|
implicitConstraintDesc: 'tangential to previous segment',
|
||||||
|
},
|
||||||
|
|
||||||
|
// we don't render this one
|
||||||
|
intersectionTag: {
|
||||||
|
varName: '',
|
||||||
|
displayName: '',
|
||||||
|
iconName: 'dimension',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const varName =
|
||||||
|
_type in varNameMap ? varNameMap[_type as LineInputsType].varName : 'var'
|
||||||
|
const name: CustomIconName = varNameMap[_type as LineInputsType].iconName
|
||||||
|
const displayName = varNameMap[_type as LineInputsType]?.displayName
|
||||||
|
const implicitDesc =
|
||||||
|
varNameMap[_type as LineInputsType]?.implicitConstraintDesc
|
||||||
|
|
||||||
|
const node = useMemo(
|
||||||
|
() => getNodeFromPath<Value>(kclManager.ast, pathToNode).node,
|
||||||
|
[kclManager.ast, pathToNode]
|
||||||
|
)
|
||||||
|
const range: SourceRange = node ? [node.start, node.end] : [0, 0]
|
||||||
|
|
||||||
|
if (_type === 'intersectionTag') return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative group">
|
||||||
|
<button
|
||||||
|
data-testid="constraint-symbol"
|
||||||
|
data-is-implicit-constraint={implicitDesc ? 'true' : 'false'}
|
||||||
|
data-constraint-type={_type}
|
||||||
|
data-is-constrained={isConstrained ? 'true' : 'false'}
|
||||||
|
className={`${
|
||||||
|
implicitDesc
|
||||||
|
? 'bg-chalkboard-10 dark:bg-chalkboard-100 border-transparent border-0 rounded'
|
||||||
|
: isConstrained
|
||||||
|
? 'bg-chalkboard-10 dark:bg-chalkboard-90 dark:hover:bg-chalkboard-80 border-chalkboard-40 dark:border-chalkboard-70 rounded-sm'
|
||||||
|
: 'bg-primary/30 dark:bg-primary text-primary dark:text-chalkboard-10 dark:border-transparent group-hover:bg-primary/40 group-hover:border-primary/50 group-hover:brightness-125'
|
||||||
|
} h-[26px] w-[26px] rounded-sm relative m-0 p-0`}
|
||||||
|
onMouseEnter={() => {
|
||||||
|
editorManager.setHighlightRange(range)
|
||||||
|
}}
|
||||||
|
onMouseLeave={() => {
|
||||||
|
editorManager.setHighlightRange([0, 0])
|
||||||
|
}}
|
||||||
|
// disabled={isConstrained || !convertToVarEnabled}
|
||||||
|
// disabled={implicitDesc} TODO why does this change styles that are hard to override?
|
||||||
|
onClick={async () => {
|
||||||
|
if (!isConstrained) {
|
||||||
|
send({
|
||||||
|
type: 'Convert to variable',
|
||||||
|
data: {
|
||||||
|
pathToNode,
|
||||||
|
variableName: varName,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} else if (isConstrained) {
|
||||||
|
try {
|
||||||
|
const shallowPath = getNodeFromPath<CallExpression>(
|
||||||
|
parse(recast(kclManager.ast)),
|
||||||
|
pathToNode,
|
||||||
|
'CallExpression',
|
||||||
|
true
|
||||||
|
).shallowPath
|
||||||
|
const input = makeRemoveSingleConstraintInput(
|
||||||
|
argPosition,
|
||||||
|
shallowPath
|
||||||
|
)
|
||||||
|
if (!input || !context.sketchDetails) return
|
||||||
|
const transform = removeSingleConstraintInfo(
|
||||||
|
input,
|
||||||
|
kclManager.ast,
|
||||||
|
kclManager.programMemory
|
||||||
|
)
|
||||||
|
if (!transform) return
|
||||||
|
const { modifiedAst } = transform
|
||||||
|
kclManager.updateAst(modifiedAst, true)
|
||||||
|
} catch (e) {
|
||||||
|
console.log('error', e)
|
||||||
|
}
|
||||||
|
toast.success('Constraint removed')
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CustomIcon name={name} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`absolute ${
|
||||||
|
verticalPosition === 'top'
|
||||||
|
? 'top-0 -translate-y-full'
|
||||||
|
: 'bottom-0 translate-y-full'
|
||||||
|
} group-hover:block hidden w-[2px] h-2 translate-x-[12px] bg-white/40`}
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
className={`absolute ${
|
||||||
|
verticalPosition === 'top' ? 'top-0' : 'bottom-0'
|
||||||
|
} group-hover:block hidden`}
|
||||||
|
style={{
|
||||||
|
transform: `translate3d(calc(-50% + 13px), ${
|
||||||
|
verticalPosition === 'top' ? '-100%' : '100%'
|
||||||
|
}, 0)`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="bg-chalkboard-10 dark:bg-chalkboard-90 p-2 px-3 rounded-sm border border-solid border-chalkboard-20 dark:border-chalkboard-80 shadow-sm"
|
||||||
|
data-testid="constraint-symbol-popover"
|
||||||
|
>
|
||||||
|
{implicitDesc ? (
|
||||||
|
<div className="min-w-48">
|
||||||
|
<pre className="inline-block">
|
||||||
|
<code className="text-primary">{value}</code>
|
||||||
|
</pre>{' '}
|
||||||
|
<span>is implicitly constrained {implicitDesc}</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex mb-1">
|
||||||
|
<span className="text-nowrap">
|
||||||
|
<span className="font-bold">
|
||||||
|
{isConstrained ? 'Constrained' : 'Unconstrained'}
|
||||||
|
</span>
|
||||||
|
<span className="text-white/80 text-sm pl-2">
|
||||||
|
{displayName}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex mb-1">
|
||||||
|
<span className="pr-2 whitespace-nowrap">Set to</span>
|
||||||
|
<pre>
|
||||||
|
<code className="text-primary">{value}</code>
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-chalkboard-70 dark:text-chalkboard-40 text-nowrap">
|
||||||
|
{isConstrained
|
||||||
|
? 'Click to unconstrain with raw number'
|
||||||
|
: 'Click to constrain with variable'}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -150,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"
|
||||||
@ -267,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 {
|
||||||
@ -69,12 +67,13 @@ import {
|
|||||||
tangentialArcToSegment,
|
tangentialArcToSegment,
|
||||||
} from './segments'
|
} from './segments'
|
||||||
import {
|
import {
|
||||||
|
addCallExpressionsToPipe,
|
||||||
addCloseToPipe,
|
addCloseToPipe,
|
||||||
addNewSketchLn,
|
addNewSketchLn,
|
||||||
changeSketchArguments,
|
changeSketchArguments,
|
||||||
updateStartProfileAtArgs,
|
updateStartProfileAtArgs,
|
||||||
} from 'lang/std/sketch'
|
} from 'lang/std/sketch'
|
||||||
import { roundOff, throttle } from 'lib/utils'
|
import { normaliseAngle, roundOff, throttle } from 'lib/utils'
|
||||||
import {
|
import {
|
||||||
createArrayExpression,
|
createArrayExpression,
|
||||||
createCallExpressionStdLib,
|
createCallExpressionStdLib,
|
||||||
@ -91,8 +90,11 @@ import { getTangentPointFromPreviousArc } from 'lib/utils2d'
|
|||||||
import { createGridHelper, orthoScale, perspScale } from './helpers'
|
import { createGridHelper, orthoScale, perspScale } from './helpers'
|
||||||
import { Models } from '@kittycad/lib'
|
import { Models } from '@kittycad/lib'
|
||||||
import { uuidv4 } from 'lib/utils'
|
import { uuidv4 } from 'lib/utils'
|
||||||
import { 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,
|
||||||
@ -138,8 +140,8 @@ export class SceneEntities {
|
|||||||
}
|
}
|
||||||
onCamChange = () => {
|
onCamChange = () => {
|
||||||
const orthoFactor = orthoScale(sceneInfra.camControls.camera)
|
const orthoFactor = orthoScale(sceneInfra.camControls.camera)
|
||||||
|
const callbacks: (() => SegmentOverlayPayload | null)[] = []
|
||||||
Object.values(this.activeSegments).forEach((segment) => {
|
Object.values(this.activeSegments).forEach((segment, index) => {
|
||||||
const factor =
|
const factor =
|
||||||
(sceneInfra.camControls.camera instanceof OrthographicCamera
|
(sceneInfra.camControls.camera instanceof OrthographicCamera
|
||||||
? orthoFactor
|
? orthoFactor
|
||||||
@ -150,12 +152,14 @@ export class SceneEntities {
|
|||||||
segment.userData.to &&
|
segment.userData.to &&
|
||||||
segment.userData.type === STRAIGHT_SEGMENT
|
segment.userData.type === STRAIGHT_SEGMENT
|
||||||
) {
|
) {
|
||||||
this.updateStraightSegment({
|
callbacks.push(
|
||||||
from: segment.userData.from,
|
this.updateStraightSegment({
|
||||||
to: segment.userData.to,
|
from: segment.userData.from,
|
||||||
group: segment,
|
to: segment.userData.to,
|
||||||
scale: factor,
|
group: segment,
|
||||||
})
|
scale: factor,
|
||||||
|
})
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@ -164,13 +168,15 @@ export class SceneEntities {
|
|||||||
segment.userData.prevSegment &&
|
segment.userData.prevSegment &&
|
||||||
segment.userData.type === TANGENTIAL_ARC_TO_SEGMENT
|
segment.userData.type === TANGENTIAL_ARC_TO_SEGMENT
|
||||||
) {
|
) {
|
||||||
this.updateTangentialArcToSegment({
|
callbacks.push(
|
||||||
prevSegment: segment.userData.prevSegment,
|
this.updateTangentialArcToSegment({
|
||||||
from: segment.userData.from,
|
prevSegment: segment.userData.prevSegment,
|
||||||
to: segment.userData.to,
|
from: segment.userData.from,
|
||||||
group: segment,
|
to: segment.userData.to,
|
||||||
scale: factor,
|
group: segment,
|
||||||
})
|
scale: factor,
|
||||||
|
})
|
||||||
|
)
|
||||||
}
|
}
|
||||||
if (segment.name === PROFILE_START) {
|
if (segment.name === PROFILE_START) {
|
||||||
segment.scale.set(factor, factor, factor)
|
segment.scale.set(factor, factor, factor)
|
||||||
@ -186,6 +192,7 @@ export class SceneEntities {
|
|||||||
const y = this.axisGroup.getObjectByName(Y_AXIS)
|
const y = this.axisGroup.getObjectByName(Y_AXIS)
|
||||||
y?.scale.set(factor / sceneInfra._baseUnitMultiplier, 1, 1)
|
y?.scale.set(factor / sceneInfra._baseUnitMultiplier, 1, 1)
|
||||||
}
|
}
|
||||||
|
sceneInfra.overlayCallbacks(callbacks)
|
||||||
}
|
}
|
||||||
|
|
||||||
createIntersectionPlane() {
|
createIntersectionPlane() {
|
||||||
@ -365,7 +372,7 @@ export class SceneEntities {
|
|||||||
})
|
})
|
||||||
group.add(_profileStart)
|
group.add(_profileStart)
|
||||||
this.activeSegments[JSON.stringify(segPathToNode)] = _profileStart
|
this.activeSegments[JSON.stringify(segPathToNode)] = _profileStart
|
||||||
|
const callbacks: (() => SegmentOverlayPayload | null)[] = []
|
||||||
sketchGroup.value.forEach((segment, index) => {
|
sketchGroup.value.forEach((segment, index) => {
|
||||||
let segPathToNode = getNodePathFromSourceRange(
|
let segPathToNode = getNodePathFromSourceRange(
|
||||||
maybeModdedAst,
|
maybeModdedAst,
|
||||||
@ -410,6 +417,15 @@ export class SceneEntities {
|
|||||||
texture: sceneInfra.extraSegmentTexture,
|
texture: sceneInfra.extraSegmentTexture,
|
||||||
theme: sceneInfra._theme,
|
theme: sceneInfra._theme,
|
||||||
})
|
})
|
||||||
|
callbacks.push(
|
||||||
|
this.updateTangentialArcToSegment({
|
||||||
|
prevSegment: sketchGroup.value[index - 1],
|
||||||
|
from: segment.from,
|
||||||
|
to: segment.to,
|
||||||
|
group: seg,
|
||||||
|
scale: factor,
|
||||||
|
})
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
seg = straightSegment({
|
seg = straightSegment({
|
||||||
from: segment.from,
|
from: segment.from,
|
||||||
@ -422,6 +438,14 @@ export class SceneEntities {
|
|||||||
texture: sceneInfra.extraSegmentTexture,
|
texture: sceneInfra.extraSegmentTexture,
|
||||||
theme: sceneInfra._theme,
|
theme: sceneInfra._theme,
|
||||||
})
|
})
|
||||||
|
callbacks.push(
|
||||||
|
this.updateStraightSegment({
|
||||||
|
from: segment.from,
|
||||||
|
to: segment.to,
|
||||||
|
group: seg,
|
||||||
|
scale: factor,
|
||||||
|
})
|
||||||
|
)
|
||||||
}
|
}
|
||||||
seg.layers.set(SKETCH_LAYER)
|
seg.layers.set(SKETCH_LAYER)
|
||||||
seg.traverse((child) => {
|
seg.traverse((child) => {
|
||||||
@ -446,6 +470,7 @@ export class SceneEntities {
|
|||||||
this.intersectionPlane.position.set(...position)
|
this.intersectionPlane.position.set(...position)
|
||||||
this.scene.add(group)
|
this.scene.add(group)
|
||||||
sceneInfra.camControls.enableRotate = false
|
sceneInfra.camControls.enableRotate = false
|
||||||
|
sceneInfra.overlayCallbacks(callbacks)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
truncatedAst,
|
truncatedAst,
|
||||||
@ -536,10 +561,34 @@ export class SceneEntities {
|
|||||||
|
|
||||||
let modifiedAst
|
let modifiedAst
|
||||||
if (profileStart) {
|
if (profileStart) {
|
||||||
modifiedAst = addCloseToPipe({
|
const lastSegment = sketchGroup.value.slice(-1)[0]
|
||||||
|
modifiedAst = addCallExpressionsToPipe({
|
||||||
node: kclManager.ast,
|
node: kclManager.ast,
|
||||||
programMemory: kclManager.programMemory,
|
programMemory: kclManager.programMemory,
|
||||||
pathToNode: sketchPathToNode,
|
pathToNode: sketchPathToNode,
|
||||||
|
expressions: [
|
||||||
|
createCallExpressionStdLib(
|
||||||
|
lastSegment.type === 'TangentialArcTo'
|
||||||
|
? 'tangentialArcTo'
|
||||||
|
: 'lineTo',
|
||||||
|
[
|
||||||
|
createArrayExpression([
|
||||||
|
createCallExpressionStdLib('profileStartX', [
|
||||||
|
createPipeSubstitution(),
|
||||||
|
]),
|
||||||
|
createCallExpressionStdLib('profileStartY', [
|
||||||
|
createPipeSubstitution(),
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
createPipeSubstitution(),
|
||||||
|
]
|
||||||
|
),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
modifiedAst = addCloseToPipe({
|
||||||
|
node: modifiedAst,
|
||||||
|
programMemory: kclManager.programMemory,
|
||||||
|
pathToNode: sketchPathToNode,
|
||||||
})
|
})
|
||||||
} else if (intersection2d) {
|
} else if (intersection2d) {
|
||||||
const lastSegment = sketchGroup.value.slice(-1)[0]
|
const lastSegment = sketchGroup.value.slice(-1)[0]
|
||||||
@ -560,13 +609,17 @@ export class SceneEntities {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await kclManager.executeAstMock(modifiedAst)
|
await kclManager.executeAstMock(modifiedAst)
|
||||||
this.setUpDraftSegment(
|
if (profileStart) {
|
||||||
sketchPathToNode,
|
sceneInfra.modelingSend({ type: 'CancelSketch' })
|
||||||
forward,
|
} else {
|
||||||
up,
|
this.setUpDraftSegment(
|
||||||
origin,
|
sketchPathToNode,
|
||||||
segmentName
|
forward,
|
||||||
)
|
up,
|
||||||
|
origin,
|
||||||
|
segmentName
|
||||||
|
)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onMove: (args) => {
|
onMove: (args) => {
|
||||||
this.onDragSegment({
|
this.onDragSegment({
|
||||||
@ -707,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' })
|
||||||
@ -990,7 +1035,8 @@ export class SceneEntities {
|
|||||||
orthoFactor,
|
orthoFactor,
|
||||||
sketchGroup
|
sketchGroup
|
||||||
)
|
)
|
||||||
sgPaths.forEach((group, index) =>
|
|
||||||
|
const callBacks = sgPaths.map((group, index) =>
|
||||||
this.updateSegment(
|
this.updateSegment(
|
||||||
group,
|
group,
|
||||||
index,
|
index,
|
||||||
@ -1000,6 +1046,7 @@ export class SceneEntities {
|
|||||||
sketchGroup
|
sketchGroup
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
sceneInfra.overlayCallbacks(callBacks)
|
||||||
})()
|
})()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1020,7 +1067,7 @@ export class SceneEntities {
|
|||||||
modifiedAst: Program,
|
modifiedAst: Program,
|
||||||
orthoFactor: number,
|
orthoFactor: number,
|
||||||
sketchGroup: SketchGroup
|
sketchGroup: SketchGroup
|
||||||
) => {
|
): (() => SegmentOverlayPayload | null) => {
|
||||||
const segPathToNode = getNodePathFromSourceRange(
|
const segPathToNode = getNodePathFromSourceRange(
|
||||||
modifiedAst,
|
modifiedAst,
|
||||||
segment.__geoMeta.sourceRange
|
segment.__geoMeta.sourceRange
|
||||||
@ -1041,7 +1088,7 @@ export class SceneEntities {
|
|||||||
: perspScale(sceneInfra.camControls.camera, group)) /
|
: perspScale(sceneInfra.camControls.camera, group)) /
|
||||||
sceneInfra._baseUnitMultiplier
|
sceneInfra._baseUnitMultiplier
|
||||||
if (type === TANGENTIAL_ARC_TO_SEGMENT) {
|
if (type === TANGENTIAL_ARC_TO_SEGMENT) {
|
||||||
this.updateTangentialArcToSegment({
|
return this.updateTangentialArcToSegment({
|
||||||
prevSegment: sgPaths[index - 1],
|
prevSegment: sgPaths[index - 1],
|
||||||
from: segment.from,
|
from: segment.from,
|
||||||
to: segment.to,
|
to: segment.to,
|
||||||
@ -1049,7 +1096,7 @@ export class SceneEntities {
|
|||||||
scale: factor,
|
scale: factor,
|
||||||
})
|
})
|
||||||
} else if (type === STRAIGHT_SEGMENT) {
|
} else if (type === STRAIGHT_SEGMENT) {
|
||||||
this.updateStraightSegment({
|
return this.updateStraightSegment({
|
||||||
from: segment.from,
|
from: segment.from,
|
||||||
to: segment.to,
|
to: segment.to,
|
||||||
group,
|
group,
|
||||||
@ -1059,6 +1106,7 @@ export class SceneEntities {
|
|||||||
group.position.set(segment.from[0], segment.from[1], 0)
|
group.position.set(segment.from[0], segment.from[1], 0)
|
||||||
group.scale.set(factor, factor, factor)
|
group.scale.set(factor, factor, factor)
|
||||||
}
|
}
|
||||||
|
return () => null
|
||||||
}
|
}
|
||||||
|
|
||||||
updateTangentialArcToSegment({
|
updateTangentialArcToSegment({
|
||||||
@ -1073,7 +1121,7 @@ export class SceneEntities {
|
|||||||
to: [number, number]
|
to: [number, number]
|
||||||
group: Group
|
group: Group
|
||||||
scale?: number
|
scale?: number
|
||||||
}) {
|
}): () => SegmentOverlayPayload | null {
|
||||||
group.userData.from = from
|
group.userData.from = from
|
||||||
group.userData.to = to
|
group.userData.to = to
|
||||||
group.userData.prevSegment = prevSegment
|
group.userData.prevSegment = prevSegment
|
||||||
@ -1161,6 +1209,18 @@ export class SceneEntities {
|
|||||||
scale,
|
scale,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
const angle = normaliseAngle(
|
||||||
|
(arcInfo.endAngle * 180) / Math.PI + (arcInfo.ccw ? 90 : -90)
|
||||||
|
)
|
||||||
|
return () =>
|
||||||
|
sceneInfra.updateOverlayDetails({
|
||||||
|
arrowGroup,
|
||||||
|
group,
|
||||||
|
isHandlesVisible,
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
angle,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
throttledUpdateDashedArcGeo = throttle(
|
throttledUpdateDashedArcGeo = throttle(
|
||||||
(
|
(
|
||||||
@ -1181,7 +1241,7 @@ export class SceneEntities {
|
|||||||
to: [number, number]
|
to: [number, number]
|
||||||
group: Group
|
group: Group
|
||||||
scale?: number
|
scale?: number
|
||||||
}) {
|
}): () => SegmentOverlayPayload | null {
|
||||||
group.userData.from = from
|
group.userData.from = from
|
||||||
group.userData.to = to
|
group.userData.to = to
|
||||||
const shape = new Shape()
|
const shape = new Shape()
|
||||||
@ -1258,13 +1318,14 @@ export class SceneEntities {
|
|||||||
scale
|
scale
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
return () =>
|
||||||
async animateAfterSketch() {
|
sceneInfra.updateOverlayDetails({
|
||||||
// if (isReducedMotion()) {
|
arrowGroup,
|
||||||
// sceneInfra.camControls.usePerspectiveCamera()
|
group,
|
||||||
// return
|
isHandlesVisible,
|
||||||
// }
|
from,
|
||||||
await sceneInfra.camControls.animateToPerspective()
|
to,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
removeSketchGrid() {
|
removeSketchGrid() {
|
||||||
if (this.axisGroup) this.scene.remove(this.axisGroup)
|
if (this.axisGroup) this.scene.remove(this.axisGroup)
|
||||||
@ -1329,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
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@ -1529,6 +1638,14 @@ export class SceneEntities {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
resetOverlays() {
|
||||||
|
sceneInfra.modelingSend({
|
||||||
|
type: 'Set Segment Overlays',
|
||||||
|
data: {
|
||||||
|
type: 'clear',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DefaultPlaneStr = 'XY' | 'XZ' | 'YZ' | '-XY' | '-XZ' | '-YZ'
|
export type DefaultPlaneStr = 'XY' | 'XZ' | 'YZ' | '-XY' | '-XZ' | '-YZ'
|
||||||
|
@ -21,15 +21,15 @@ import {
|
|||||||
TextureLoader,
|
TextureLoader,
|
||||||
Texture,
|
Texture,
|
||||||
} from 'three'
|
} from 'three'
|
||||||
import { compareVec2Epsilon2 } from 'lang/std/sketch'
|
import { Coords2d, compareVec2Epsilon2 } from 'lang/std/sketch'
|
||||||
import { useModelingContext } from 'hooks/useModelingContext'
|
import { useModelingContext } from 'hooks/useModelingContext'
|
||||||
import * as TWEEN from '@tweenjs/tween.js'
|
import * as TWEEN from '@tweenjs/tween.js'
|
||||||
import { Axis } from 'lib/selections'
|
import { Axis } from 'lib/selections'
|
||||||
import { type BaseUnit } from 'lib/settings/settingsTypes'
|
import { type BaseUnit } from 'lib/settings/settingsTypes'
|
||||||
import { CameraControls } from './CameraControls'
|
import { CameraControls } from './CameraControls'
|
||||||
import { EngineCommandManager } from 'lang/std/engineConnection'
|
import { EngineCommandManager } from 'lang/std/engineConnection'
|
||||||
import { settings } from 'lib/settings/initialSettings'
|
import { MouseState, SegmentOverlayPayload } from 'machines/modelingMachine'
|
||||||
import { MouseState } from 'machines/modelingMachine'
|
import { getAngle, throttle } from 'lib/utils'
|
||||||
import { Themes } from 'lib/theme'
|
import { Themes } from 'lib/theme'
|
||||||
|
|
||||||
type SendType = ReturnType<typeof useModelingContext>['send']
|
type SendType = ReturnType<typeof useModelingContext>['send']
|
||||||
@ -155,8 +155,88 @@ export class SceneInfra {
|
|||||||
}
|
}
|
||||||
|
|
||||||
modelingSend: SendType = (() => {}) as any
|
modelingSend: SendType = (() => {}) as any
|
||||||
|
throttledModelingSend: any = (() => {}) as any
|
||||||
setSend(send: SendType) {
|
setSend(send: SendType) {
|
||||||
this.modelingSend = send
|
this.modelingSend = send
|
||||||
|
this.throttledModelingSend = throttle(send, 100)
|
||||||
|
}
|
||||||
|
overlayTimeout = 0
|
||||||
|
callbacks: (() => SegmentOverlayPayload | null)[] = []
|
||||||
|
_overlayCallbacks(callbacks: (() => SegmentOverlayPayload | null)[]) {
|
||||||
|
const segmentOverlayPayload: SegmentOverlayPayload = {
|
||||||
|
type: 'set-many',
|
||||||
|
overlays: {},
|
||||||
|
}
|
||||||
|
callbacks.forEach((cb) => {
|
||||||
|
const overlay = cb()
|
||||||
|
if (overlay?.type === 'set-one') {
|
||||||
|
segmentOverlayPayload.overlays[overlay.pathToNodeString] = overlay.seg
|
||||||
|
}
|
||||||
|
})
|
||||||
|
this.modelingSend({
|
||||||
|
type: 'Set Segment Overlays',
|
||||||
|
data: segmentOverlayPayload,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
overlayCallbacks(
|
||||||
|
callbacks: (() => SegmentOverlayPayload | null)[],
|
||||||
|
instant = false
|
||||||
|
) {
|
||||||
|
if (instant) {
|
||||||
|
this._overlayCallbacks(callbacks)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.callbacks = callbacks
|
||||||
|
if (this.overlayTimeout) clearTimeout(this.overlayTimeout)
|
||||||
|
this.overlayTimeout = setTimeout(() => {
|
||||||
|
this._overlayCallbacks(this.callbacks)
|
||||||
|
}, 100) as unknown as number
|
||||||
|
}
|
||||||
|
|
||||||
|
overlayThrottleMap: { [pathToNodeString: string]: number } = {}
|
||||||
|
updateOverlayDetails({
|
||||||
|
arrowGroup,
|
||||||
|
group,
|
||||||
|
isHandlesVisible,
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
angle,
|
||||||
|
}: {
|
||||||
|
arrowGroup: Group
|
||||||
|
group: Group
|
||||||
|
isHandlesVisible: boolean
|
||||||
|
from: Coords2d
|
||||||
|
to: Coords2d
|
||||||
|
angle?: number
|
||||||
|
}): SegmentOverlayPayload | null {
|
||||||
|
if (group.userData.pathToNode && arrowGroup) {
|
||||||
|
const vector = new Vector3(0, 0, 0)
|
||||||
|
|
||||||
|
// Get the position of the object3D in world space
|
||||||
|
// console.log('arrowGroup', arrowGroup)
|
||||||
|
arrowGroup.getWorldPosition(vector)
|
||||||
|
|
||||||
|
// Project that position to screen space
|
||||||
|
vector.project(this.camControls.camera)
|
||||||
|
|
||||||
|
const _angle = typeof angle === 'number' ? angle : getAngle(from, to)
|
||||||
|
|
||||||
|
const x = (vector.x * 0.5 + 0.5) * window.innerWidth
|
||||||
|
const y = (-vector.y * 0.5 + 0.5) * window.innerHeight
|
||||||
|
const pathToNodeString = JSON.stringify(group.userData.pathToNode)
|
||||||
|
return {
|
||||||
|
type: 'set-one',
|
||||||
|
pathToNodeString,
|
||||||
|
seg: {
|
||||||
|
windowCoords: [x, y],
|
||||||
|
angle: _angle,
|
||||||
|
group,
|
||||||
|
pathToNode: group.userData.pathToNode,
|
||||||
|
visible: isHandlesVisible,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
hoveredObject: null | any = null
|
hoveredObject: null | any = null
|
||||||
@ -182,15 +262,6 @@ export class SceneInfra {
|
|||||||
this.renderer.setClearColor(0x000000, 0) // Set clear color to black with 0 alpha (fully transparent)
|
this.renderer.setClearColor(0x000000, 0) // Set clear color to black with 0 alpha (fully transparent)
|
||||||
window.addEventListener('resize', this.onWindowResize)
|
window.addEventListener('resize', this.onWindowResize)
|
||||||
|
|
||||||
// CAMERA
|
|
||||||
const camHeightDistanceRatio = 0.5
|
|
||||||
const baseUnit: BaseUnit = settings.modeling.defaultUnit.current
|
|
||||||
const baseRadius = 5.6
|
|
||||||
const length = baseUnitTomm(baseUnit) * baseRadius
|
|
||||||
const ang = Math.atan(camHeightDistanceRatio)
|
|
||||||
const x = Math.cos(ang) * length
|
|
||||||
const y = Math.sin(ang) * length
|
|
||||||
|
|
||||||
this.camControls = new CameraControls(
|
this.camControls = new CameraControls(
|
||||||
false,
|
false,
|
||||||
this.renderer.domElement,
|
this.renderer.domElement,
|
||||||
@ -198,7 +269,6 @@ export class SceneInfra {
|
|||||||
)
|
)
|
||||||
this.camControls.subscribeToCamChange(() => this.onCameraChange())
|
this.camControls.subscribeToCamChange(() => this.onCameraChange())
|
||||||
this.camControls.camera.layers.enable(SKETCH_LAYER)
|
this.camControls.camera.layers.enable(SKETCH_LAYER)
|
||||||
this.camControls.camera.position.set(0, -x, y)
|
|
||||||
if (DEBUG_SHOW_INTERSECTION_PLANE)
|
if (DEBUG_SHOW_INTERSECTION_PLANE)
|
||||||
this.camControls.camera.layers.enable(INTERSECTION_PLANE_LAYER)
|
this.camControls.camera.layers.enable(INTERSECTION_PLANE_LAYER)
|
||||||
|
|
||||||
|
@ -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 && (
|
||||||
|