Compare commits
56 Commits
v0.21.9
...
1549-sketc
Author | SHA1 | Date | |
---|---|---|---|
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 |
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.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
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
run: |
|
||||
@ -172,9 +180,7 @@ jobs:
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
# TODO: re-enable for Windows builds, see https://github.com/tauri-apps/tauri/issues/9045
|
||||
- name: Setup Rust cache
|
||||
if: matrix.os != 'windows-latest'
|
||||
uses: swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: './src-tauri -> target'
|
||||
@ -364,6 +370,17 @@ jobs:
|
||||
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 }}
|
||||
|
||||
- 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:
|
||||
runs-on: ubuntu-latest
|
||||
|
18
.github/workflows/playwright.yml
vendored
@ -46,12 +46,18 @@ jobs:
|
||||
- uses: KittyCAD/action-install-cli@main
|
||||
- name: Install dependencies
|
||||
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
|
||||
run: yarn playwright install --with-deps
|
||||
- name: Download Wasm Cache
|
||||
id: download-wasm
|
||||
if: needs.check-rust-changes.outputs.rust-changed == 'false'
|
||||
uses: dawidd6/action-download-artifact@v3
|
||||
uses: dawidd6/action-download-artifact@v5
|
||||
continue-on-error: true
|
||||
with:
|
||||
github_token: ${{secrets.GITHUB_TOKEN}}
|
||||
@ -143,12 +149,20 @@ jobs:
|
||||
cache: 'yarn'
|
||||
- name: Install dependencies
|
||||
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
|
||||
run: yarn playwright install --with-deps
|
||||
- name: Download Wasm Cache
|
||||
id: download-wasm
|
||||
if: needs.check-rust-changes.outputs.rust-changed == 'false'
|
||||
uses: dawidd6/action-download-artifact@v3
|
||||
uses: dawidd6/action-download-artifact@v5
|
||||
continue-on-error: true
|
||||
with:
|
||||
github_token: ${{secrets.GITHUB_TOKEN}}
|
||||
|
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
|
||||
|
||||
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
|
||||
touch ./e2e/playwright/playwright-secrets.env
|
||||
printf 'token="your-token"\nsnapshottoken="your-snapshot-token"' > ./e2e/playwright/playwright-secrets.env
|
||||
docker run --network host --rm --init -it playwright/chrome:playwright-1.43.1
|
||||
```
|
||||
|
||||
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:
|
||||
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('...`
|
||||
(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>
|
||||
|
||||
### Tauri e2e tests
|
||||
|
||||
#### Windows (local only until the CI edge version mismatch is fixed)
|
||||
|
||||
```
|
||||
yarn install
|
||||
yarn build:wasm
|
||||
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
|
||||
|
||||
For how to contribute to KCL, [see our KCL README](https://github.com/KittyCAD/modeling-app/tree/main/src/wasm-lib/kcl).
|
||||
|
@ -56095,7 +56095,8 @@
|
||||
"const part001 = startSketchOn('XY')\n |> startProfileAt([4, 12], %)\n |> line([2, 0], %)\n |> line([0, -6], %)\n |> line([4, -6], %)\n |> line([0, -6], %)\n |> line([-3.75, -4.5], %)\n |> line([0, -5.5], %)\n |> line([-2, 0], %)\n |> close(%)\n |> revolve({ axis: 'y', angle: 180 }, %)",
|
||||
"const part001 = startSketchOn('XY')\n |> startProfileAt([4, 12], %)\n |> line([2, 0], %)\n |> line([0, -6], %)\n |> line([4, -6], %)\n |> line([0, -6], %)\n |> line([-3.75, -4.5], %)\n |> line([0, -5.5], %)\n |> line([-2, 0], %)\n |> close(%)\n |> revolve({ axis: 'y', angle: 180 }, %)\nconst part002 = startSketchOn(part001, 'end')\n |> startProfileAt([4.5, -5], %)\n |> line([0, 5], %)\n |> line([5, 0], %)\n |> line([0, -5], %)\n |> close(%)\n |> extrude(5, %)",
|
||||
"const box = startSketchOn('XY')\n |> startProfileAt([0, 0], %)\n |> line([0, 20], %)\n |> line([20, 0], %)\n |> line([0, -20], %)\n |> close(%)\n |> extrude(20, %)\n\nconst sketch001 = startSketchOn(box, \"END\")\n |> circle([10, 10], 4, %)\n |> revolve({ angle: -90, axis: 'y' }, %)",
|
||||
"const box = startSketchOn('XY')\n |> startProfileAt([0, 0], %)\n |> line([0, 20], %)\n |> line([20, 0], %)\n |> line([0, -20], %, 'revolveAxis')\n |> close(%)\n |> extrude(20, %)\n\nconst sketch001 = startSketchOn(box, \"END\")\n |> circle([10, 10], 4, %)\n |> revolve({\n angle: 90,\n axis: getOppositeEdge('revolveAxis', box)\n }, %)"
|
||||
"const box = startSketchOn('XY')\n |> startProfileAt([0, 0], %)\n |> line([0, 20], %)\n |> line([20, 0], %)\n |> line([0, -20], %, 'revolveAxis')\n |> close(%)\n |> extrude(20, %)\n\nconst sketch001 = startSketchOn(box, \"END\")\n |> circle([10, 10], 4, %)\n |> revolve({\n angle: 90,\n axis: getOppositeEdge('revolveAxis', box)\n }, %)",
|
||||
"const sketch001 = startSketchOn('XY')\n |> startProfileAt([10, 0], %)\n |> line([5, -5], %)\n |> line([5, 5], %)\n |> lineTo([profileStartX(%), profileStartY(%)], %)\n |> close(%)\n\nconst part001 = revolve({\n axis: {\n custom: {\n axis: [0.0, 1.0, 0.0],\n origin: [0.0, 0.0, 0.0]\n }\n }\n}, sketch001)"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
@ -1,10 +1,10 @@
|
||||
import { test, expect, Download } from '@playwright/test'
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { secrets } from './secrets'
|
||||
import { getUtils } from './test-utils'
|
||||
import { Paths, doExport, getUtils } from './test-utils'
|
||||
import { Models } from '@kittycad/lib'
|
||||
import fsp from 'fs/promises'
|
||||
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 path from 'path'
|
||||
import { TEST_SETTINGS, TEST_SETTINGS_KEY } from './storageStates'
|
||||
@ -99,78 +99,6 @@ const part001 = startSketchOn('-XZ')
|
||||
await page.waitForTimeout(1000)
|
||||
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'] = {
|
||||
axis: 'z',
|
||||
direction: 'positive',
|
||||
@ -186,84 +114,114 @@ const part001 = startSketchOn('-XZ')
|
||||
// just note that only `type` and `storage` are used for selecting the drop downs is the app
|
||||
// the rest are only there to make typescript happy
|
||||
exportLocations.push(
|
||||
await doExport({
|
||||
type: 'step',
|
||||
coords: sysType,
|
||||
})
|
||||
await doExport(
|
||||
{
|
||||
type: 'step',
|
||||
coords: sysType,
|
||||
},
|
||||
page
|
||||
)
|
||||
)
|
||||
exportLocations.push(
|
||||
await doExport({
|
||||
type: 'ply',
|
||||
coords: sysType,
|
||||
selection: { type: 'default_scene' },
|
||||
storage: 'ascii',
|
||||
units: 'in',
|
||||
})
|
||||
await doExport(
|
||||
{
|
||||
type: 'ply',
|
||||
coords: sysType,
|
||||
selection: { type: 'default_scene' },
|
||||
storage: 'ascii',
|
||||
units: 'in',
|
||||
},
|
||||
page
|
||||
)
|
||||
)
|
||||
exportLocations.push(
|
||||
await doExport({
|
||||
type: 'ply',
|
||||
storage: 'binary_little_endian',
|
||||
coords: sysType,
|
||||
selection: { type: 'default_scene' },
|
||||
units: 'in',
|
||||
})
|
||||
await doExport(
|
||||
{
|
||||
type: 'ply',
|
||||
storage: 'binary_little_endian',
|
||||
coords: sysType,
|
||||
selection: { type: 'default_scene' },
|
||||
units: 'in',
|
||||
},
|
||||
page
|
||||
)
|
||||
)
|
||||
exportLocations.push(
|
||||
await doExport({
|
||||
type: 'ply',
|
||||
storage: 'binary_big_endian',
|
||||
coords: sysType,
|
||||
selection: { type: 'default_scene' },
|
||||
units: 'in',
|
||||
})
|
||||
await doExport(
|
||||
{
|
||||
type: 'ply',
|
||||
storage: 'binary_big_endian',
|
||||
coords: sysType,
|
||||
selection: { type: 'default_scene' },
|
||||
units: 'in',
|
||||
},
|
||||
page
|
||||
)
|
||||
)
|
||||
exportLocations.push(
|
||||
await doExport({
|
||||
type: 'stl',
|
||||
storage: 'ascii',
|
||||
coords: sysType,
|
||||
units: 'in',
|
||||
selection: { type: 'default_scene' },
|
||||
})
|
||||
await doExport(
|
||||
{
|
||||
type: 'stl',
|
||||
storage: 'ascii',
|
||||
coords: sysType,
|
||||
units: 'in',
|
||||
selection: { type: 'default_scene' },
|
||||
},
|
||||
page
|
||||
)
|
||||
)
|
||||
exportLocations.push(
|
||||
await doExport({
|
||||
type: 'stl',
|
||||
storage: 'binary',
|
||||
coords: sysType,
|
||||
units: 'in',
|
||||
selection: { type: 'default_scene' },
|
||||
})
|
||||
await doExport(
|
||||
{
|
||||
type: 'stl',
|
||||
storage: 'binary',
|
||||
coords: sysType,
|
||||
units: 'in',
|
||||
selection: { type: 'default_scene' },
|
||||
},
|
||||
page
|
||||
)
|
||||
)
|
||||
exportLocations.push(
|
||||
await doExport({
|
||||
// obj seems to be a little flaky, times out tests sometimes
|
||||
type: 'obj',
|
||||
coords: sysType,
|
||||
units: 'in',
|
||||
})
|
||||
await doExport(
|
||||
{
|
||||
// obj seems to be a little flaky, times out tests sometimes
|
||||
type: 'obj',
|
||||
coords: sysType,
|
||||
units: 'in',
|
||||
},
|
||||
page
|
||||
)
|
||||
)
|
||||
exportLocations.push(
|
||||
await doExport({
|
||||
type: 'gltf',
|
||||
storage: 'embedded',
|
||||
presentation: 'pretty',
|
||||
})
|
||||
await doExport(
|
||||
{
|
||||
type: 'gltf',
|
||||
storage: 'embedded',
|
||||
presentation: 'pretty',
|
||||
},
|
||||
page
|
||||
)
|
||||
)
|
||||
exportLocations.push(
|
||||
await doExport({
|
||||
type: 'gltf',
|
||||
storage: 'binary',
|
||||
presentation: 'pretty',
|
||||
})
|
||||
await doExport(
|
||||
{
|
||||
type: 'gltf',
|
||||
storage: 'binary',
|
||||
presentation: 'pretty',
|
||||
},
|
||||
page
|
||||
)
|
||||
)
|
||||
exportLocations.push(
|
||||
await doExport({
|
||||
type: 'gltf',
|
||||
storage: 'standard',
|
||||
presentation: 'pretty',
|
||||
})
|
||||
await doExport(
|
||||
{
|
||||
type: 'gltf',
|
||||
storage: 'standard',
|
||||
presentation: 'pretty',
|
||||
},
|
||||
page
|
||||
)
|
||||
)
|
||||
|
||||
// close page to disconnect websocket since we can only have one open atm
|
||||
@ -448,7 +406,7 @@ test('Draft segments should look right', async ({ page, context }) => {
|
||||
await page.mouse.click(700, 200)
|
||||
|
||||
await expect(page.locator('.cm-content')).toHaveText(
|
||||
`const part001 = startSketchOn('XZ')`
|
||||
`const sketch001 = startSketchOn('XZ')`
|
||||
)
|
||||
|
||||
await page.waitForTimeout(300) // TODO detect animation ending, or disable animation
|
||||
@ -456,7 +414,7 @@ test('Draft segments should look right', async ({ page, context }) => {
|
||||
const startXPx = 600
|
||||
await page.mouse.click(startXPx + PUR * 10, 500 - PUR * 10)
|
||||
await expect(page.locator('.cm-content'))
|
||||
.toHaveText(`const part001 = startSketchOn('XZ')
|
||||
.toHaveText(`const sketch001 = startSketchOn('XZ')
|
||||
|> startProfileAt([9.06, -12.22], %)`)
|
||||
await page.waitForTimeout(100)
|
||||
|
||||
@ -470,7 +428,7 @@ test('Draft segments should look right', async ({ page, context }) => {
|
||||
await page.waitForTimeout(100)
|
||||
|
||||
await expect(page.locator('.cm-content'))
|
||||
.toHaveText(`const part001 = startSketchOn('XZ')
|
||||
.toHaveText(`const sketch001 = startSketchOn('XZ')
|
||||
|> startProfileAt([9.06, -12.22], %)
|
||||
|> line([9.14, 0], %)`)
|
||||
|
||||
@ -507,7 +465,7 @@ test('Draft rectangles should look right', async ({ page, context }) => {
|
||||
await page.mouse.click(700, 200)
|
||||
|
||||
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
|
||||
@ -556,7 +514,7 @@ test.describe('Client side scene scale should match engine scale', () => {
|
||||
await page.mouse.click(700, 200)
|
||||
|
||||
await expect(page.locator('.cm-content')).toHaveText(
|
||||
`const part001 = startSketchOn('XZ')`
|
||||
`const sketch001 = startSketchOn('XZ')`
|
||||
)
|
||||
|
||||
await page.waitForTimeout(300) // TODO detect animation ending, or disable animation
|
||||
@ -564,7 +522,7 @@ test.describe('Client side scene scale should match engine scale', () => {
|
||||
const startXPx = 600
|
||||
await page.mouse.click(startXPx + PUR * 10, 500 - PUR * 10)
|
||||
await expect(page.locator('.cm-content'))
|
||||
.toHaveText(`const part001 = startSketchOn('XZ')
|
||||
.toHaveText(`const sketch001 = startSketchOn('XZ')
|
||||
|> startProfileAt([9.06, -12.22], %)`)
|
||||
await page.waitForTimeout(100)
|
||||
|
||||
@ -574,7 +532,7 @@ test.describe('Client side scene scale should match engine scale', () => {
|
||||
await page.waitForTimeout(100)
|
||||
|
||||
await expect(page.locator('.cm-content'))
|
||||
.toHaveText(`const part001 = startSketchOn('XZ')
|
||||
.toHaveText(`const sketch001 = startSketchOn('XZ')
|
||||
|> startProfileAt([9.06, -12.22], %)
|
||||
|> line([9.14, 0], %)`)
|
||||
|
||||
@ -584,7 +542,7 @@ test.describe('Client side scene scale should match engine scale', () => {
|
||||
await page.mouse.click(startXPx + PUR * 30, 500 - PUR * 20)
|
||||
|
||||
await expect(page.locator('.cm-content'))
|
||||
.toHaveText(`const part001 = startSketchOn('XZ')
|
||||
.toHaveText(`const sketch001 = startSketchOn('XZ')
|
||||
|> startProfileAt([9.06, -12.22], %)
|
||||
|> line([9.14, 0], %)
|
||||
|> tangentialArcTo([27.34, -3.08], %)`)
|
||||
@ -659,7 +617,7 @@ test.describe('Client side scene scale should match engine scale', () => {
|
||||
await page.mouse.click(700, 200)
|
||||
|
||||
await expect(page.locator('.cm-content')).toHaveText(
|
||||
`const part001 = startSketchOn('XZ')`
|
||||
`const sketch001 = startSketchOn('XZ')`
|
||||
)
|
||||
|
||||
await page.waitForTimeout(300) // TODO detect animation ending, or disable animation
|
||||
@ -667,7 +625,7 @@ test.describe('Client side scene scale should match engine scale', () => {
|
||||
const startXPx = 600
|
||||
await page.mouse.click(startXPx + PUR * 10, 500 - PUR * 10)
|
||||
await expect(page.locator('.cm-content'))
|
||||
.toHaveText(`const part001 = startSketchOn('XZ')
|
||||
.toHaveText(`const sketch001 = startSketchOn('XZ')
|
||||
|> startProfileAt([230.03, -310.32], %)`)
|
||||
await page.waitForTimeout(100)
|
||||
|
||||
@ -677,7 +635,7 @@ test.describe('Client side scene scale should match engine scale', () => {
|
||||
await page.waitForTimeout(100)
|
||||
|
||||
await expect(page.locator('.cm-content'))
|
||||
.toHaveText(`const part001 = startSketchOn('XZ')
|
||||
.toHaveText(`const sketch001 = startSketchOn('XZ')
|
||||
|> startProfileAt([230.03, -310.32], %)
|
||||
|> line([232.2, 0], %)`)
|
||||
|
||||
@ -687,7 +645,7 @@ test.describe('Client side scene scale should match engine scale', () => {
|
||||
await page.mouse.click(startXPx + PUR * 30, 500 - PUR * 20)
|
||||
|
||||
await expect(page.locator('.cm-content'))
|
||||
.toHaveText(`const part001 = startSketchOn('XZ')
|
||||
.toHaveText(`const sketch001 = startSketchOn('XZ')
|
||||
|> startProfileAt([230.03, -310.32], %)
|
||||
|> line([232.2, 0], %)
|
||||
|> tangentialArcTo([694.43, -78.12], %)`)
|
||||
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 41 KiB |
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 42 KiB |
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 47 KiB |
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 43 KiB |
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 28 KiB |
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 31 KiB |
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 34 KiB |
Before Width: | Height: | Size: 65 KiB After Width: | Height: | Size: 66 KiB |
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 71 KiB |
Before Width: | Height: | Size: 75 KiB After Width: | Height: | Size: 75 KiB |
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 43 KiB |
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 45 KiB |
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 43 KiB |
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 46 KiB |
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 47 KiB |
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 42 KiB |
@ -50,3 +50,25 @@ export const TEST_SETTINGS_CORRUPTED = {
|
||||
textWrapping: true,
|
||||
},
|
||||
} satisfies Partial<SaveSettingsPayload>
|
||||
|
||||
export const TEST_CODE_GIZMO = `const part001 = startSketchOn('XZ')
|
||||
|> startProfileAt([20, 0], %)
|
||||
|> line([7.13, 4 + 0], %)
|
||||
|> angledLine({ angle: 3 + 0, length: 3.14 + 0 }, %)
|
||||
|> lineTo([20.14 + 0, -0.14 + 0], %)
|
||||
|> xLineTo(29 + 0, %)
|
||||
|> yLine(-3.14 + 0, %, 'a')
|
||||
|> xLine(1.63, %)
|
||||
|> angledLineOfXLength({ angle: 3 + 0, length: 3.14 }, %)
|
||||
|> angledLineOfYLength({ angle: 30, length: 3 + 0 }, %)
|
||||
|> angledLineToX({ angle: 22.14 + 0, to: 12 }, %)
|
||||
|> angledLineToY({ angle: 30, to: 11.14 }, %)
|
||||
|> angledLineThatIntersects({
|
||||
angle: 3.14,
|
||||
intersectTag: 'a',
|
||||
offset: 0
|
||||
}, %)
|
||||
|> tangentialArcTo([13.14 + 0, 13.14], %)
|
||||
|> close(%)
|
||||
|> extrude(5 + 7, %)
|
||||
`
|
||||
|
@ -1,22 +1,27 @@
|
||||
import { test, expect, Page } from '@playwright/test'
|
||||
import { test, expect, Page, Download } from '@playwright/test'
|
||||
import { EngineCommand } from '../../src/lang/std/engineConnection'
|
||||
import os from 'os'
|
||||
import fsp from 'fs/promises'
|
||||
import pixelMatch from 'pixelmatch'
|
||||
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) {
|
||||
// wait for 'Loading stream...' spinner
|
||||
await page.getByTestId('loading-stream').waitFor()
|
||||
// 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()
|
||||
}
|
||||
|
||||
async function removeCurrentCode(page: Page) {
|
||||
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.press('a')
|
||||
await page.keyboard.up(hotkey)
|
||||
@ -25,12 +30,12 @@ async function removeCurrentCode(page: Page) {
|
||||
}
|
||||
|
||||
async function sendCustomCmd(page: Page, cmd: EngineCommand) {
|
||||
await page.fill('[data-testid="custom-cmd-input"]', JSON.stringify(cmd))
|
||||
await page.click('[data-testid="custom-cmd-send-button"]')
|
||||
await page.getByTestId('custom-cmd-input').fill(JSON.stringify(cmd))
|
||||
await page.getByTestId('custom-cmd-send-button').click()
|
||||
}
|
||||
|
||||
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) {
|
||||
@ -94,11 +99,79 @@ async function waitForCmdReceive(page: Page, commandType: string) {
|
||||
.waitFor()
|
||||
}
|
||||
|
||||
export const wiggleMove = async (
|
||||
page: any,
|
||||
x: number,
|
||||
y: number,
|
||||
steps: number,
|
||||
dist: number,
|
||||
ang: number,
|
||||
amplitude: number,
|
||||
freq: number
|
||||
) => {
|
||||
const tau = Math.PI * 2
|
||||
const deg = tau / 360
|
||||
const step = dist / steps
|
||||
for (let i = 0, j = 0; i < dist; i += step, j += 1) {
|
||||
const [x1, y1] = [0, Math.sin((tau / steps) * j * freq) * amplitude]
|
||||
const [x2, y2] = [
|
||||
Math.cos(-ang * deg) * i - Math.sin(-ang * deg) * y1,
|
||||
Math.sin(-ang * deg) * i + Math.cos(-ang * deg) * y1,
|
||||
]
|
||||
const [xr, yr] = [x2, y2]
|
||||
await page.mouse.move(x + xr, y + yr, { steps: 2 })
|
||||
}
|
||||
}
|
||||
|
||||
export const getMovementUtils = (opts: any) => {
|
||||
// The way we truncate is kinda odd apparently, so we need this function
|
||||
// "[k]itty[c]ad round"
|
||||
const kcRound = (n: number) => Math.trunc(n * 100) / 100
|
||||
|
||||
// To translate between screen and engine ("[U]nit") coordinates
|
||||
// NOTE: these pretty much can't be perfect because of screen scaling.
|
||||
// Handle on a case-by-case.
|
||||
const toU = (x: number, y: number) => [
|
||||
kcRound(x * 0.0854),
|
||||
kcRound(-y * 0.0854), // 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 =
|
||||
process.platform === 'darwin'
|
||||
? null
|
||||
: await page.context().newCDPSession(page)
|
||||
browserType !== 'chromium' ? null : await page.context().newCDPSession(page)
|
||||
|
||||
return {
|
||||
waitForAuthSkipAppStart: () => waitForPageLoad(page),
|
||||
@ -130,11 +203,29 @@ export async function getUtils(page: Page) {
|
||||
},
|
||||
waitForCmdReceive: (commandType: string) =>
|
||||
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) => ({ x: box?.x || 0, y: box?.y || 0 })),
|
||||
.then((box) => ({ ...box, x: box?.x || 0, y: box?.y || 0 })),
|
||||
codeLocator: page.locator('.cm-content'),
|
||||
doAndWaitForCmd: async (
|
||||
fn: () => Promise<void>,
|
||||
commandType: string,
|
||||
@ -150,6 +241,30 @@ export async function getUtils(page: 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) =>
|
||||
new Promise(async (resolve) => {
|
||||
await page.screenshot({
|
||||
@ -277,3 +392,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,11 +1,19 @@
|
||||
import { browser, $, expect } from '@wdio/globals'
|
||||
import fs from 'fs/promises'
|
||||
import path from 'path'
|
||||
import os from 'os'
|
||||
|
||||
const documentsDir = `${process.env.HOME}/Documents`
|
||||
const userSettingsDir = `${process.env.HOME}/.config/dev.zoo.modeling-app`
|
||||
const defaultProjectDir = `${documentsDir}/zoo-modeling-app-projects`
|
||||
const newProjectDir = `${documentsDir}/a-different-directory`
|
||||
const userCodeDir = '/tmp/kittycad_user_code'
|
||||
const isWin32 = os.platform() === 'win32'
|
||||
const documentsDir = path.join(os.homedir(), 'Documents')
|
||||
const userSettingsDir = path.join(
|
||||
os.homedir(),
|
||||
'.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> {
|
||||
// Workaround for .click(), see https://github.com/tauri-apps/tauri/issues/6541
|
||||
@ -24,7 +32,7 @@ async function setDatasetValue(
|
||||
await browser.execute(`arguments[0].dataset.${property} = "${value}"`, field)
|
||||
}
|
||||
|
||||
describe('ZMA (Tauri, Linux)', () => {
|
||||
describe('ZMA (Tauri)', () => {
|
||||
it('opens the auth page and signs in', async () => {
|
||||
// Clean up filesystem from previous tests
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
@ -42,9 +50,7 @@ describe('ZMA (Tauri, Linux)', () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000))
|
||||
|
||||
// Get from main.rs
|
||||
const userCode = await (
|
||||
await fs.readFile('/tmp/kittycad_user_code')
|
||||
).toString()
|
||||
const userCode = await (await fs.readFile(userCodeDir)).toString()
|
||||
console.log(`Found user code ${userCode}`)
|
||||
|
||||
// Device flow: verify
|
||||
@ -92,7 +98,12 @@ describe('ZMA (Tauri, Linux)', () => {
|
||||
* to be able to skip the folder selection dialog if data-testValue
|
||||
* 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"]')
|
||||
await click(projectDirButton)
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
@ -102,6 +113,15 @@ describe('ZMA (Tauri, Linux)', () => {
|
||||
const nameInput = await $('[data-testid="projects-defaultProjectName"]')
|
||||
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"]')
|
||||
await click(closeButton)
|
||||
})
|
||||
@ -120,12 +140,19 @@ describe('ZMA (Tauri, Linux)', () => {
|
||||
it('opens the new file and expects a loading stream', async () => {
|
||||
const projectLink = await $('[data-testid="project-link"]')
|
||||
await click(projectLink)
|
||||
const errorText = await $('[data-testid="unexpected-error"]')
|
||||
expect(await errorText.getText()).toContain('unexpected error')
|
||||
await browser.execute('window.location.href = "tauri://localhost/home"')
|
||||
if (isWin32) {
|
||||
// TODO: actually do something to check that the stream is up
|
||||
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"`)
|
||||
})
|
||||
|
||||
it('signs out', async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||
const menuButton = await $('[data-testid="user-sidebar-toggle"]')
|
||||
await click(menuButton)
|
||||
const signoutButton = await $('[data-testid="user-sidebar-sign-out"]')
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "untitled-app",
|
||||
"version": "0.21.9",
|
||||
"version": "0.22.1",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.16.0",
|
||||
@ -10,7 +10,7 @@
|
||||
"@fortawesome/react-fontawesome": "^0.2.0",
|
||||
"@headlessui/react": "^1.7.19",
|
||||
"@headlessui/tailwindcss": "^0.2.0",
|
||||
"@kittycad/lib": "^0.0.63",
|
||||
"@kittycad/lib": "^0.0.64",
|
||||
"@lezer/javascript": "^1.4.9",
|
||||
"@open-rpc/client-js": "^1.8.1",
|
||||
"@react-hook/resize-observer": "^2.0.1",
|
||||
@ -95,7 +95,8 @@
|
||||
"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",
|
||||
"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": {
|
||||
"trailingComma": "es5",
|
||||
|
@ -12,12 +12,12 @@ import { defineConfig, devices } from '@playwright/test'
|
||||
export default defineConfig({
|
||||
testDir: './e2e/playwright',
|
||||
/* 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. */
|
||||
forbidOnly: !!process.env.CI,
|
||||
/* Retry on CI only */
|
||||
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,
|
||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||
reporter: 'html',
|
||||
@ -34,7 +34,14 @@ export default defineConfig({
|
||||
projects: [
|
||||
{
|
||||
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',
|
||||
@ -72,7 +79,7 @@ export default defineConfig({
|
||||
|
||||
/* Run your local dev server before starting the tests */
|
||||
webServer: {
|
||||
command: 'yarn serve',
|
||||
command: 'yarn start',
|
||||
// url: 'http://127.0.0.1:3000',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
},
|
||||
|
126
src-tauri/Cargo.lock
generated
@ -200,7 +200,7 @@ dependencies = [
|
||||
"tauri-plugin-shell",
|
||||
"tauri-plugin-updater",
|
||||
"tokio",
|
||||
"toml 0.8.13",
|
||||
"toml 0.8.14",
|
||||
"url",
|
||||
]
|
||||
|
||||
@ -766,7 +766,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a969e13a7589e9e3e4207e153bae624ade2b5622fb4684a4923b23ec3d57719"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"toml 0.8.13",
|
||||
"toml 0.8.14",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -1373,7 +1373,7 @@ dependencies = [
|
||||
"cc",
|
||||
"memchr",
|
||||
"rustc_version",
|
||||
"toml 0.8.13",
|
||||
"toml 0.8.14",
|
||||
"vswhom",
|
||||
"winreg 0.52.0",
|
||||
]
|
||||
@ -2578,8 +2578,6 @@ dependencies = [
|
||||
"gltf-json",
|
||||
"js-sys",
|
||||
"kittycad",
|
||||
"kittycad-execution-plan-macros",
|
||||
"kittycad-execution-plan-traits",
|
||||
"lazy_static",
|
||||
"mime_guess",
|
||||
"parse-display",
|
||||
@ -2592,7 +2590,7 @@ dependencies = [
|
||||
"thiserror",
|
||||
"tokio",
|
||||
"tokio-tungstenite",
|
||||
"toml 0.8.13",
|
||||
"toml 0.8.14",
|
||||
"tower-lsp",
|
||||
"ts-rs",
|
||||
"url",
|
||||
@ -2602,7 +2600,7 @@ dependencies = [
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
"winnow 0.5.40",
|
||||
"zip 1.3.0",
|
||||
"zip 2.1.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -2655,28 +2653,6 @@ dependencies = [
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kittycad-execution-plan-macros"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0611fc9b9786175da21d895ffa0f65039e19c9111e94a41b7af999e3b95f045f"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.66",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kittycad-execution-plan-traits"
|
||||
version = "0.1.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "123cb47e2780ea8ef3aa67b4db237a27b388d3d3b96db457e274aa4565723151"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"thiserror",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kuchikiki"
|
||||
version = "0.8.2"
|
||||
@ -3353,9 +3329,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "parse-display"
|
||||
version = "0.9.0"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "06af5f9333eb47bd9ba8462d612e37a8328a5cb80b13f0af4de4c3b89f52dee5"
|
||||
checksum = "914a1c2265c98e2446911282c6ac86d8524f495792c38c5bd884f80499c7538a"
|
||||
dependencies = [
|
||||
"parse-display-derive",
|
||||
"regex",
|
||||
@ -3364,9 +3340,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "parse-display-derive"
|
||||
version = "0.9.0"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc9252f259500ee570c75adcc4e317fa6f57a1e47747d622e0bf838002a7b790"
|
||||
checksum = "2ae7800a4c974efd12df917266338e79a7a74415173caf7e70aa0a0707345281"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@ -3735,9 +3711,9 @@ checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068"
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.83"
|
||||
version = "1.0.85"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b33eb56c327dec362a9e55b3ad14f9d2f0904fb5a5b03b513ab5465399e9f43"
|
||||
checksum = "22244ce15aa966053a896d1accb3a6e68469b97c7f33f284b99f0d576879fc23"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
@ -4299,6 +4275,19 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ebbbdb961df0ad3f2652da8f3fdc4b36122f568f968f45ad3316f26c025c677b"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"rustls-pki-types",
|
||||
"rustls-webpki 0.102.3",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-native-certs"
|
||||
version = "0.7.0"
|
||||
@ -4500,9 +4489,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.202"
|
||||
version = "1.0.203"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "226b61a0d411b2ba5ff6d7f73a476ac4f8bb900373459cd00fab8512828ba395"
|
||||
checksum = "7253ab4de971e72fb7be983802300c30b5a7f0c2e56fab8abfc6a214307c0094"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
@ -4518,9 +4507,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.202"
|
||||
version = "1.0.203"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6048858004bcff69094cd972ed40a32500f153bd3be9f716b2eed2e8217c4838"
|
||||
checksum = "500cbc0ebeb6f46627f50f3f5811ccf6bf00643be300b4c3eabc0ef55dc5b5ba"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@ -5042,7 +5031,7 @@ dependencies = [
|
||||
"cfg-expr",
|
||||
"heck 0.5.0",
|
||||
"pkg-config",
|
||||
"toml 0.8.13",
|
||||
"toml 0.8.14",
|
||||
"version-compare",
|
||||
]
|
||||
|
||||
@ -5195,7 +5184,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"tauri-utils",
|
||||
"tauri-winres",
|
||||
"toml 0.8.13",
|
||||
"toml 0.8.14",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
@ -5253,7 +5242,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri-utils",
|
||||
"toml 0.8.13",
|
||||
"toml 0.8.14",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
@ -5530,7 +5519,7 @@ dependencies = [
|
||||
"serde_with",
|
||||
"swift-rs",
|
||||
"thiserror",
|
||||
"toml 0.8.13",
|
||||
"toml 0.8.14",
|
||||
"url",
|
||||
"urlpattern",
|
||||
"walkdir",
|
||||
@ -5664,9 +5653,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.37.0"
|
||||
version = "1.38.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1adbebffeca75fcfd058afa480fb6c0b81e165a0323f9c9d39c9697e37c46787"
|
||||
checksum = "ba4f4a02a7a80d6f274636f0aa95c7e383b912d41fe721a31f29e29698585a4a"
|
||||
dependencies = [
|
||||
"backtrace",
|
||||
"bytes",
|
||||
@ -5684,9 +5673,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tokio-macros"
|
||||
version = "2.2.0"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5b8a1e28f2deaa14e508979454cb3a223b10b938b45af148bc0986de36f1923b"
|
||||
checksum = "5f5ae998a069d4b5aba8ee9dad856af7d520c3699e6159b185c2acd48155d39a"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@ -5715,18 +5704,29 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-tungstenite"
|
||||
version = "0.21.0"
|
||||
name = "tokio-rustls"
|
||||
version = "0.26.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c83b561d025642014097b66e6c1bb422783339e0909e4429cde4749d1990bc38"
|
||||
checksum = "0c7bc40d0e5a97695bb96e27995cd3a08538541b0a846f65bba7a359f36700d4"
|
||||
dependencies = [
|
||||
"rustls 0.23.7",
|
||||
"rustls-pki-types",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-tungstenite"
|
||||
version = "0.23.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "becd34a233e7e31a3dbf7c7241b38320f57393dcae8e7324b0167d21b8e320b0"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"log",
|
||||
"rustls 0.22.4",
|
||||
"rustls 0.23.7",
|
||||
"rustls-native-certs",
|
||||
"rustls-pki-types",
|
||||
"tokio",
|
||||
"tokio-rustls 0.25.0",
|
||||
"tokio-rustls 0.26.0",
|
||||
"tungstenite",
|
||||
]
|
||||
|
||||
@ -5758,14 +5758,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "toml"
|
||||
version = "0.8.13"
|
||||
version = "0.8.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a4e43f8cc456c9704c851ae29c67e17ef65d2c30017c17a9765b89c382dc8bba"
|
||||
checksum = "6f49eb2ab21d2f26bd6db7bf383edc527a7ebaee412d17af4d40fdccd442f335"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_spanned",
|
||||
"toml_datetime",
|
||||
"toml_edit 0.22.13",
|
||||
"toml_edit 0.22.14",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -5814,9 +5814,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "toml_edit"
|
||||
version = "0.22.13"
|
||||
version = "0.22.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c127785850e8c20836d49732ae6abfa47616e60bf9d9f57c43c250361a9db96c"
|
||||
checksum = "f21c7aaf97f1bd9ca9d4f9e73b0a6c74bd5afef56f2bc931943a6e1c37e04e38"
|
||||
dependencies = [
|
||||
"indexmap 2.2.6",
|
||||
"serde",
|
||||
@ -6035,9 +6035,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tungstenite"
|
||||
version = "0.21.0"
|
||||
version = "0.23.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ef1a641ea34f399a848dea702823bbecfb4c486f911735368f1f137cb8257e1"
|
||||
checksum = "6e2e2ce1e47ed2994fd43b04c8f618008d4cabdd5ee34027cf14f9d918edd9c8"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"bytes",
|
||||
@ -6046,11 +6046,10 @@ dependencies = [
|
||||
"httparse",
|
||||
"log",
|
||||
"rand 0.8.5",
|
||||
"rustls 0.22.4",
|
||||
"rustls 0.23.7",
|
||||
"rustls-pki-types",
|
||||
"sha1",
|
||||
"thiserror",
|
||||
"url",
|
||||
"utf-8",
|
||||
]
|
||||
|
||||
@ -7106,15 +7105,16 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "1.3.0"
|
||||
version = "2.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1f4a27345eb6f7aa7bd015ba7eb4175fa4e1b462a29874b779e0bbcf96c6ac7"
|
||||
checksum = "1dd56a4d5921bc2f99947ac5b3abe5f510b1be7376fdc5e9fce4a23c6a93e87c"
|
||||
dependencies = [
|
||||
"arbitrary",
|
||||
"crc32fast",
|
||||
"crossbeam-utils",
|
||||
"displaydoc",
|
||||
"indexmap 2.2.6",
|
||||
"memchr",
|
||||
"thiserror",
|
||||
]
|
||||
|
||||
|
@ -267,7 +267,15 @@ async fn login(app: tauri::AppHandle, host: &str) -> Result<String, InvokeError>
|
||||
let e2e_tauri_enabled = env::var("E2E_TAURI_ENABLED").is_ok();
|
||||
if e2e_tauri_enabled {
|
||||
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
|
||||
.map_err(|e| InvokeError::from_anyhow(e.into()))?;
|
||||
} else {
|
||||
|
@ -74,5 +74,5 @@
|
||||
}
|
||||
},
|
||||
"productName": "Zoo Modeling App",
|
||||
"version": "0.21.9"
|
||||
"version": "0.22.1"
|
||||
}
|
||||
|
@ -12,6 +12,8 @@ import SignIn from './routes/SignIn'
|
||||
import { Auth } from './Auth'
|
||||
import { isTauri } from './lib/isTauri'
|
||||
import Home from './routes/Home'
|
||||
import { NetworkContext } from './hooks/useNetworkContext'
|
||||
import { useNetworkStatus } from './hooks/useNetworkStatus'
|
||||
import makeUrlPathRelative from './lib/makeUrlPathRelative'
|
||||
import DownloadAppBanner from 'components/DownloadAppBanner'
|
||||
import { WasmErrBanner } from 'components/WasmErrBanner'
|
||||
@ -155,5 +157,11 @@ const router = createBrowserRouter([
|
||||
* @returns RouterProvider
|
||||
*/
|
||||
export const Router = () => {
|
||||
return <RouterProvider router={router} />
|
||||
const networkStatus = useNetworkStatus()
|
||||
|
||||
return (
|
||||
<NetworkContext.Provider value={networkStatus}>
|
||||
<RouterProvider router={router} />
|
||||
</NetworkContext.Provider>
|
||||
)
|
||||
}
|
||||
|
@ -3,13 +3,11 @@ import { isCursorInSketchCommandRange } from 'lang/util'
|
||||
import { engineCommandManager, kclManager } from 'lib/singletons'
|
||||
import { useModelingContext } from 'hooks/useModelingContext'
|
||||
import { useCommandsContext } from 'hooks/useCommandsContext'
|
||||
import { useNetworkContext } from 'hooks/useNetworkContext'
|
||||
import { NetworkHealthState } from 'hooks/useNetworkStatus'
|
||||
import { ActionButton } from 'components/ActionButton'
|
||||
import { isSingleCursorInPipe } from 'lang/queryAst'
|
||||
import { useKclContext } from 'lang/KclProvider'
|
||||
import {
|
||||
NetworkHealthState,
|
||||
useNetworkStatus,
|
||||
} from 'components/NetworkHealthIndicator'
|
||||
import { useStore } from 'useStore'
|
||||
import { ActionButtonDropdown } from 'components/ActionButtonDropdown'
|
||||
import { useHotkeys } from 'react-hotkeys-hook'
|
||||
@ -38,14 +36,16 @@ export function Toolbar({
|
||||
}, [engineCommandManager.artifactMap, context.selectionRanges])
|
||||
|
||||
const toolbarButtonsRef = useRef<HTMLUListElement>(null)
|
||||
|
||||
const { overallState } = useNetworkStatus()
|
||||
const { overallState } = useNetworkContext()
|
||||
const { isExecuting } = useKclContext()
|
||||
const { isStreamReady } = useStore((s) => ({
|
||||
isStreamReady: s.isStreamReady,
|
||||
}))
|
||||
const disableAllButtons =
|
||||
overallState !== NetworkHealthState.Ok || isExecuting || !isStreamReady
|
||||
(overallState !== NetworkHealthState.Ok &&
|
||||
overallState !== NetworkHealthState.Weak) ||
|
||||
isExecuting ||
|
||||
!isStreamReady
|
||||
|
||||
useHotkeys(
|
||||
'l',
|
||||
|
@ -48,12 +48,14 @@ export type ReactCameraProperties =
|
||||
type: 'perspective'
|
||||
fov?: number
|
||||
position: [number, number, number]
|
||||
target: [number, number, number]
|
||||
quaternion: [number, number, number, number]
|
||||
}
|
||||
| {
|
||||
type: 'orthographic'
|
||||
zoom?: number
|
||||
position: [number, number, number]
|
||||
target: [number, number, number]
|
||||
quaternion: [number, number, number, number]
|
||||
}
|
||||
|
||||
@ -442,7 +444,7 @@ export class CameraControls {
|
||||
this.handleEnd()
|
||||
return
|
||||
}
|
||||
this.throttledEngCmd({
|
||||
this.engineCommandManager.sendSceneCommand({
|
||||
type: 'modeling_cmd_req',
|
||||
cmd: {
|
||||
type: 'default_camera_zoom',
|
||||
@ -454,11 +456,11 @@ export class CameraControls {
|
||||
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 *= 1 + (event.deltaY > 0 ? zoomSpeed : -zoomSpeed)
|
||||
this.pendingZoom *= 1 + event.deltaY * 0.01
|
||||
this.handleEnd()
|
||||
}
|
||||
|
||||
@ -773,6 +775,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(
|
||||
targetQuaternion: Quaternion,
|
||||
targetPosition = new Vector3(),
|
||||
@ -957,6 +1028,11 @@ export class CameraControls {
|
||||
roundOff(this.camera.position.y, 2),
|
||||
roundOff(this.camera.position.z, 2),
|
||||
],
|
||||
target: [
|
||||
roundOff(this.target.x, 2),
|
||||
roundOff(this.target.y, 2),
|
||||
roundOff(this.target.z, 2),
|
||||
],
|
||||
quaternion: [
|
||||
roundOff(this.camera.quaternion.x, 2),
|
||||
roundOff(this.camera.quaternion.y, 2),
|
||||
|
@ -420,12 +420,16 @@ const SegmentMenu = ({
|
||||
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="hover:bg-white/80 bg-white/50 rounded p-1 text-nowrap">
|
||||
Remove segment constraints
|
||||
</button> */}
|
||||
<button
|
||||
className="!border-transparent rounded-sm text-left p-1 text-nowrap"
|
||||
// disabled={dependentSourceRanges.length > 0}
|
||||
onClick={() => {
|
||||
send({ type: 'Constrain remove constraints', data: pathToNode })
|
||||
}}
|
||||
>
|
||||
Remove constraints
|
||||
</button>
|
||||
<button
|
||||
className="!border-transparent rounded-sm text-left p-1 text-nowrap"
|
||||
title={
|
||||
dependentSourceRanges.length > 0
|
||||
? `At least ${dependentSourceRanges.length} segment rely on this segment's tag.`
|
||||
@ -531,8 +535,7 @@ const ConstraintSymbol = ({
|
||||
varNameMap[_type as LineInputsType]?.implicitConstraintDesc
|
||||
|
||||
const node = useMemo(
|
||||
() =>
|
||||
getNodeFromPath<Value>(parse(recast(kclManager.ast)), pathToNode).node,
|
||||
() => getNodeFromPath<Value>(kclManager.ast, pathToNode).node,
|
||||
[kclManager.ast, pathToNode]
|
||||
)
|
||||
const range: SourceRange = node ? [node.start, node.end] : [0, 0]
|
||||
@ -696,6 +699,15 @@ export const CamDebugSettings = () => {
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div>
|
||||
<button
|
||||
onClick={() => {
|
||||
sceneInfra.camControls.resetCameraPosition()
|
||||
}}
|
||||
>
|
||||
Reset Camera Position
|
||||
</button>
|
||||
</div>
|
||||
{camSettings.type === 'perspective' && (
|
||||
<input
|
||||
type="range"
|
||||
@ -813,6 +825,71 @@ export const CamDebugSettings = () => {
|
||||
</li>
|
||||
</ul>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
@ -93,7 +93,10 @@ import { createGridHelper, orthoScale, perspScale } from './helpers'
|
||||
import { Models } from '@kittycad/lib'
|
||||
import { uuidv4 } from 'lib/utils'
|
||||
import { SegmentOverlayPayload, SketchDetails } from 'machines/modelingMachine'
|
||||
import { EngineCommandManager } from 'lang/std/engineConnection'
|
||||
import {
|
||||
ArtifactMapCommand,
|
||||
EngineCommandManager,
|
||||
} from 'lang/std/engineConnection'
|
||||
import {
|
||||
getRectangleCallExpressions,
|
||||
updateRectangleSketch,
|
||||
@ -759,14 +762,6 @@ export class SceneEntities {
|
||||
|
||||
_ast = parse(recast(_ast))
|
||||
|
||||
console.log('onClick', {
|
||||
sketchInit: sketchInit,
|
||||
_ast,
|
||||
x,
|
||||
y,
|
||||
truncatedAst,
|
||||
})
|
||||
|
||||
// Update the primary AST and unequip the rectangle tool
|
||||
await kclManager.executeAstMock(_ast)
|
||||
sceneInfra.modelingSend({ type: 'CancelSketch' })
|
||||
@ -1422,6 +1417,30 @@ export class SceneEntities {
|
||||
return ['plane', entity_id]
|
||||
}
|
||||
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
|
||||
|
||||
// tsc cannot infer that target can have extrusions
|
||||
// from the commandType (why?) so we need to cast it
|
||||
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 ['other', entity_id]
|
||||
|
||||
@ -1429,10 +1448,13 @@ export class SceneEntities {
|
||||
if (!faceInfo?.origin || !faceInfo?.z_axis || !faceInfo?.y_axis)
|
||||
return ['other', entity_id]
|
||||
const { z_axis, y_axis, origin } = faceInfo
|
||||
const pathToNode = getNodePathFromSourceRange(
|
||||
const sketchPathToNode = getNodePathFromSourceRange(
|
||||
kclManager.ast,
|
||||
artifact.range
|
||||
)
|
||||
const extrudePathToNode = extrusions?.range
|
||||
? getNodePathFromSourceRange(kclManager.ast, extrusions.range)
|
||||
: []
|
||||
|
||||
sceneInfra.modelingSend({
|
||||
type: 'Select default plane',
|
||||
@ -1443,7 +1465,8 @@ export class SceneEntities {
|
||||
position: [origin.x, origin.y, origin.z].map(
|
||||
(num) => num / sceneInfra._baseUnitMultiplier
|
||||
) as [number, number, number],
|
||||
extrudeSegmentPathToNode: pathToNode,
|
||||
sketchPathToNode,
|
||||
extrudePathToNode,
|
||||
cap:
|
||||
artifact?.additionalData?.type === 'cap'
|
||||
? artifact.additionalData.info
|
||||
@ -1455,7 +1478,6 @@ export class SceneEntities {
|
||||
}
|
||||
|
||||
const faceResult = await checkExtrudeFaceClick()
|
||||
console.log('faceResult', faceResult)
|
||||
if (faceResult[0] === 'face') return
|
||||
|
||||
if (!args || !args.intersects?.[0]) return
|
||||
|
@ -39,6 +39,7 @@ export function ActionButtonDropdown({
|
||||
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"
|
||||
disabled={item.disabled}
|
||||
data-testid={item.label}
|
||||
>
|
||||
<span className="capitalize">{item.label}</span>
|
||||
{item.shortcut && (
|
||||
|
@ -214,13 +214,17 @@ export const CreateNewVariable = ({
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
<label htmlFor="create-new-variable" className="block mt-3 font-mono">
|
||||
<label
|
||||
htmlFor="create-new-variable"
|
||||
className="block mt-3 font-mono text-chalkboard-90"
|
||||
>
|
||||
Create new variable
|
||||
</label>
|
||||
<div className="mt-1 flex gap-2 items-center">
|
||||
{showCheckbox && (
|
||||
<input
|
||||
type="checkbox"
|
||||
data-testid="create-new-variable-checkbox"
|
||||
checked={shouldCreateVariable}
|
||||
onChange={(e) => {
|
||||
setShouldCreateVariable(e.target.checked)
|
||||
|
@ -51,14 +51,6 @@ function CommandBarSelectionInput({
|
||||
inputRef.current?.focus()
|
||||
}, [selection, inputRef])
|
||||
|
||||
// Exit engine's edit mode when this input step is active,
|
||||
// and re-enter it when it's not.
|
||||
// In future the engine's edit mode will go away and this will be handled differently.
|
||||
useEffect(() => {
|
||||
kclManager.exitEditMode()
|
||||
return () => kclManager.defaultSelectionFilter()
|
||||
}, [])
|
||||
|
||||
// Fast-forward through this arg if it's marked as skippable
|
||||
// and we have a valid selection already
|
||||
useEffect(() => {
|
||||
|
199
src/components/ContextMenu.tsx
Normal file
@ -0,0 +1,199 @@
|
||||
import toast from 'react-hot-toast'
|
||||
import { ActionIcon, ActionIconProps } from './ActionIcon'
|
||||
import { RefObject, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useHotkeys } from 'react-hotkeys-hook'
|
||||
import { Dialog } from '@headlessui/react'
|
||||
|
||||
interface ContextMenuProps
|
||||
extends Omit<React.HTMLAttributes<HTMLUListElement>, 'children'> {
|
||||
items?: React.ReactElement[]
|
||||
menuTargetElement?: RefObject<HTMLElement>
|
||||
}
|
||||
|
||||
const DefaultContextMenuItems = [
|
||||
<ContextMenuItemRefresh />,
|
||||
<ContextMenuItemCopy />,
|
||||
// add more default context menu items here
|
||||
]
|
||||
|
||||
export function ContextMenu({
|
||||
items = DefaultContextMenuItems,
|
||||
menuTargetElement,
|
||||
className,
|
||||
...props
|
||||
}: ContextMenuProps) {
|
||||
const dialogRef = useRef<HTMLDivElement>(null)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [windowSize, setWindowSize] = useState({
|
||||
width: globalThis?.window?.innerWidth,
|
||||
height: globalThis?.window?.innerHeight,
|
||||
})
|
||||
const [position, setPosition] = useState({ x: 0, y: 0 })
|
||||
useHotkeys('esc', () => setOpen(false), {
|
||||
enabled: open,
|
||||
})
|
||||
|
||||
const dialogPositionStyle = useMemo(() => {
|
||||
if (!dialogRef.current)
|
||||
return {
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 'auto',
|
||||
bottom: 'auto',
|
||||
}
|
||||
|
||||
return {
|
||||
top:
|
||||
position.y + dialogRef.current.clientHeight > windowSize.height
|
||||
? 'auto'
|
||||
: position.y,
|
||||
left:
|
||||
position.x + dialogRef.current.clientWidth > windowSize.width
|
||||
? 'auto'
|
||||
: position.x,
|
||||
right:
|
||||
position.x + dialogRef.current.clientWidth > windowSize.width
|
||||
? windowSize.width - position.x
|
||||
: 'auto',
|
||||
bottom:
|
||||
position.y + dialogRef.current.clientHeight > windowSize.height
|
||||
? windowSize.height - position.y
|
||||
: 'auto',
|
||||
}
|
||||
}, [position, windowSize, dialogRef.current])
|
||||
|
||||
// Listen for window resize to update context menu position
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
setWindowSize({
|
||||
width: globalThis?.window?.innerWidth,
|
||||
height: globalThis?.window?.innerHeight,
|
||||
})
|
||||
}
|
||||
globalThis?.window?.addEventListener('resize', handleResize)
|
||||
return () => {
|
||||
globalThis?.window?.removeEventListener('resize', handleResize)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Add context menu listener to target once mounted
|
||||
useEffect(() => {
|
||||
const handleContextMenu = (e: MouseEvent) => {
|
||||
console.log('context menu', e)
|
||||
e.preventDefault()
|
||||
setPosition({ x: e.x, y: e.y })
|
||||
setOpen(true)
|
||||
}
|
||||
menuTargetElement?.current?.addEventListener(
|
||||
'contextmenu',
|
||||
handleContextMenu
|
||||
)
|
||||
return () => {
|
||||
menuTargetElement?.current?.removeEventListener(
|
||||
'contextmenu',
|
||||
handleContextMenu
|
||||
)
|
||||
}
|
||||
}, [menuTargetElement?.current])
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={() => setOpen(false)}>
|
||||
<div
|
||||
className="fixed inset-0 z-50 w-screen h-screen"
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
>
|
||||
<Dialog.Backdrop className="fixed z-10 inset-0" />
|
||||
<Dialog.Panel
|
||||
ref={dialogRef}
|
||||
className={`w-48 fixed bg-chalkboard-10 dark:bg-chalkboard-90
|
||||
border border-solid border-chalkboard-10 dark:border-chalkboard-90 rounded
|
||||
shadow-lg backdrop:fixed backdrop:inset-0 backdrop:bg-primary ${className}`}
|
||||
style={{
|
||||
...dialogPositionStyle,
|
||||
...props.style,
|
||||
}}
|
||||
>
|
||||
<ul
|
||||
{...props}
|
||||
className="relative flex flex-col gap-0.5 items-stretch content-stretch"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
{...items}
|
||||
</ul>
|
||||
</Dialog.Panel>
|
||||
</div>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export function ContextMenuDivider() {
|
||||
return <hr className="border-chalkboard-20 dark:border-chalkboard-80" />
|
||||
}
|
||||
|
||||
interface ContextMenuItemProps {
|
||||
children: React.ReactNode
|
||||
icon?: ActionIconProps['icon']
|
||||
onClick?: () => void
|
||||
hotkey?: string
|
||||
}
|
||||
|
||||
export function ContextMenuItem({
|
||||
children,
|
||||
icon,
|
||||
onClick,
|
||||
hotkey,
|
||||
}: ContextMenuItemProps) {
|
||||
return (
|
||||
<button
|
||||
className="flex items-center gap-2 py-1 px-2 cursor-pointer hover:bg-chalkboard-20 dark:hover:bg-chalkboard-80 border-none text-left"
|
||||
onClick={onClick}
|
||||
>
|
||||
{icon && <ActionIcon icon={icon} bgClassName="!bg-transparent" />}
|
||||
<div className="flex-1">{children}</div>
|
||||
{hotkey && (
|
||||
<kbd className="px-1.5 py-0.5 rounded bg-primary/10 text-primary dark:bg-chalkboard-80 dark:text-chalkboard-40">
|
||||
{hotkey}
|
||||
</kbd>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export function ContextMenuItemRefresh() {
|
||||
return (
|
||||
<ContextMenuItem
|
||||
icon="arrowRotateRight"
|
||||
onClick={() => globalThis?.window?.location.reload()}
|
||||
>
|
||||
Refresh
|
||||
</ContextMenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
interface ContextMenuItemCopyProps {
|
||||
toBeCopiedContent?: string
|
||||
toBeCopiedLabel?: string
|
||||
}
|
||||
|
||||
export function ContextMenuItemCopy({
|
||||
toBeCopiedContent = globalThis.window?.getSelection()?.toString(),
|
||||
toBeCopiedLabel = 'selection',
|
||||
}: ContextMenuItemCopyProps) {
|
||||
return (
|
||||
<ContextMenuItem
|
||||
icon="clipboardPlus"
|
||||
onClick={() => {
|
||||
if (toBeCopiedContent) {
|
||||
globalThis?.navigator?.clipboard
|
||||
.writeText(toBeCopiedContent)
|
||||
.then(() => toast.success(`Copied ${toBeCopiedLabel} to clipboard`))
|
||||
.catch(() =>
|
||||
toast.error(`Failed to copy ${toBeCopiedLabel} to clipboard`)
|
||||
)
|
||||
}
|
||||
}}
|
||||
>
|
||||
Copy
|
||||
</ContextMenuItem>
|
||||
)
|
||||
}
|
@ -257,6 +257,14 @@ const CustomIconMap = {
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
keyboard: (
|
||||
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M16 12V15H13.5M16 12V9M16 12H13.5M4 12V15H6.5M4 12V9M4 12H6.5M4 9V6H6.5M4 9H6.5M16 9V6H13.5M16 9H13.5M6.5 12V15M6.5 12H7.5M6.5 15H13.5M13.5 15V12M13.5 12H12.5M7.5 12V9M7.5 12H10M7.5 9H8.75M7.5 9H6.5M10 12V9M10 12H12.5M10 9H11.25M10 9H8.75M12.5 12V9M12.5 9H13.5M12.5 9H11.25M13.5 9V6M13.5 6H11.25M11.25 9V6M11.25 6H8.75M8.75 9V6M8.75 6H6.5M6.5 9V6"
|
||||
stroke="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
line: (
|
||||
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
|
@ -18,6 +18,8 @@ import { useLspContext } from './LspProvider'
|
||||
import useHotkeyWrapper from 'lib/hotkeyWrapper'
|
||||
import { useModelingContext } from 'hooks/useModelingContext'
|
||||
import { DeleteConfirmationDialog } from './ProjectCard/DeleteProjectDialog'
|
||||
import { ContextMenu, ContextMenuItem } from './ContextMenu'
|
||||
import usePlatform from 'hooks/usePlatform'
|
||||
|
||||
function getIndentationCSS(level: number) {
|
||||
return `calc(1rem * ${level + 1})`
|
||||
@ -125,6 +127,7 @@ const FileTreeItem = ({
|
||||
const navigate = useNavigate()
|
||||
const [isConfirmingDelete, setIsConfirmingDelete] = useState(false)
|
||||
const isCurrentFile = fileOrDir.path === currentFile?.path
|
||||
const itemRef = useRef(null)
|
||||
|
||||
const isRenaming = fileContext.itemsBeingRenamed.includes(fileOrDir.path)
|
||||
const removeCurrentItemFromRenaming = useCallback(
|
||||
@ -185,7 +188,7 @@ const FileTreeItem = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="contents" ref={itemRef}>
|
||||
{fileOrDir.children === undefined ? (
|
||||
<li
|
||||
className={
|
||||
@ -321,7 +324,41 @@ const FileTreeItem = ({
|
||||
setIsOpen={setIsConfirmingDelete}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
<FileTreeContextMenu
|
||||
itemRef={itemRef}
|
||||
onRename={addCurrentItemToRenaming}
|
||||
onDelete={() => setIsConfirmingDelete(true)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface FileTreeContextMenuProps {
|
||||
itemRef: React.RefObject<HTMLElement>
|
||||
onRename: () => void
|
||||
onDelete: () => void
|
||||
}
|
||||
|
||||
function FileTreeContextMenu({
|
||||
itemRef,
|
||||
onRename,
|
||||
onDelete,
|
||||
}: FileTreeContextMenuProps) {
|
||||
const platform = usePlatform()
|
||||
const metaKey = platform === 'macos' ? '⌘' : 'Ctrl'
|
||||
|
||||
return (
|
||||
<ContextMenu
|
||||
menuTargetElement={itemRef}
|
||||
items={[
|
||||
<ContextMenuItem onClick={onRename} hotkey="Enter">
|
||||
Rename
|
||||
</ContextMenuItem>,
|
||||
<ContextMenuItem onClick={onDelete} hotkey={metaKey + ' + Del'}>
|
||||
Delete
|
||||
</ContextMenuItem>,
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { SceneInfra } from 'clientSideScene/sceneInfra'
|
||||
import { sceneInfra } from 'lib/singletons'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { MutableRefObject, useEffect, useRef } from 'react'
|
||||
import {
|
||||
WebGLRenderer,
|
||||
Scene,
|
||||
@ -12,21 +13,52 @@ import {
|
||||
Clock,
|
||||
Quaternion,
|
||||
ColorRepresentation,
|
||||
Vector2,
|
||||
Raycaster,
|
||||
Camera,
|
||||
Intersection,
|
||||
Object3D,
|
||||
} from 'three'
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuDivider,
|
||||
ContextMenuItem,
|
||||
ContextMenuItemRefresh,
|
||||
} from './ContextMenu'
|
||||
|
||||
const CANVAS_SIZE = 80
|
||||
const FRUSTUM_SIZE = 0.5
|
||||
const AXIS_LENGTH = 0.35
|
||||
const AXIS_WIDTH = 0.02
|
||||
const AXIS_COLORS = {
|
||||
x: '#fa6668',
|
||||
y: '#11eb6b',
|
||||
z: '#6689ef',
|
||||
gray: '#c6c7c2',
|
||||
enum AxisColors {
|
||||
X = '#fa6668',
|
||||
Y = '#11eb6b',
|
||||
Z = '#6689ef',
|
||||
Gray = '#c6c7c2',
|
||||
}
|
||||
enum AxisNames {
|
||||
X = 'x',
|
||||
Y = 'y',
|
||||
Z = 'z',
|
||||
NEG_X = '-x',
|
||||
NEG_Y = '-y',
|
||||
NEG_Z = '-z',
|
||||
}
|
||||
const axisNamesSemantic: Record<AxisNames, string> = {
|
||||
[AxisNames.X]: 'Right',
|
||||
[AxisNames.Y]: 'Back',
|
||||
[AxisNames.Z]: 'Top',
|
||||
[AxisNames.NEG_X]: 'Left',
|
||||
[AxisNames.NEG_Y]: 'Front',
|
||||
[AxisNames.NEG_Z]: 'Bottom',
|
||||
}
|
||||
|
||||
export default function Gizmo() {
|
||||
const wrapperRef = useRef<HTMLDivElement | null>(null)
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null)
|
||||
const raycasterIntersect = useRef<Intersection<Object3D> | null>(null)
|
||||
const cameraPassiveUpdateTimer = useRef(0)
|
||||
const raycasterPassiveUpdateTimer = useRef(0)
|
||||
|
||||
useEffect(() => {
|
||||
if (!canvasRef.current) return
|
||||
@ -41,35 +73,89 @@ export default function Gizmo() {
|
||||
const { gizmoAxes, gizmoAxisHeads } = createGizmo()
|
||||
scene.add(...gizmoAxes, ...gizmoAxisHeads)
|
||||
|
||||
const raycaster = new Raycaster()
|
||||
const { mouse, disposeMouseEvents } = initializeMouseEvents(
|
||||
canvas,
|
||||
raycasterIntersect,
|
||||
sceneInfra
|
||||
)
|
||||
const raycasterObjects = [...gizmoAxisHeads]
|
||||
|
||||
const clock = new Clock()
|
||||
const clientCamera = sceneInfra.camControls.camera
|
||||
let currentQuaternion = new Quaternion().copy(clientCamera.quaternion)
|
||||
|
||||
const animate = () => {
|
||||
requestAnimationFrame(animate)
|
||||
const delta = clock.getDelta()
|
||||
updateCameraOrientation(
|
||||
camera,
|
||||
currentQuaternion,
|
||||
sceneInfra.camControls.camera.quaternion,
|
||||
clock.getDelta()
|
||||
delta,
|
||||
cameraPassiveUpdateTimer
|
||||
)
|
||||
updateRayCaster(
|
||||
raycasterObjects,
|
||||
raycaster,
|
||||
mouse,
|
||||
camera,
|
||||
raycasterIntersect,
|
||||
delta,
|
||||
raycasterPassiveUpdateTimer
|
||||
)
|
||||
renderer.render(scene, camera)
|
||||
requestAnimationFrame(animate)
|
||||
}
|
||||
animate()
|
||||
|
||||
return () => {
|
||||
renderer.dispose()
|
||||
disposeMouseEvents()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="grid place-content-center rounded-full overflow-hidden border border-solid border-primary/50 pointer-events-none">
|
||||
<canvas ref={canvasRef} />
|
||||
</div>
|
||||
<>
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
aria-label="View orientation gizmo"
|
||||
className="grid place-content-center rounded-full overflow-hidden border border-solid border-primary/50 pointer-events-auto"
|
||||
>
|
||||
<canvas ref={canvasRef} />
|
||||
<ContextMenu
|
||||
menuTargetElement={wrapperRef}
|
||||
items={[
|
||||
...Object.entries(axisNamesSemantic).map(
|
||||
([axisName, axisSemantic]) => (
|
||||
<ContextMenuItem
|
||||
key={axisName}
|
||||
onClick={() => {
|
||||
sceneInfra.camControls.updateCameraToAxis(
|
||||
axisName as AxisNames
|
||||
)
|
||||
}}
|
||||
>
|
||||
{axisSemantic} view
|
||||
</ContextMenuItem>
|
||||
)
|
||||
),
|
||||
<ContextMenuItem
|
||||
onClick={() => {
|
||||
sceneInfra.camControls.resetCameraPosition()
|
||||
}}
|
||||
>
|
||||
Reset view
|
||||
</ContextMenuItem>,
|
||||
<ContextMenuDivider />,
|
||||
<ContextMenuItemRefresh />,
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const createCamera = () => {
|
||||
const createCamera = (): OrthographicCamera => {
|
||||
return new OrthographicCamera(
|
||||
-FRUSTUM_SIZE,
|
||||
FRUSTUM_SIZE,
|
||||
@ -82,21 +168,21 @@ const createCamera = () => {
|
||||
|
||||
const createGizmo = () => {
|
||||
const gizmoAxes = [
|
||||
createAxis(AXIS_LENGTH, AXIS_WIDTH, AXIS_COLORS.x, 0, 'z'),
|
||||
createAxis(AXIS_LENGTH, AXIS_WIDTH, AXIS_COLORS.y, Math.PI / 2, 'z'),
|
||||
createAxis(AXIS_LENGTH, AXIS_WIDTH, AXIS_COLORS.z, -Math.PI / 2, 'y'),
|
||||
createAxis(AXIS_LENGTH, AXIS_WIDTH, AXIS_COLORS.gray, Math.PI, 'z'),
|
||||
createAxis(AXIS_LENGTH, AXIS_WIDTH, AXIS_COLORS.gray, -Math.PI / 2, 'z'),
|
||||
createAxis(AXIS_LENGTH, AXIS_WIDTH, AXIS_COLORS.gray, Math.PI / 2, 'y'),
|
||||
createAxis(AXIS_LENGTH, AXIS_WIDTH, AxisColors.X, 0, 'z'),
|
||||
createAxis(AXIS_LENGTH, AXIS_WIDTH, AxisColors.Y, Math.PI / 2, 'z'),
|
||||
createAxis(AXIS_LENGTH, AXIS_WIDTH, AxisColors.Z, -Math.PI / 2, 'y'),
|
||||
createAxis(AXIS_LENGTH, AXIS_WIDTH, AxisColors.Gray, Math.PI, 'z'),
|
||||
createAxis(AXIS_LENGTH, AXIS_WIDTH, AxisColors.Gray, -Math.PI / 2, 'z'),
|
||||
createAxis(AXIS_LENGTH, AXIS_WIDTH, AxisColors.Gray, Math.PI / 2, 'y'),
|
||||
]
|
||||
|
||||
const gizmoAxisHeads = [
|
||||
createAxisHead(AXIS_LENGTH, AXIS_COLORS.x, 0, 'z'),
|
||||
createAxisHead(AXIS_LENGTH, AXIS_COLORS.y, Math.PI / 2, 'z'),
|
||||
createAxisHead(AXIS_LENGTH, AXIS_COLORS.z, -Math.PI / 2, 'y'),
|
||||
createAxisHead(AXIS_LENGTH, AXIS_COLORS.gray, Math.PI, 'z'),
|
||||
createAxisHead(AXIS_LENGTH, AXIS_COLORS.gray, -Math.PI / 2, 'z'),
|
||||
createAxisHead(AXIS_LENGTH, AXIS_COLORS.gray, Math.PI / 2, 'y'),
|
||||
createAxisHead(AxisNames.X, AxisColors.X, [AXIS_LENGTH, 0, 0]),
|
||||
createAxisHead(AxisNames.Y, AxisColors.Y, [0, AXIS_LENGTH, 0]),
|
||||
createAxisHead(AxisNames.Z, AxisColors.Z, [0, 0, AXIS_LENGTH]),
|
||||
createAxisHead(AxisNames.NEG_X, AxisColors.Gray, [-AXIS_LENGTH, 0, 0]),
|
||||
createAxisHead(AxisNames.NEG_Y, AxisColors.Gray, [0, -AXIS_LENGTH, 0]),
|
||||
createAxisHead(AxisNames.NEG_Z, AxisColors.Gray, [0, 0, -AXIS_LENGTH]),
|
||||
]
|
||||
|
||||
return { gizmoAxes, gizmoAxisHeads }
|
||||
@ -108,12 +194,9 @@ const createAxis = (
|
||||
color: ColorRepresentation,
|
||||
rotation = 0,
|
||||
axis = 'x'
|
||||
) => {
|
||||
const geometry = new BoxGeometry(length, width, width).translate(
|
||||
length / 2,
|
||||
0,
|
||||
0
|
||||
)
|
||||
): Mesh => {
|
||||
const geometry = new BoxGeometry(length, width, width)
|
||||
geometry.translate(length / 2, 0, 0)
|
||||
const material = new MeshBasicMaterial({ color: new Color(color) })
|
||||
const mesh = new Mesh(geometry, material)
|
||||
mesh.rotation[axis as 'x' | 'y' | 'z'] = rotation
|
||||
@ -121,15 +204,17 @@ const createAxis = (
|
||||
}
|
||||
|
||||
const createAxisHead = (
|
||||
length: number,
|
||||
name: AxisNames,
|
||||
color: ColorRepresentation,
|
||||
rotation = 0,
|
||||
axis = 'x'
|
||||
) => {
|
||||
const geometry = new SphereGeometry(0.065, 16, 8).translate(length, 0, 0)
|
||||
position: number[]
|
||||
): Mesh => {
|
||||
const geometry = new SphereGeometry(0.065, 16, 8)
|
||||
const material = new MeshBasicMaterial({ color: new Color(color) })
|
||||
const mesh = new Mesh(geometry, material)
|
||||
mesh.rotation[axis as 'x' | 'y' | 'z'] = rotation
|
||||
|
||||
mesh.position.set(position[0], position[1], position[2])
|
||||
mesh.updateMatrixWorld()
|
||||
mesh.name = name
|
||||
return mesh
|
||||
}
|
||||
|
||||
@ -137,10 +222,97 @@ const updateCameraOrientation = (
|
||||
camera: OrthographicCamera,
|
||||
currentQuaternion: Quaternion,
|
||||
targetQuaternion: Quaternion,
|
||||
deltaTime: number
|
||||
deltaTime: number,
|
||||
cameraPassiveUpdateTimer: MutableRefObject<number>
|
||||
) => {
|
||||
const slerpFactor = 1 - Math.exp(-30 * deltaTime)
|
||||
currentQuaternion.slerp(targetQuaternion, slerpFactor).normalize()
|
||||
camera.position.set(0, 0, 1).applyQuaternion(currentQuaternion)
|
||||
camera.quaternion.copy(currentQuaternion)
|
||||
cameraPassiveUpdateTimer.current += deltaTime
|
||||
if (
|
||||
!quaternionsEqual(currentQuaternion, targetQuaternion) ||
|
||||
cameraPassiveUpdateTimer.current >= 5
|
||||
) {
|
||||
const slerpFactor = 1 - Math.exp(-30 * deltaTime)
|
||||
currentQuaternion.slerp(targetQuaternion, slerpFactor).normalize()
|
||||
camera.position.set(0, 0, 1).applyQuaternion(currentQuaternion)
|
||||
camera.quaternion.copy(currentQuaternion)
|
||||
cameraPassiveUpdateTimer.current = 0
|
||||
}
|
||||
}
|
||||
|
||||
const quaternionsEqual = (
|
||||
q1: Quaternion,
|
||||
q2: Quaternion,
|
||||
tolerance: number = 0.001
|
||||
): boolean => {
|
||||
return (
|
||||
Math.abs(q1.x - q2.x) < tolerance &&
|
||||
Math.abs(q1.y - q2.y) < tolerance &&
|
||||
Math.abs(q1.z - q2.z) < tolerance &&
|
||||
Math.abs(q1.w - q2.w) < tolerance
|
||||
)
|
||||
}
|
||||
|
||||
const initializeMouseEvents = (
|
||||
canvas: HTMLCanvasElement,
|
||||
raycasterIntersect: MutableRefObject<Intersection<Object3D> | null>,
|
||||
sceneInfra: SceneInfra
|
||||
): { mouse: Vector2; disposeMouseEvents: () => void } => {
|
||||
const mouse = new Vector2()
|
||||
mouse.x = 1 // fix initial mouse position issue
|
||||
|
||||
const handleMouseMove = (event: MouseEvent) => {
|
||||
const { left, top, width, height } = canvas.getBoundingClientRect()
|
||||
mouse.x = ((event.clientX - left) / width) * 2 - 1
|
||||
mouse.y = ((event.clientY - top) / height) * -2 + 1
|
||||
}
|
||||
|
||||
const handleClick = () => {
|
||||
if (raycasterIntersect.current) {
|
||||
const axisName = raycasterIntersect.current.object.name as AxisNames
|
||||
sceneInfra.camControls.updateCameraToAxis(axisName)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('mousemove', handleMouseMove)
|
||||
window.addEventListener('click', handleClick)
|
||||
|
||||
const disposeMouseEvents = () => {
|
||||
window.removeEventListener('mousemove', handleMouseMove)
|
||||
window.removeEventListener('click', handleClick)
|
||||
}
|
||||
|
||||
return { mouse, disposeMouseEvents }
|
||||
}
|
||||
|
||||
const updateRayCaster = (
|
||||
objects: Object3D[],
|
||||
raycaster: Raycaster,
|
||||
mouse: Vector2,
|
||||
camera: Camera,
|
||||
raycasterIntersect: MutableRefObject<Intersection<Object3D> | null>,
|
||||
deltaTime: number,
|
||||
raycasterPassiveUpdateTimer: MutableRefObject<number>
|
||||
) => {
|
||||
raycasterPassiveUpdateTimer.current += deltaTime
|
||||
|
||||
// check if mouse is outside the canvas bounds and stop raycaster
|
||||
if (raycasterPassiveUpdateTimer.current < 2) {
|
||||
if (mouse.x < -1 || mouse.x > 1 || mouse.y < -1 || mouse.y > 1) {
|
||||
raycasterIntersect.current = null
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
raycaster.setFromCamera(mouse, camera)
|
||||
const intersects = raycaster.intersectObjects(objects)
|
||||
|
||||
objects.forEach((object) => object.scale.set(1, 1, 1))
|
||||
if (intersects.length) {
|
||||
intersects[0].object.scale.set(1.5, 1.5, 1.5)
|
||||
raycasterIntersect.current = intersects[0] // filter first object
|
||||
} else {
|
||||
raycasterIntersect.current = null
|
||||
}
|
||||
if (raycasterPassiveUpdateTimer.current > 2) {
|
||||
raycasterPassiveUpdateTimer.current = 0
|
||||
}
|
||||
}
|
||||
|
@ -81,6 +81,12 @@ export function HelpMenu(props: React.PropsWithChildren) {
|
||||
>
|
||||
Release notes
|
||||
</HelpMenuItem>
|
||||
<HelpMenuItem
|
||||
as="button"
|
||||
onClick={() => navigate('settings?tab=keybindings')}
|
||||
>
|
||||
Keyboard shortcuts
|
||||
</HelpMenuItem>
|
||||
<HelpMenuItem
|
||||
as="button"
|
||||
onClick={() => {
|
||||
|
@ -1,14 +1,65 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import {
|
||||
EngineConnectionStateType,
|
||||
DisconnectingType,
|
||||
EngineCommandManagerEvents,
|
||||
EngineConnectionEvents,
|
||||
ConnectionError,
|
||||
CONNECTION_ERROR_TEXT,
|
||||
} from '../lang/std/engineConnection'
|
||||
|
||||
import { engineCommandManager } from '../lib/singletons'
|
||||
|
||||
const Loading = ({ children }: React.PropsWithChildren) => {
|
||||
const [hasLongLoadTime, setHasLongLoadTime] = useState(false)
|
||||
const [error, setError] = useState<ConnectionError>(ConnectionError.Unset)
|
||||
|
||||
useEffect(() => {
|
||||
const onConnectionStateChange = ({ detail: state }: CustomEvent) => {
|
||||
if (
|
||||
(state.type !== EngineConnectionStateType.Disconnected ||
|
||||
state.type !== EngineConnectionStateType.Disconnecting) &&
|
||||
state.value?.type !== DisconnectingType.Error
|
||||
)
|
||||
return
|
||||
setError(state.value.value.error)
|
||||
}
|
||||
|
||||
const onEngineAvailable = ({ detail: engineConnection }: CustomEvent) => {
|
||||
engineConnection.addEventListener(
|
||||
EngineConnectionEvents.ConnectionStateChanged,
|
||||
onConnectionStateChange as EventListener
|
||||
)
|
||||
}
|
||||
|
||||
engineCommandManager.addEventListener(
|
||||
EngineCommandManagerEvents.EngineAvailable,
|
||||
onEngineAvailable as EventListener
|
||||
)
|
||||
|
||||
return () => {
|
||||
engineCommandManager.removeEventListener(
|
||||
EngineCommandManagerEvents.EngineAvailable,
|
||||
onEngineAvailable as EventListener
|
||||
)
|
||||
engineCommandManager.engineConnection?.removeEventListener(
|
||||
EngineConnectionEvents.ConnectionStateChanged,
|
||||
onConnectionStateChange as EventListener
|
||||
)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
// Don't set long loading time if there's a more severe error
|
||||
if (error > ConnectionError.LongLoadingTime) return
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
setHasLongLoadTime(true)
|
||||
setError(ConnectionError.LongLoadingTime)
|
||||
}, 4000)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}, [setHasLongLoadTime])
|
||||
}, [error, setError])
|
||||
|
||||
return (
|
||||
<div
|
||||
className="body-bg flex flex-col items-center justify-center h-screen"
|
||||
@ -29,10 +80,10 @@ const Loading = ({ children }: React.PropsWithChildren) => {
|
||||
<p
|
||||
className={
|
||||
'text-sm mt-4 text-primary/60 transition-opacity duration-500' +
|
||||
(hasLongLoadTime ? ' opacity-100' : ' opacity-0')
|
||||
(error !== ConnectionError.Unset ? ' opacity-100' : ' opacity-0')
|
||||
}
|
||||
>
|
||||
Loading is taking longer than expected.
|
||||
{CONNECTION_ERROR_TEXT[error]}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
|
@ -13,7 +13,6 @@ import { LanguageSupport } from '@codemirror/language'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { paths } from 'lib/paths'
|
||||
import { FileEntry } from 'lib/types'
|
||||
import { NetworkHealthState, useNetworkStatus } from './NetworkHealthIndicator'
|
||||
import Worker from 'editor/plugins/lsp/worker.ts?worker'
|
||||
import {
|
||||
LspWorkerEventType,
|
||||
@ -23,6 +22,8 @@ import {
|
||||
} from 'editor/plugins/lsp/types'
|
||||
import { wasmUrl } from 'lang/wasm'
|
||||
import { PROJECT_ENTRYPOINT } from 'lib/constants'
|
||||
import { useNetworkContext } from 'hooks/useNetworkContext'
|
||||
import { NetworkHealthState } from 'hooks/useNetworkStatus'
|
||||
|
||||
function getWorkspaceFolders(): LSP.WorkspaceFolder[] {
|
||||
return []
|
||||
@ -86,7 +87,7 @@ export const LspProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
} = useSettingsAuthContext()
|
||||
const token = auth?.context.token
|
||||
const navigate = useNavigate()
|
||||
const { overallState } = useNetworkStatus()
|
||||
const { overallState } = useNetworkContext()
|
||||
const isNetworkOkay = overallState === NetworkHealthState.Ok
|
||||
|
||||
// So this is a bit weird, we need to initialize the lsp server and client.
|
||||
|
@ -11,7 +11,10 @@ import {
|
||||
import { SetSelections, modelingMachine } from 'machines/modelingMachine'
|
||||
import { useSetupEngineManager } from 'hooks/useSetupEngineManager'
|
||||
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
|
||||
import { isCursorInSketchCommandRange } from 'lang/util'
|
||||
import {
|
||||
isCursorInSketchCommandRange,
|
||||
updatePathToNodeFromMap,
|
||||
} from 'lang/util'
|
||||
import {
|
||||
kclManager,
|
||||
sceneInfra,
|
||||
@ -150,7 +153,7 @@ export const ModelingMachineProvider = ({
|
||||
])
|
||||
const pathToNode = parent?.userData?.pathToNode
|
||||
const pathToNodeString = JSON.stringify(pathToNode)
|
||||
if (!parent || !pathToNode) return {}
|
||||
if (!parent || !pathToNode) return segmentHoverMap
|
||||
if (segmentHoverMap[pathToNodeString] !== undefined)
|
||||
clearTimeout(segmentHoverMap[JSON.stringify(pathToNode)])
|
||||
return {
|
||||
@ -218,9 +221,8 @@ export const ModelingMachineProvider = ({
|
||||
}
|
||||
: {}
|
||||
),
|
||||
'Set selection': assign(({ selectionRanges }, event) => {
|
||||
if (event.type !== 'Set selection') return {} // this was needed for ts after adding 'Set selection' action to on done modal events
|
||||
const setSelections = event.data
|
||||
'Set selection': assign(({ selectionRanges, sketchDetails }, event) => {
|
||||
const setSelections = event.data as SetSelections // this was needed for ts after adding 'Set selection' action to on done modal events
|
||||
if (!editorManager.editorView) return {}
|
||||
const dispatchSelection = (selection?: EditorSelection) => {
|
||||
if (!selection) return // TODO less of hack for the below please
|
||||
@ -307,11 +309,29 @@ export const ModelingMachineProvider = ({
|
||||
selectionRanges: selections,
|
||||
}
|
||||
}
|
||||
if (setSelections.selectionType === 'completeSelection') {
|
||||
editorManager.selectRange(setSelections.selection)
|
||||
if (!sketchDetails)
|
||||
return {
|
||||
selectionRanges: setSelections.selection,
|
||||
}
|
||||
return {
|
||||
selectionRanges: setSelections.selection,
|
||||
sketchDetails: {
|
||||
...sketchDetails,
|
||||
sketchPathToNode:
|
||||
setSelections.updatedPathToNode ||
|
||||
sketchDetails?.sketchPathToNode ||
|
||||
[],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return {}
|
||||
}),
|
||||
'Engine export': (_, event) => {
|
||||
'Engine export': async (_, event) => {
|
||||
if (event.type !== 'Export' || TEST) return
|
||||
console.log('exporting', event.data)
|
||||
const format = {
|
||||
...event.data,
|
||||
} as Partial<Models['OutputFormat_type']>
|
||||
@ -355,9 +375,16 @@ export const ModelingMachineProvider = ({
|
||||
format.selection = { type: 'default_scene' }
|
||||
}
|
||||
|
||||
exportFromEngine({
|
||||
format: format as Models['OutputFormat_type'],
|
||||
}).catch((e) => toast.error('Error while exporting', e)) // TODO I think we need to throw the error from engineCommandManager
|
||||
toast.promise(
|
||||
exportFromEngine({
|
||||
format: format as Models['OutputFormat_type'],
|
||||
}),
|
||||
{
|
||||
loading: 'Exporting...',
|
||||
success: 'Exported successfully',
|
||||
error: 'Error while exporting',
|
||||
}
|
||||
)
|
||||
},
|
||||
},
|
||||
guards: {
|
||||
@ -426,7 +453,8 @@ export const ModelingMachineProvider = ({
|
||||
const { modifiedAst, pathToNode: pathToNewSketchNode } =
|
||||
sketchOnExtrudedFace(
|
||||
kclManager.ast,
|
||||
data.extrudeSegmentPathToNode,
|
||||
data.sketchPathToNode,
|
||||
data.extrudePathToNode,
|
||||
kclManager.programMemory,
|
||||
data.cap
|
||||
)
|
||||
@ -481,13 +509,26 @@ export const ModelingMachineProvider = ({
|
||||
},
|
||||
'Get horizontal info': async ({
|
||||
selectionRanges,
|
||||
sketchDetails,
|
||||
}): Promise<SetSelections> => {
|
||||
const { modifiedAst, pathToNodeMap } =
|
||||
await applyConstraintHorzVertDistance({
|
||||
constraint: 'setHorzDistance',
|
||||
selectionRanges,
|
||||
})
|
||||
await kclManager.updateAst(modifiedAst, true)
|
||||
const _modifiedAst = parse(recast(modifiedAst))
|
||||
if (!sketchDetails) throw new Error('No sketch details')
|
||||
const updatedPathToNode = updatePathToNodeFromMap(
|
||||
sketchDetails.sketchPathToNode,
|
||||
pathToNodeMap
|
||||
)
|
||||
await sceneEntitiesManager.updateAstAndRejigSketch(
|
||||
updatedPathToNode,
|
||||
_modifiedAst,
|
||||
sketchDetails.zAxis,
|
||||
sketchDetails.yAxis,
|
||||
sketchDetails.origin
|
||||
)
|
||||
return {
|
||||
selectionType: 'completeSelection',
|
||||
selection: pathMapToSelections(
|
||||
@ -495,17 +536,31 @@ export const ModelingMachineProvider = ({
|
||||
selectionRanges,
|
||||
pathToNodeMap
|
||||
),
|
||||
updatedPathToNode,
|
||||
}
|
||||
},
|
||||
'Get vertical info': async ({
|
||||
selectionRanges,
|
||||
sketchDetails,
|
||||
}): Promise<SetSelections> => {
|
||||
const { modifiedAst, pathToNodeMap } =
|
||||
await applyConstraintHorzVertDistance({
|
||||
constraint: 'setVertDistance',
|
||||
selectionRanges,
|
||||
})
|
||||
await kclManager.updateAst(modifiedAst, true)
|
||||
const _modifiedAst = parse(recast(modifiedAst))
|
||||
if (!sketchDetails) throw new Error('No sketch details')
|
||||
const updatedPathToNode = updatePathToNodeFromMap(
|
||||
sketchDetails.sketchPathToNode,
|
||||
pathToNodeMap
|
||||
)
|
||||
await sceneEntitiesManager.updateAstAndRejigSketch(
|
||||
updatedPathToNode,
|
||||
_modifiedAst,
|
||||
sketchDetails.zAxis,
|
||||
sketchDetails.yAxis,
|
||||
sketchDetails.origin
|
||||
)
|
||||
return {
|
||||
selectionType: 'completeSelection',
|
||||
selection: pathMapToSelections(
|
||||
@ -513,10 +568,12 @@ export const ModelingMachineProvider = ({
|
||||
selectionRanges,
|
||||
pathToNodeMap
|
||||
),
|
||||
updatedPathToNode,
|
||||
}
|
||||
},
|
||||
'Get angle info': async ({
|
||||
selectionRanges,
|
||||
sketchDetails,
|
||||
}): Promise<SetSelections> => {
|
||||
const { modifiedAst, pathToNodeMap } = await (angleBetweenInfo({
|
||||
selectionRanges,
|
||||
@ -528,22 +585,48 @@ export const ModelingMachineProvider = ({
|
||||
selectionRanges,
|
||||
angleOrLength: 'setAngle',
|
||||
}))
|
||||
await kclManager.updateAst(modifiedAst, true)
|
||||
const _modifiedAst = parse(recast(modifiedAst))
|
||||
if (!sketchDetails) throw new Error('No sketch details')
|
||||
const updatedPathToNode = updatePathToNodeFromMap(
|
||||
sketchDetails.sketchPathToNode,
|
||||
pathToNodeMap
|
||||
)
|
||||
await sceneEntitiesManager.updateAstAndRejigSketch(
|
||||
updatedPathToNode,
|
||||
_modifiedAst,
|
||||
sketchDetails.zAxis,
|
||||
sketchDetails.yAxis,
|
||||
sketchDetails.origin
|
||||
)
|
||||
return {
|
||||
selectionType: 'completeSelection',
|
||||
selection: pathMapToSelections(
|
||||
kclManager.ast,
|
||||
_modifiedAst,
|
||||
selectionRanges,
|
||||
pathToNodeMap
|
||||
),
|
||||
updatedPathToNode,
|
||||
}
|
||||
},
|
||||
'Get length info': async ({
|
||||
selectionRanges,
|
||||
sketchDetails,
|
||||
}): Promise<SetSelections> => {
|
||||
const { modifiedAst, pathToNodeMap } =
|
||||
await applyConstraintAngleLength({ selectionRanges })
|
||||
await kclManager.updateAst(modifiedAst, true)
|
||||
const _modifiedAst = parse(recast(modifiedAst))
|
||||
if (!sketchDetails) throw new Error('No sketch details')
|
||||
const updatedPathToNode = updatePathToNodeFromMap(
|
||||
sketchDetails.sketchPathToNode,
|
||||
pathToNodeMap
|
||||
)
|
||||
await sceneEntitiesManager.updateAstAndRejigSketch(
|
||||
updatedPathToNode,
|
||||
_modifiedAst,
|
||||
sketchDetails.zAxis,
|
||||
sketchDetails.yAxis,
|
||||
sketchDetails.origin
|
||||
)
|
||||
return {
|
||||
selectionType: 'completeSelection',
|
||||
selection: pathMapToSelections(
|
||||
@ -551,17 +634,31 @@ export const ModelingMachineProvider = ({
|
||||
selectionRanges,
|
||||
pathToNodeMap
|
||||
),
|
||||
updatedPathToNode,
|
||||
}
|
||||
},
|
||||
'Get perpendicular distance info': async ({
|
||||
selectionRanges,
|
||||
sketchDetails,
|
||||
}): Promise<SetSelections> => {
|
||||
const { modifiedAst, pathToNodeMap } = await applyConstraintIntersect(
|
||||
{
|
||||
selectionRanges,
|
||||
}
|
||||
)
|
||||
await kclManager.updateAst(modifiedAst, true)
|
||||
const _modifiedAst = parse(recast(modifiedAst))
|
||||
if (!sketchDetails) throw new Error('No sketch details')
|
||||
const updatedPathToNode = updatePathToNodeFromMap(
|
||||
sketchDetails.sketchPathToNode,
|
||||
pathToNodeMap
|
||||
)
|
||||
await sceneEntitiesManager.updateAstAndRejigSketch(
|
||||
updatedPathToNode,
|
||||
_modifiedAst,
|
||||
sketchDetails.zAxis,
|
||||
sketchDetails.yAxis,
|
||||
sketchDetails.origin
|
||||
)
|
||||
return {
|
||||
selectionType: 'completeSelection',
|
||||
selection: pathMapToSelections(
|
||||
@ -569,17 +666,31 @@ export const ModelingMachineProvider = ({
|
||||
selectionRanges,
|
||||
pathToNodeMap
|
||||
),
|
||||
updatedPathToNode,
|
||||
}
|
||||
},
|
||||
'Get ABS X info': async ({
|
||||
selectionRanges,
|
||||
sketchDetails,
|
||||
}): Promise<SetSelections> => {
|
||||
const { modifiedAst, pathToNodeMap } =
|
||||
await applyConstraintAbsDistance({
|
||||
constraint: 'xAbs',
|
||||
selectionRanges,
|
||||
})
|
||||
await kclManager.updateAst(modifiedAst, true)
|
||||
const _modifiedAst = parse(recast(modifiedAst))
|
||||
if (!sketchDetails) throw new Error('No sketch details')
|
||||
const updatedPathToNode = updatePathToNodeFromMap(
|
||||
sketchDetails.sketchPathToNode,
|
||||
pathToNodeMap
|
||||
)
|
||||
await sceneEntitiesManager.updateAstAndRejigSketch(
|
||||
updatedPathToNode,
|
||||
_modifiedAst,
|
||||
sketchDetails.zAxis,
|
||||
sketchDetails.yAxis,
|
||||
sketchDetails.origin
|
||||
)
|
||||
return {
|
||||
selectionType: 'completeSelection',
|
||||
selection: pathMapToSelections(
|
||||
@ -587,17 +698,31 @@ export const ModelingMachineProvider = ({
|
||||
selectionRanges,
|
||||
pathToNodeMap
|
||||
),
|
||||
updatedPathToNode,
|
||||
}
|
||||
},
|
||||
'Get ABS Y info': async ({
|
||||
selectionRanges,
|
||||
sketchDetails,
|
||||
}): Promise<SetSelections> => {
|
||||
const { modifiedAst, pathToNodeMap } =
|
||||
await applyConstraintAbsDistance({
|
||||
constraint: 'yAbs',
|
||||
selectionRanges,
|
||||
})
|
||||
await kclManager.updateAst(modifiedAst, true)
|
||||
const _modifiedAst = parse(recast(modifiedAst))
|
||||
if (!sketchDetails) throw new Error('No sketch details')
|
||||
const updatedPathToNode = updatePathToNodeFromMap(
|
||||
sketchDetails.sketchPathToNode,
|
||||
pathToNodeMap
|
||||
)
|
||||
await sceneEntitiesManager.updateAstAndRejigSketch(
|
||||
updatedPathToNode,
|
||||
_modifiedAst,
|
||||
sketchDetails.zAxis,
|
||||
sketchDetails.yAxis,
|
||||
sketchDetails.origin
|
||||
)
|
||||
return {
|
||||
selectionType: 'completeSelection',
|
||||
selection: pathMapToSelections(
|
||||
@ -605,6 +730,7 @@ export const ModelingMachineProvider = ({
|
||||
selectionRanges,
|
||||
pathToNodeMap
|
||||
),
|
||||
updatedPathToNode,
|
||||
}
|
||||
},
|
||||
'Get convert to variable info': async ({ sketchDetails }, { data }) => {
|
||||
@ -658,6 +784,19 @@ export const ModelingMachineProvider = ({
|
||||
editorManager.selectionRanges = modelingState.context.selectionRanges
|
||||
}, [modelingState.context.selectionRanges])
|
||||
|
||||
useEffect(() => {
|
||||
const offlineCallback = () => {
|
||||
// If we are in sketch mode we need to exit it.
|
||||
// TODO: how do i check if we are in a sketch mode, I only want to call
|
||||
// this then.
|
||||
modelingSend({ type: 'Cancel' })
|
||||
}
|
||||
window.addEventListener('offline', offlineCallback)
|
||||
return () => {
|
||||
window.removeEventListener('offline', offlineCallback)
|
||||
}
|
||||
}, [modelingSend])
|
||||
|
||||
useStateMachineCommands({
|
||||
machineId: 'modeling',
|
||||
state: modelingState,
|
||||
|
@ -5,8 +5,8 @@ import { CommandBarProvider } from './CommandBar/CommandBarProvider'
|
||||
import {
|
||||
NETWORK_HEALTH_TEXT,
|
||||
NetworkHealthIndicator,
|
||||
NetworkHealthState,
|
||||
} from './NetworkHealthIndicator'
|
||||
import { NetworkHealthState } from 'hooks/useNetworkStatus'
|
||||
|
||||
function TestWrap({ children }: { children: React.ReactNode }) {
|
||||
// wrap in router and xState context
|
||||
@ -19,6 +19,7 @@ function TestWrap({ children }: { children: React.ReactNode }) {
|
||||
)
|
||||
}
|
||||
|
||||
// Our Playwright tests for this are much more comprehensive.
|
||||
describe('NetworkHealthIndicator tests', () => {
|
||||
test('Renders the network indicator', () => {
|
||||
render(
|
||||
@ -29,21 +30,7 @@ describe('NetworkHealthIndicator tests', () => {
|
||||
|
||||
fireEvent.click(screen.getByTestId('network-toggle'))
|
||||
|
||||
expect(screen.getByTestId('network')).toHaveTextContent(
|
||||
NETWORK_HEALTH_TEXT[NetworkHealthState.Ok]
|
||||
)
|
||||
})
|
||||
|
||||
test('Responds to network changes', () => {
|
||||
render(
|
||||
<TestWrap>
|
||||
<NetworkHealthIndicator />
|
||||
</TestWrap>
|
||||
)
|
||||
|
||||
fireEvent.offline(window)
|
||||
fireEvent.click(screen.getByTestId('network-toggle'))
|
||||
|
||||
// Starts as disconnected
|
||||
expect(screen.getByTestId('network')).toHaveTextContent(
|
||||
NETWORK_HEALTH_TEXT[NetworkHealthState.Disconnected]
|
||||
)
|
||||
|
@ -1,26 +1,13 @@
|
||||
import { Popover } from '@headlessui/react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { ActionIcon, ActionIconProps } from './ActionIcon'
|
||||
import {
|
||||
ConnectingType,
|
||||
ConnectingTypeGroup,
|
||||
DisconnectingType,
|
||||
EngineConnectionState,
|
||||
EngineConnectionStateType,
|
||||
ErrorType,
|
||||
initialConnectingTypeGroupState,
|
||||
} from '../lang/std/engineConnection'
|
||||
import { engineCommandManager } from '../lib/singletons'
|
||||
import Tooltip from './Tooltip'
|
||||
|
||||
export enum NetworkHealthState {
|
||||
Ok,
|
||||
Issue,
|
||||
Disconnected,
|
||||
}
|
||||
import { ConnectingTypeGroup } from '../lang/std/engineConnection'
|
||||
import { useNetworkContext } from '../hooks/useNetworkContext'
|
||||
import { NetworkHealthState } from '../hooks/useNetworkStatus'
|
||||
|
||||
export const NETWORK_HEALTH_TEXT: Record<NetworkHealthState, string> = {
|
||||
[NetworkHealthState.Ok]: 'Connected',
|
||||
[NetworkHealthState.Weak]: 'Weak',
|
||||
[NetworkHealthState.Issue]: 'Problem',
|
||||
[NetworkHealthState.Disconnected]: 'Offline',
|
||||
}
|
||||
@ -61,6 +48,10 @@ const overallConnectionStateColor: Record<NetworkHealthState, IconColorConfig> =
|
||||
icon: 'text-succeed-80 dark:text-succeed-10',
|
||||
bg: 'bg-succeed-10/30 dark:bg-succeed-80/50',
|
||||
},
|
||||
[NetworkHealthState.Weak]: {
|
||||
icon: 'text-warn-80 dark:text-warn-10',
|
||||
bg: 'bg-warn-10 dark:bg-warn-80/80',
|
||||
},
|
||||
[NetworkHealthState.Issue]: {
|
||||
icon: 'text-destroy-80 dark:text-destroy-10',
|
||||
bg: 'bg-destroy-10 dark:bg-destroy-80/80',
|
||||
@ -76,125 +67,11 @@ const overallConnectionStateIcon: Record<
|
||||
ActionIconProps['icon']
|
||||
> = {
|
||||
[NetworkHealthState.Ok]: 'network',
|
||||
[NetworkHealthState.Weak]: 'network',
|
||||
[NetworkHealthState.Issue]: 'networkCrossedOut',
|
||||
[NetworkHealthState.Disconnected]: 'networkCrossedOut',
|
||||
}
|
||||
|
||||
export function useNetworkStatus() {
|
||||
const [steps, setSteps] = useState(initialConnectingTypeGroupState)
|
||||
const [internetConnected, setInternetConnected] = useState<boolean>(true)
|
||||
const [overallState, setOverallState] = useState<NetworkHealthState>(
|
||||
NetworkHealthState.Ok
|
||||
)
|
||||
const [hasCopied, setHasCopied] = useState<boolean>(false)
|
||||
|
||||
const [error, setError] = useState<ErrorType | undefined>(undefined)
|
||||
|
||||
const issues: Record<ConnectingTypeGroup, boolean> = {
|
||||
[ConnectingTypeGroup.WebSocket]: steps[ConnectingTypeGroup.WebSocket].some(
|
||||
(a: [ConnectingType, boolean | undefined]) => a[1] === false
|
||||
),
|
||||
[ConnectingTypeGroup.ICE]: steps[ConnectingTypeGroup.ICE].some(
|
||||
(a: [ConnectingType, boolean | undefined]) => a[1] === false
|
||||
),
|
||||
[ConnectingTypeGroup.WebRTC]: steps[ConnectingTypeGroup.WebRTC].some(
|
||||
(a: [ConnectingType, boolean | undefined]) => a[1] === false
|
||||
),
|
||||
}
|
||||
|
||||
const hasIssues: boolean =
|
||||
issues[ConnectingTypeGroup.WebSocket] ||
|
||||
issues[ConnectingTypeGroup.ICE] ||
|
||||
issues[ConnectingTypeGroup.WebRTC]
|
||||
|
||||
useEffect(() => {
|
||||
setOverallState(
|
||||
!internetConnected
|
||||
? NetworkHealthState.Disconnected
|
||||
: hasIssues
|
||||
? NetworkHealthState.Issue
|
||||
: NetworkHealthState.Ok
|
||||
)
|
||||
}, [hasIssues, internetConnected])
|
||||
|
||||
useEffect(() => {
|
||||
const onlineCallback = () => {
|
||||
setSteps(initialConnectingTypeGroupState)
|
||||
setInternetConnected(true)
|
||||
}
|
||||
const offlineCallback = () => {
|
||||
setInternetConnected(false)
|
||||
}
|
||||
window.addEventListener('online', onlineCallback)
|
||||
window.addEventListener('offline', offlineCallback)
|
||||
return () => {
|
||||
window.removeEventListener('online', onlineCallback)
|
||||
window.removeEventListener('offline', offlineCallback)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
engineCommandManager.onConnectionStateChange(
|
||||
(engineConnectionState: EngineConnectionState) => {
|
||||
let hasSetAStep = false
|
||||
|
||||
if (
|
||||
engineConnectionState.type === EngineConnectionStateType.Connecting
|
||||
) {
|
||||
const groups = Object.values(steps)
|
||||
for (let group of groups) {
|
||||
for (let step of group) {
|
||||
if (step[0] !== engineConnectionState.value.type) continue
|
||||
step[1] = true
|
||||
hasSetAStep = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
engineConnectionState.type === EngineConnectionStateType.Disconnecting
|
||||
) {
|
||||
const groups = Object.values(steps)
|
||||
for (let group of groups) {
|
||||
for (let step of group) {
|
||||
if (
|
||||
engineConnectionState.value.type === DisconnectingType.Error
|
||||
) {
|
||||
if (
|
||||
engineConnectionState.value.value.lastConnectingValue
|
||||
?.type === step[0]
|
||||
) {
|
||||
step[1] = false
|
||||
hasSetAStep = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (engineConnectionState.value.type === DisconnectingType.Error) {
|
||||
setError(engineConnectionState.value.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (hasSetAStep) {
|
||||
setSteps(steps)
|
||||
}
|
||||
}
|
||||
)
|
||||
}, [])
|
||||
|
||||
return {
|
||||
hasIssues,
|
||||
overallState,
|
||||
internetConnected,
|
||||
steps,
|
||||
issues,
|
||||
error,
|
||||
setHasCopied,
|
||||
hasCopied,
|
||||
}
|
||||
}
|
||||
|
||||
export const NetworkHealthIndicator = () => {
|
||||
const {
|
||||
hasIssues,
|
||||
@ -205,7 +82,7 @@ export const NetworkHealthIndicator = () => {
|
||||
error,
|
||||
setHasCopied,
|
||||
hasCopied,
|
||||
} = useNetworkStatus()
|
||||
} = useNetworkContext()
|
||||
|
||||
return (
|
||||
<Popover className="relative">
|
||||
@ -259,18 +136,18 @@ export const NetworkHealthIndicator = () => {
|
||||
size="lg"
|
||||
icon={
|
||||
hasIssueToIcon[
|
||||
issues[name as ConnectingTypeGroup].toString()
|
||||
String(issues[name as ConnectingTypeGroup])
|
||||
]
|
||||
}
|
||||
iconClassName={
|
||||
hasIssueToIconColors[
|
||||
issues[name as ConnectingTypeGroup].toString()
|
||||
String(issues[name as ConnectingTypeGroup])
|
||||
].icon
|
||||
}
|
||||
bgClassName={
|
||||
'rounded-sm ' +
|
||||
hasIssueToIconColors[
|
||||
issues[name as ConnectingTypeGroup].toString()
|
||||
String(issues[name as ConnectingTypeGroup])
|
||||
].bg
|
||||
}
|
||||
/>
|
||||
|
@ -31,43 +31,6 @@ const projectWellFormed = {
|
||||
} satisfies Project
|
||||
|
||||
describe('ProjectSidebarMenu tests', () => {
|
||||
test('Renders the project name', () => {
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<CommandBarProvider>
|
||||
<SettingsAuthProviderJest>
|
||||
<ProjectSidebarMenu project={projectWellFormed} enableMenu={true} />
|
||||
</SettingsAuthProviderJest>
|
||||
</CommandBarProvider>
|
||||
</BrowserRouter>
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('project-sidebar-toggle'))
|
||||
|
||||
expect(screen.getByTestId('projectName')).toHaveTextContent(
|
||||
projectWellFormed.name
|
||||
)
|
||||
expect(screen.getByTestId('createdAt')).toHaveTextContent(
|
||||
`Created ${now.toLocaleDateString()}`
|
||||
)
|
||||
})
|
||||
|
||||
test('Renders app name if given no project', () => {
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<CommandBarProvider>
|
||||
<SettingsAuthProviderJest>
|
||||
<ProjectSidebarMenu enableMenu={true} />
|
||||
</SettingsAuthProviderJest>
|
||||
</CommandBarProvider>
|
||||
</BrowserRouter>
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('project-sidebar-toggle'))
|
||||
|
||||
expect(screen.getByTestId('projectName')).toHaveTextContent(APP_NAME)
|
||||
})
|
||||
|
||||
test('Disables popover menu by default', () => {
|
||||
render(
|
||||
<BrowserRouter>
|
||||
|
@ -5,7 +5,6 @@ import { paths } from 'lib/paths'
|
||||
import { isTauri } from '../lib/isTauri'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Fragment } from 'react'
|
||||
import { FileTree } from './FileTree'
|
||||
import { sep } from '@tauri-apps/api/path'
|
||||
import { Logo } from './Logo'
|
||||
import { APP_NAME } from 'lib/constants'
|
||||
@ -138,41 +137,7 @@ function ProjectMenuPopover({
|
||||
>
|
||||
{({ close }) => (
|
||||
<>
|
||||
<div className="flex items-center gap-4 px-4 py-3">
|
||||
<div>
|
||||
<p className="m-0 text-mono" data-testid="projectName">
|
||||
{project?.name ? project.name : APP_NAME}
|
||||
</p>
|
||||
{project?.metadata && project.metadata.created && (
|
||||
<p
|
||||
className="m-0 text-xs text-chalkboard-80 dark:text-chalkboard-40"
|
||||
data-testid="createdAt"
|
||||
>
|
||||
Created{' '}
|
||||
{new Date(project.metadata.created).toLocaleDateString()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{isTauri() ? (
|
||||
<FileTree
|
||||
file={file}
|
||||
className="overflow-hidden border-0 border-y border-chalkboard-30 dark:border-chalkboard-80"
|
||||
onNavigateToFile={close}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex-1 p-4 text-sm overflow-hidden">
|
||||
<p>
|
||||
In the browser version of Modeling App you can only have one
|
||||
part, and the code is stored in your browser's storage.
|
||||
</p>
|
||||
<p className="my-6">
|
||||
Please save any code you want to keep more permanently, as
|
||||
your browser's storage is not guaranteed to be permanent.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-2 p-4 dark:bg-chalkboard-90">
|
||||
<div className="flex flex-col gap-2 p-4">
|
||||
<ActionButton
|
||||
Element="button"
|
||||
iconStart={{ icon: 'exportFile', className: 'p-1' }}
|
||||
|
87
src/components/Settings/AllKeybindingsFields.tsx
Normal file
@ -0,0 +1,87 @@
|
||||
import {
|
||||
InteractionMapItem,
|
||||
interactionMap,
|
||||
sortInteractionMapByCategory,
|
||||
} from 'lib/settings/initialKeybindings'
|
||||
import { ForwardedRef, forwardRef } from 'react'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
|
||||
interface AllKeybindingsFieldsProps {}
|
||||
|
||||
export const AllKeybindingsFields = forwardRef(
|
||||
(
|
||||
props: AllKeybindingsFieldsProps,
|
||||
scrollRef: ForwardedRef<HTMLDivElement>
|
||||
) => {
|
||||
// This is how we will get the interaction map from the context
|
||||
// in the future whene franknoirot/editable-hotkeys is merged.
|
||||
// const { state } = useInteractionMapContext()
|
||||
|
||||
return (
|
||||
<div className="relative overflow-y-auto pb-16">
|
||||
<div ref={scrollRef} className="flex flex-col gap-12">
|
||||
{Object.entries(interactionMap)
|
||||
.sort(sortInteractionMapByCategory)
|
||||
.map(([category, categoryItems]) => (
|
||||
<div className="flex flex-col gap-4 px-2 pr-4">
|
||||
<h2
|
||||
id={`category-${category}`}
|
||||
className="text-xl mt-6 first-of-type:mt-0 capitalize font-bold"
|
||||
>
|
||||
{category}
|
||||
</h2>
|
||||
{categoryItems.map((item) => (
|
||||
<KeybindingField
|
||||
key={category + '-' + item.name}
|
||||
category={category}
|
||||
item={item}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
function KeybindingField({
|
||||
item,
|
||||
category,
|
||||
}: {
|
||||
item: InteractionMapItem
|
||||
category: string
|
||||
}) {
|
||||
const location = useLocation()
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
'flex gap-16 justify-between items-start py-1 px-2 -my-1 -mx-2 ' +
|
||||
(location.hash === `#${item.name}`
|
||||
? 'bg-primary/5 dark:bg-chalkboard-90'
|
||||
: '')
|
||||
}
|
||||
id={item.name}
|
||||
>
|
||||
<div>
|
||||
<h3 className="text-lg font-normal capitalize tracking-wide">
|
||||
{item.title}
|
||||
</h3>
|
||||
<p className="text-xs text-chalkboard-60 dark:text-chalkboard-50">
|
||||
{item.description}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex-1 flex flex-wrap justify-end gap-3">
|
||||
{item.sequence.split(' ').map((chord, i) => (
|
||||
<kbd
|
||||
key={`${category}-${item.name}-${chord}-${i}`}
|
||||
className="py-0.5 px-1.5 rounded bg-primary/10 dark:bg-chalkboard-80"
|
||||
>
|
||||
{chord}
|
||||
</kbd>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
238
src/components/Settings/AllSettingsFields.tsx
Normal file
@ -0,0 +1,238 @@
|
||||
import decamelize from 'decamelize'
|
||||
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
|
||||
import { Setting } from 'lib/settings/initialSettings'
|
||||
import { SetEventTypes, SettingsLevel } from 'lib/settings/settingsTypes'
|
||||
import {
|
||||
shouldHideSetting,
|
||||
shouldShowSettingInput,
|
||||
} from 'lib/settings/settingsUtils'
|
||||
import { Fragment } from 'react/jsx-runtime'
|
||||
import { SettingsSection } from './SettingsSection'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import { isTauri } from 'lib/isTauri'
|
||||
import { ActionButton } from 'components/ActionButton'
|
||||
import { SettingsFieldInput } from './SettingsFieldInput'
|
||||
import { getInitialDefaultDir, showInFolder } from 'lib/tauri'
|
||||
import toast from 'react-hot-toast'
|
||||
import { APP_VERSION } from 'routes/Settings'
|
||||
import { createAndOpenNewProject, getSettingsFolderPaths } from 'lib/tauriFS'
|
||||
import { paths } from 'lib/paths'
|
||||
import { useDotDotSlash } from 'hooks/useDotDotSlash'
|
||||
import { sep } from '@tauri-apps/api/path'
|
||||
import { ForwardedRef, forwardRef } from 'react'
|
||||
|
||||
interface AllSettingsFieldsProps {
|
||||
searchParamTab: SettingsLevel
|
||||
isFileSettings: boolean
|
||||
}
|
||||
|
||||
export const AllSettingsFields = forwardRef(
|
||||
(
|
||||
{ searchParamTab, isFileSettings }: AllSettingsFieldsProps,
|
||||
scrollRef: ForwardedRef<HTMLDivElement>
|
||||
) => {
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const dotDotSlash = useDotDotSlash()
|
||||
const {
|
||||
settings: { send, context },
|
||||
} = useSettingsAuthContext()
|
||||
|
||||
const projectPath =
|
||||
isFileSettings && isTauri()
|
||||
? decodeURI(
|
||||
location.pathname
|
||||
.replace(paths.FILE + '/', '')
|
||||
.replace(paths.SETTINGS, '')
|
||||
.slice(0, decodeURI(location.pathname).lastIndexOf(sep()))
|
||||
)
|
||||
: undefined
|
||||
|
||||
function restartOnboarding() {
|
||||
send({
|
||||
type: `set.app.onboardingStatus`,
|
||||
data: { level: 'user', value: '' },
|
||||
})
|
||||
|
||||
if (isFileSettings) {
|
||||
navigate(dotDotSlash(1) + paths.ONBOARDING.INDEX)
|
||||
} else {
|
||||
createAndOpenNewProject(navigate)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative overflow-y-auto">
|
||||
<div ref={scrollRef} className="flex flex-col gap-4 px-2">
|
||||
{Object.entries(context)
|
||||
.filter(([_, categorySettings]) =>
|
||||
// Filter out categories that don't have any non-hidden settings
|
||||
Object.values(categorySettings).some(
|
||||
(setting) => !shouldHideSetting(setting, searchParamTab)
|
||||
)
|
||||
)
|
||||
.map(([category, categorySettings]) => (
|
||||
<Fragment key={category}>
|
||||
<h2
|
||||
id={`category-${category}`}
|
||||
className="text-xl mt-6 first-of-type:mt-0 capitalize font-bold"
|
||||
>
|
||||
{decamelize(category, { separator: ' ' })}
|
||||
</h2>
|
||||
{Object.entries(categorySettings)
|
||||
.filter(
|
||||
// Filter out settings that don't have a Component or inputType
|
||||
// or are hidden on the current level or the current platform
|
||||
(item: [string, Setting<unknown>]) =>
|
||||
shouldShowSettingInput(item[1], searchParamTab)
|
||||
)
|
||||
.map(([settingName, s]) => {
|
||||
const setting = s as Setting
|
||||
const parentValue =
|
||||
setting[setting.getParentLevel(searchParamTab)]
|
||||
return (
|
||||
<SettingsSection
|
||||
title={decamelize(settingName, {
|
||||
separator: ' ',
|
||||
})}
|
||||
id={settingName}
|
||||
className={
|
||||
location.hash === `#${settingName}`
|
||||
? 'bg-primary/5 dark:bg-chalkboard-90'
|
||||
: ''
|
||||
}
|
||||
key={`${category}-${settingName}-${searchParamTab}`}
|
||||
description={setting.description}
|
||||
settingHasChanged={
|
||||
setting[searchParamTab] !== undefined &&
|
||||
setting[searchParamTab] !==
|
||||
setting.getFallback(searchParamTab)
|
||||
}
|
||||
parentLevel={setting.getParentLevel(searchParamTab)}
|
||||
onFallback={() =>
|
||||
send({
|
||||
type: `set.${category}.${settingName}`,
|
||||
data: {
|
||||
level: searchParamTab,
|
||||
value:
|
||||
parentValue !== undefined
|
||||
? parentValue
|
||||
: setting.getFallback(searchParamTab),
|
||||
},
|
||||
} as SetEventTypes)
|
||||
}
|
||||
>
|
||||
<SettingsFieldInput
|
||||
category={category}
|
||||
settingName={settingName}
|
||||
settingsLevel={searchParamTab}
|
||||
setting={setting}
|
||||
/>
|
||||
</SettingsSection>
|
||||
)
|
||||
})}
|
||||
</Fragment>
|
||||
))}
|
||||
<h2 id="settings-resets" className="text-2xl mt-6 font-bold">
|
||||
Resets
|
||||
</h2>
|
||||
<SettingsSection
|
||||
title="Onboarding"
|
||||
description="Replay the onboarding process"
|
||||
>
|
||||
<ActionButton
|
||||
Element="button"
|
||||
onClick={restartOnboarding}
|
||||
iconStart={{
|
||||
icon: 'refresh',
|
||||
size: 'sm',
|
||||
className: 'p-1',
|
||||
}}
|
||||
>
|
||||
Replay Onboarding
|
||||
</ActionButton>
|
||||
</SettingsSection>
|
||||
<SettingsSection
|
||||
title="Reset settings"
|
||||
description={`Restore settings to their default values. Your settings are saved in
|
||||
${
|
||||
isTauri()
|
||||
? ' a file in the app data folder for your OS.'
|
||||
: " your browser's local storage."
|
||||
}
|
||||
`}
|
||||
>
|
||||
<div className="flex flex-col items-start gap-4">
|
||||
{isTauri() && (
|
||||
<ActionButton
|
||||
Element="button"
|
||||
onClick={async () => {
|
||||
const paths = await getSettingsFolderPaths(
|
||||
projectPath ? decodeURIComponent(projectPath) : undefined
|
||||
)
|
||||
showInFolder(paths[searchParamTab])
|
||||
}}
|
||||
iconStart={{
|
||||
icon: 'folder',
|
||||
size: 'sm',
|
||||
className: 'p-1',
|
||||
}}
|
||||
>
|
||||
Show in folder
|
||||
</ActionButton>
|
||||
)}
|
||||
<ActionButton
|
||||
Element="button"
|
||||
onClick={async () => {
|
||||
const defaultDirectory = await getInitialDefaultDir()
|
||||
send({
|
||||
type: 'Reset settings',
|
||||
defaultDirectory,
|
||||
})
|
||||
toast.success('Settings restored to default')
|
||||
}}
|
||||
iconStart={{
|
||||
icon: 'refresh',
|
||||
size: 'sm',
|
||||
className: 'p-1 text-chalkboard-10',
|
||||
bgClassName: 'bg-destroy-70',
|
||||
}}
|
||||
>
|
||||
Restore default settings
|
||||
</ActionButton>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
<h2 id="settings-about" className="text-2xl mt-6 font-bold">
|
||||
About Modeling App
|
||||
</h2>
|
||||
<div className="text-sm mb-12">
|
||||
<p>
|
||||
{/* This uses a Vite plugin, set in vite.config.ts
|
||||
to inject the version from package.json */}
|
||||
App version {APP_VERSION}.{' '}
|
||||
<a
|
||||
href={`https://github.com/KittyCAD/modeling-app/releases/tag/v${APP_VERSION}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
View release on GitHub
|
||||
</a>
|
||||
</p>
|
||||
<p className="max-w-2xl mt-6">
|
||||
Don't see the feature you want? Check to see if it's on{' '}
|
||||
<a
|
||||
href="https://github.com/KittyCAD/modeling-app/discussions"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
our roadmap
|
||||
</a>
|
||||
, and start a discussion if you don't see it! Your feedback will
|
||||
help us prioritize what to build next.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
35
src/components/Settings/KeybindingsSectionsList.tsx
Normal file
@ -0,0 +1,35 @@
|
||||
import {
|
||||
interactionMap,
|
||||
sortInteractionMapByCategory,
|
||||
} from 'lib/settings/initialKeybindings'
|
||||
|
||||
interface KeybindingSectionsListProps {
|
||||
scrollRef: React.RefObject<HTMLDivElement>
|
||||
}
|
||||
|
||||
export function KeybindingsSectionsList({
|
||||
scrollRef,
|
||||
}: KeybindingSectionsListProps) {
|
||||
return (
|
||||
<div className="flex w-32 flex-col gap-3 pr-2 py-1 border-0 border-r border-r-chalkboard-20 dark:border-r-chalkboard-90">
|
||||
{Object.entries(interactionMap)
|
||||
.sort(sortInteractionMapByCategory)
|
||||
.map(([category]) => (
|
||||
<button
|
||||
key={category}
|
||||
onClick={() =>
|
||||
scrollRef.current
|
||||
?.querySelector(`#category-${category}`)
|
||||
?.scrollIntoView({
|
||||
block: 'center',
|
||||
behavior: 'smooth',
|
||||
})
|
||||
}
|
||||
className="capitalize text-left border-none px-1"
|
||||
>
|
||||
{category}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
@ -3,11 +3,23 @@ import { CustomIcon } from 'components/CustomIcon'
|
||||
import decamelize from 'decamelize'
|
||||
import Fuse from 'fuse.js'
|
||||
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
|
||||
import { interactionMap } from 'lib/settings/initialKeybindings'
|
||||
import { Setting } from 'lib/settings/initialSettings'
|
||||
import { SettingsLevel } from 'lib/settings/settingsTypes'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useHotkeys } from 'react-hotkeys-hook'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
type ExtendedSettingsLevel = SettingsLevel | 'keybindings'
|
||||
|
||||
export type SettingsSearchItem = {
|
||||
name: string
|
||||
displayName: string
|
||||
description: string
|
||||
category: string
|
||||
level: ExtendedSettingsLevel
|
||||
}
|
||||
|
||||
export function SettingsSearchBar() {
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
useHotkeys(
|
||||
@ -21,29 +33,40 @@ export function SettingsSearchBar() {
|
||||
const navigate = useNavigate()
|
||||
const [query, setQuery] = useState('')
|
||||
const { settings } = useSettingsAuthContext()
|
||||
const settingsAsSearchable = useMemo(
|
||||
() =>
|
||||
Object.entries(settings.state.context).flatMap(
|
||||
const settingsAsSearchable: SettingsSearchItem[] = useMemo(
|
||||
() => [
|
||||
...Object.entries(settings.state.context).flatMap(
|
||||
([category, categorySettings]) =>
|
||||
Object.entries(categorySettings).flatMap(([settingName, setting]) => {
|
||||
const s = setting as Setting
|
||||
return ['project', 'user']
|
||||
return (['project', 'user'] satisfies SettingsLevel[])
|
||||
.filter((l) => s.hideOnLevel !== l)
|
||||
.map((l) => ({
|
||||
category: decamelize(category, { separator: ' ' }),
|
||||
settingName: settingName,
|
||||
settingNameDisplay: decamelize(settingName, { separator: ' ' }),
|
||||
setting: s,
|
||||
level: l,
|
||||
name: settingName,
|
||||
description: s.description ?? '',
|
||||
displayName: decamelize(settingName, { separator: ' ' }),
|
||||
level: l as ExtendedSettingsLevel,
|
||||
}))
|
||||
})
|
||||
),
|
||||
...Object.entries(interactionMap).flatMap(
|
||||
([category, categoryKeybindings]) =>
|
||||
categoryKeybindings.map((keybinding) => ({
|
||||
name: keybinding.name,
|
||||
displayName: keybinding.title,
|
||||
description: keybinding.description,
|
||||
category: category,
|
||||
level: 'keybindings' as ExtendedSettingsLevel,
|
||||
}))
|
||||
),
|
||||
],
|
||||
[settings.state.context]
|
||||
)
|
||||
const [searchResults, setSearchResults] = useState(settingsAsSearchable)
|
||||
|
||||
const fuse = new Fuse(settingsAsSearchable, {
|
||||
keys: ['category', 'settingNameDisplay', 'setting.description'],
|
||||
keys: ['category', 'displayName', 'description'],
|
||||
includeScore: true,
|
||||
})
|
||||
|
||||
@ -52,16 +75,8 @@ export function SettingsSearchBar() {
|
||||
setSearchResults(query.length > 0 ? results : settingsAsSearchable)
|
||||
}, [query])
|
||||
|
||||
function handleSelection({
|
||||
level,
|
||||
settingName,
|
||||
}: {
|
||||
category: string
|
||||
settingName: string
|
||||
setting: Setting<unknown>
|
||||
level: string
|
||||
}) {
|
||||
navigate(`?tab=${level}#${settingName}`)
|
||||
function handleSelection({ level, name }: SettingsSearchItem) {
|
||||
navigate(`?tab=${level}#${name}`)
|
||||
}
|
||||
|
||||
return (
|
||||
@ -87,18 +102,18 @@ export function SettingsSearchBar() {
|
||||
<Combobox.Options className="absolute top-full mt-2 right-0 w-80 overflow-y-auto z-50 max-h-96 cursor-pointer bg-chalkboard-10 dark:bg-chalkboard-100 border border-solid border-primary dark:border-chalkboard-30 rounded">
|
||||
{searchResults?.map((option) => (
|
||||
<Combobox.Option
|
||||
key={`${option.category}-${option.settingName}-${option.level}`}
|
||||
key={`${option.category}-${option.name}-${option.level}`}
|
||||
value={option}
|
||||
className="flex flex-col items-start gap-2 px-4 py-2 ui-active:bg-primary/10 dark:ui-active:bg-chalkboard-90"
|
||||
>
|
||||
<p className="flex-grow text-base capitalize m-0 leading-none">
|
||||
{option.level} ·{' '}
|
||||
{decamelize(option.category, { separator: ' ' })} ·{' '}
|
||||
{option.settingNameDisplay}
|
||||
{option.displayName}
|
||||
</p>
|
||||
{option.setting.description && (
|
||||
{option.description && (
|
||||
<p className="text-xs leading-tight text-chalkboard-70 dark:text-chalkboard-50">
|
||||
{option.setting.description}
|
||||
{option.description}
|
||||
</p>
|
||||
)}
|
||||
</Combobox.Option>
|
||||
|
68
src/components/Settings/SettingsSectionsList.tsx
Normal file
@ -0,0 +1,68 @@
|
||||
import decamelize from 'decamelize'
|
||||
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
|
||||
import { Setting } from 'lib/settings/initialSettings'
|
||||
import { SettingsLevel } from 'lib/settings/settingsTypes'
|
||||
import { shouldHideSetting } from 'lib/settings/settingsUtils'
|
||||
|
||||
interface SettingsSectionsListProps {
|
||||
searchParamTab: SettingsLevel
|
||||
scrollRef: React.RefObject<HTMLDivElement>
|
||||
}
|
||||
|
||||
export function SettingsSectionsList({
|
||||
searchParamTab,
|
||||
scrollRef,
|
||||
}: SettingsSectionsListProps) {
|
||||
const {
|
||||
settings: { context },
|
||||
} = useSettingsAuthContext()
|
||||
return (
|
||||
<div className="flex w-32 flex-col gap-3 pr-2 py-1 border-0 border-r border-r-chalkboard-20 dark:border-r-chalkboard-90">
|
||||
{Object.entries(context)
|
||||
.filter(([_, categorySettings]) =>
|
||||
// Filter out categories that don't have any non-hidden settings
|
||||
Object.values(categorySettings).some(
|
||||
(setting: Setting) => !shouldHideSetting(setting, searchParamTab)
|
||||
)
|
||||
)
|
||||
.map(([category]) => (
|
||||
<button
|
||||
key={category}
|
||||
onClick={() =>
|
||||
scrollRef.current
|
||||
?.querySelector(`#category-${category}`)
|
||||
?.scrollIntoView({
|
||||
block: 'center',
|
||||
behavior: 'smooth',
|
||||
})
|
||||
}
|
||||
className="capitalize text-left border-none px-1"
|
||||
>
|
||||
{decamelize(category, { separator: ' ' })}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
onClick={() =>
|
||||
scrollRef.current?.querySelector(`#settings-resets`)?.scrollIntoView({
|
||||
block: 'center',
|
||||
behavior: 'smooth',
|
||||
})
|
||||
}
|
||||
className="capitalize text-left border-none px-1"
|
||||
>
|
||||
Resets
|
||||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
scrollRef.current?.querySelector(`#settings-about`)?.scrollIntoView({
|
||||
block: 'center',
|
||||
behavior: 'smooth',
|
||||
})
|
||||
}
|
||||
className="capitalize text-left border-none px-1"
|
||||
>
|
||||
About
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
@ -34,6 +34,15 @@ export function SettingsTabs({
|
||||
)}
|
||||
</RadioGroup.Option>
|
||||
)}
|
||||
<RadioGroup.Option value="keybindings">
|
||||
{({ checked }) => (
|
||||
<SettingsTabButton
|
||||
checked={checked}
|
||||
icon="keyboard"
|
||||
text="Keybindings"
|
||||
/>
|
||||
)}
|
||||
</RadioGroup.Option>
|
||||
</RadioGroup>
|
||||
)
|
||||
}
|
||||
|
@ -4,8 +4,9 @@ import { getNormalisedCoordinates } from '../lib/utils'
|
||||
import Loading from './Loading'
|
||||
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
|
||||
import { useModelingContext } from 'hooks/useModelingContext'
|
||||
import { useNetworkContext } from 'hooks/useNetworkContext'
|
||||
import { NetworkHealthState } from 'hooks/useNetworkStatus'
|
||||
import { ClientSideScene } from 'clientSideScene/ClientSideSceneComp'
|
||||
import { NetworkHealthState, useNetworkStatus } from './NetworkHealthIndicator'
|
||||
import { butName } from 'lib/cameraControls'
|
||||
import { sendSelectEventToEngine } from 'lib/selections'
|
||||
|
||||
@ -28,8 +29,43 @@ export const Stream = ({ className = '' }: { className?: string }) => {
|
||||
}))
|
||||
const { settings } = useSettingsAuthContext()
|
||||
const { state } = useModelingContext()
|
||||
const { overallState } = useNetworkStatus()
|
||||
const isNetworkOkay = overallState === NetworkHealthState.Ok
|
||||
const { overallState } = useNetworkContext()
|
||||
|
||||
const isNetworkOkay =
|
||||
overallState === NetworkHealthState.Ok ||
|
||||
overallState === NetworkHealthState.Weak
|
||||
|
||||
// Linux has a default behavior to paste text on middle mouse up
|
||||
// This adds a listener to block that pasting if the click target
|
||||
// is not a text input, so users can move in the 3D scene with
|
||||
// middle mouse drag with a text input focused without pasting.
|
||||
useEffect(() => {
|
||||
const handlePaste = (e: ClipboardEvent) => {
|
||||
const isHtmlElement = e.target && e.target instanceof HTMLElement
|
||||
const isEditable =
|
||||
(isHtmlElement && !('explicitOriginalTarget' in e)) ||
|
||||
('explicitOriginalTarget' in e &&
|
||||
((e.explicitOriginalTarget as HTMLElement).contentEditable ===
|
||||
'true' ||
|
||||
['INPUT', 'TEXTAREA'].some(
|
||||
(tagName) =>
|
||||
tagName === (e.explicitOriginalTarget as HTMLElement).tagName
|
||||
)))
|
||||
if (!isEditable) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
e.stopImmediatePropagation()
|
||||
}
|
||||
}
|
||||
|
||||
globalThis?.window?.document?.addEventListener('paste', handlePaste, {
|
||||
capture: true,
|
||||
})
|
||||
return () =>
|
||||
globalThis?.window?.document?.removeEventListener('paste', handlePaste, {
|
||||
capture: true,
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
@ -43,6 +79,7 @@ export const Stream = ({ className = '' }: { className?: string }) => {
|
||||
}, [mediaStream])
|
||||
|
||||
const handleMouseDown: MouseEventHandler<HTMLDivElement> = (e) => {
|
||||
if (!isNetworkOkay) return
|
||||
if (!videoRef.current) return
|
||||
if (state.matches('Sketch')) return
|
||||
if (state.matches('Sketch no face')) return
|
||||
@ -58,6 +95,7 @@ export const Stream = ({ className = '' }: { className?: string }) => {
|
||||
}
|
||||
|
||||
const handleMouseUp: MouseEventHandler<HTMLDivElement> = (e) => {
|
||||
if (!isNetworkOkay) return
|
||||
if (!videoRef.current) return
|
||||
setButtonDownInStream(undefined)
|
||||
if (state.matches('Sketch')) return
|
||||
@ -72,6 +110,7 @@ export const Stream = ({ className = '' }: { className?: string }) => {
|
||||
}
|
||||
|
||||
const handleMouseMove: MouseEventHandler<HTMLVideoElement> = (e) => {
|
||||
if (!isNetworkOkay) return
|
||||
if (state.matches('Sketch')) return
|
||||
if (state.matches('Sketch no face')) return
|
||||
if (!clickCoords) return
|
||||
@ -112,7 +151,7 @@ export const Stream = ({ className = '' }: { className?: string }) => {
|
||||
{!isNetworkOkay && !isLoading && (
|
||||
<div className="text-center absolute inset-0">
|
||||
<Loading>
|
||||
<span data-testid="loading-stream">Stream disconnected</span>
|
||||
<span data-testid="loading-stream">Stream disconnected...</span>
|
||||
</Loading>
|
||||
</div>
|
||||
)}
|
||||
|
@ -140,7 +140,11 @@ export async function applyConstraintIntersect({
|
||||
value: valueUsedInTransform,
|
||||
initialVariableName: 'offset',
|
||||
})
|
||||
if (segName === tagInfo?.tag && Number(value) === valueUsedInTransform) {
|
||||
if (
|
||||
!variableName &&
|
||||
segName === tagInfo?.tag &&
|
||||
Number(value) === valueUsedInTransform
|
||||
) {
|
||||
return {
|
||||
modifiedAst,
|
||||
pathToNodeMap,
|
||||
@ -169,6 +173,10 @@ export async function applyConstraintIntersect({
|
||||
createVariableDeclaration(variableName, valueNode)
|
||||
)
|
||||
_modifiedAst.body = newBody
|
||||
Object.values(_pathToNodeMap).forEach((pathToNode) => {
|
||||
const index = pathToNode.findIndex((a) => a[0] === 'body') + 1
|
||||
pathToNode[index][0] = Number(pathToNode[index][0]) + 1
|
||||
})
|
||||
}
|
||||
return {
|
||||
modifiedAst: _modifiedAst,
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { toolTips } from '../../useStore'
|
||||
import { Selections } from 'lib/selections'
|
||||
import { Program, Value } from '../../lang/wasm'
|
||||
import { Selection, Selections } from 'lib/selections'
|
||||
import { PathToNode, Program, Value } from '../../lang/wasm'
|
||||
import {
|
||||
getNodePathFromSourceRange,
|
||||
getNodeFromPath,
|
||||
@ -14,15 +14,30 @@ import { kclManager } from 'lib/singletons'
|
||||
|
||||
export function removeConstrainingValuesInfo({
|
||||
selectionRanges,
|
||||
pathToNodes,
|
||||
}: {
|
||||
selectionRanges: Selections
|
||||
pathToNodes?: Array<PathToNode>
|
||||
}) {
|
||||
const paths = selectionRanges.codeBasedSelections.map(({ range }) =>
|
||||
getNodePathFromSourceRange(kclManager.ast, range)
|
||||
)
|
||||
const paths =
|
||||
pathToNodes ||
|
||||
selectionRanges.codeBasedSelections.map(({ range }) =>
|
||||
getNodePathFromSourceRange(kclManager.ast, range)
|
||||
)
|
||||
const nodes = paths.map(
|
||||
(pathToNode) => getNodeFromPath<Value>(kclManager.ast, pathToNode).node
|
||||
)
|
||||
const updatedSelectionRanges = pathToNodes
|
||||
? {
|
||||
otherSelections: [],
|
||||
codeBasedSelections: nodes.map(
|
||||
(node): Selection => ({
|
||||
range: [node.start, node.end],
|
||||
type: 'default',
|
||||
})
|
||||
),
|
||||
}
|
||||
: selectionRanges
|
||||
const isAllTooltips = nodes.every(
|
||||
(node) =>
|
||||
node?.type === 'CallExpression' &&
|
||||
@ -31,31 +46,36 @@ export function removeConstrainingValuesInfo({
|
||||
|
||||
try {
|
||||
const transforms = getRemoveConstraintsTransforms(
|
||||
selectionRanges,
|
||||
updatedSelectionRanges,
|
||||
kclManager.ast,
|
||||
'removeConstrainingValues'
|
||||
)
|
||||
|
||||
const enabled = isAllTooltips && transforms.every(Boolean)
|
||||
return { enabled, transforms }
|
||||
return { enabled, transforms, updatedSelectionRanges }
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
return { enabled: false, transforms: [] }
|
||||
return { enabled: false, transforms: [], updatedSelectionRanges }
|
||||
}
|
||||
}
|
||||
|
||||
export function applyRemoveConstrainingValues({
|
||||
selectionRanges,
|
||||
pathToNodes,
|
||||
}: {
|
||||
selectionRanges: Selections
|
||||
pathToNodes?: Array<PathToNode>
|
||||
}): {
|
||||
modifiedAst: Program
|
||||
pathToNodeMap: PathToNodeMap
|
||||
} {
|
||||
const { transforms } = removeConstrainingValuesInfo({ selectionRanges })
|
||||
const { transforms, updatedSelectionRanges } = removeConstrainingValuesInfo({
|
||||
selectionRanges,
|
||||
pathToNodes,
|
||||
})
|
||||
return transformAstSketchLines({
|
||||
ast: kclManager.ast,
|
||||
selectionRanges,
|
||||
selectionRanges: updatedSelectionRanges,
|
||||
transformInfos: transforms,
|
||||
programMemory: kclManager.programMemory,
|
||||
referenceSegName: '',
|
||||
|
@ -120,6 +120,10 @@ export async function applyConstraintAbsDistance({
|
||||
createVariableDeclaration(variableName, valueNode)
|
||||
)
|
||||
_modifiedAst.body = newBody
|
||||
Object.values(pathToNodeMap).forEach((pathToNode) => {
|
||||
const index = pathToNode.findIndex((a) => a[0] === 'body') + 1
|
||||
pathToNode[index][0] = Number(pathToNode[index][0]) + 1
|
||||
})
|
||||
}
|
||||
return { modifiedAst: _modifiedAst, pathToNodeMap }
|
||||
}
|
||||
|
@ -98,7 +98,11 @@ export async function applyConstraintAngleBetween({
|
||||
value: valueUsedInTransform,
|
||||
initialVariableName: 'angle',
|
||||
} as any)
|
||||
if (segName === tagInfo?.tag && Number(value) === valueUsedInTransform) {
|
||||
if (
|
||||
segName === tagInfo?.tag &&
|
||||
Number(value) === valueUsedInTransform &&
|
||||
!variableName
|
||||
) {
|
||||
return {
|
||||
modifiedAst,
|
||||
pathToNodeMap,
|
||||
@ -128,6 +132,10 @@ export async function applyConstraintAngleBetween({
|
||||
createVariableDeclaration(variableName, valueNode)
|
||||
)
|
||||
_modifiedAst.body = newBody
|
||||
Object.values(_pathToNodeMap).forEach((pathToNode) => {
|
||||
const index = pathToNode.findIndex((a) => a[0] === 'body') + 1
|
||||
pathToNode[index][0] = Number(pathToNode[index][0]) + 1
|
||||
})
|
||||
}
|
||||
return {
|
||||
modifiedAst: _modifiedAst,
|
||||
|
@ -106,7 +106,11 @@ export async function applyConstraintHorzVertDistance({
|
||||
value: valueUsedInTransform,
|
||||
initialVariableName: constraint === 'setHorzDistance' ? 'xDis' : 'yDis',
|
||||
} as any)
|
||||
if (segName === tagInfo?.tag && Number(value) === valueUsedInTransform) {
|
||||
if (
|
||||
!variableName &&
|
||||
segName === tagInfo?.tag &&
|
||||
Number(value) === valueUsedInTransform
|
||||
) {
|
||||
return {
|
||||
modifiedAst,
|
||||
pathToNodeMap,
|
||||
@ -133,6 +137,10 @@ export async function applyConstraintHorzVertDistance({
|
||||
createVariableDeclaration(variableName, valueNode)
|
||||
)
|
||||
_modifiedAst.body = newBody
|
||||
Object.values(pathToNodeMap).forEach((pathToNode) => {
|
||||
const index = pathToNode.findIndex((a) => a[0] === 'body') + 1
|
||||
pathToNode[index][0] = Number(pathToNode[index][0]) + 1
|
||||
})
|
||||
}
|
||||
return {
|
||||
modifiedAst: _modifiedAst,
|
||||
|
@ -138,13 +138,17 @@ export async function applyConstraintAngleLength({
|
||||
createVariableDeclaration(variableName, valueNode)
|
||||
)
|
||||
_modifiedAst.body = newBody
|
||||
Object.values(pathToNodeMap).forEach((pathToNode) => {
|
||||
const index = pathToNode.findIndex((a) => a[0] === 'body') + 1
|
||||
pathToNode[index][0] = Number(pathToNode[index][0]) + 1
|
||||
})
|
||||
}
|
||||
return {
|
||||
modifiedAst: _modifiedAst,
|
||||
pathToNodeMap,
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('erorr', e)
|
||||
console.log('error', e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
@ -474,19 +474,13 @@ const completionRequester = (client: LanguageServerClient) => {
|
||||
}
|
||||
|
||||
export const copilotPlugin = (options: LanguageServerOptions): Extension => {
|
||||
let plugin: LanguageServerPlugin | null = null
|
||||
|
||||
return [
|
||||
documentUri.of(options.documentUri),
|
||||
languageId.of('kcl'),
|
||||
workspaceFolders.of(options.workspaceFolders),
|
||||
ViewPlugin.define(
|
||||
(view) =>
|
||||
(plugin = new LanguageServerPlugin(
|
||||
options.client,
|
||||
view,
|
||||
options.allowHTMLContent
|
||||
))
|
||||
new LanguageServerPlugin(options.client, view, options.allowHTMLContent)
|
||||
),
|
||||
completionDecoration,
|
||||
Prec.highest(completionPlugin(options.client)),
|
||||
|
25
src/hooks/useNetworkContext.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
import { createContext, useContext } from 'react'
|
||||
import {
|
||||
ConnectingTypeGroup,
|
||||
initialConnectingTypeGroupState,
|
||||
} from '../lang/std/engineConnection'
|
||||
import { NetworkStatus, NetworkHealthState } from './useNetworkStatus'
|
||||
|
||||
export const NetworkContext = createContext<NetworkStatus>({
|
||||
hasIssues: undefined,
|
||||
overallState: NetworkHealthState.Disconnected,
|
||||
internetConnected: true,
|
||||
steps: structuredClone(initialConnectingTypeGroupState),
|
||||
issues: {
|
||||
[ConnectingTypeGroup.WebSocket]: undefined,
|
||||
[ConnectingTypeGroup.ICE]: undefined,
|
||||
[ConnectingTypeGroup.WebRTC]: undefined,
|
||||
},
|
||||
error: undefined,
|
||||
setHasCopied: (b: boolean) => {},
|
||||
hasCopied: false,
|
||||
pingPongHealth: undefined,
|
||||
} as NetworkStatus)
|
||||
export const useNetworkContext = () => {
|
||||
return useContext(NetworkContext)
|
||||
}
|
228
src/hooks/useNetworkStatus.tsx
Normal file
@ -0,0 +1,228 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
ConnectingType,
|
||||
ConnectingTypeGroup,
|
||||
DisconnectingType,
|
||||
EngineCommandManagerEvents,
|
||||
EngineConnectionEvents,
|
||||
EngineConnectionStateType,
|
||||
ErrorType,
|
||||
initialConnectingTypeGroupState,
|
||||
} from '../lang/std/engineConnection'
|
||||
import { engineCommandManager } from '../lib/singletons'
|
||||
|
||||
export enum NetworkHealthState {
|
||||
Ok,
|
||||
Weak,
|
||||
Issue,
|
||||
Disconnected,
|
||||
}
|
||||
|
||||
export interface NetworkStatus {
|
||||
hasIssues: boolean | undefined
|
||||
overallState: NetworkHealthState
|
||||
internetConnected: boolean
|
||||
steps: typeof initialConnectingTypeGroupState
|
||||
issues: Record<ConnectingTypeGroup, boolean | undefined>
|
||||
error: ErrorType | undefined
|
||||
setHasCopied: (b: boolean) => void
|
||||
hasCopied: boolean
|
||||
pingPongHealth: undefined | 'OK' | 'TIMEOUT'
|
||||
}
|
||||
|
||||
// Must be called from one place in the application.
|
||||
// We've chosen the <Router /> component for this.
|
||||
export function useNetworkStatus() {
|
||||
const [steps, setSteps] = useState(
|
||||
structuredClone(initialConnectingTypeGroupState)
|
||||
)
|
||||
const [internetConnected, setInternetConnected] = useState<boolean>(true)
|
||||
const [overallState, setOverallState] = useState<NetworkHealthState>(
|
||||
NetworkHealthState.Disconnected
|
||||
)
|
||||
const [pingPongHealth, setPingPongHealth] = useState<
|
||||
undefined | 'OK' | 'TIMEOUT'
|
||||
>(undefined)
|
||||
const [hasCopied, setHasCopied] = useState<boolean>(false)
|
||||
|
||||
const [error, setError] = useState<ErrorType | undefined>(undefined)
|
||||
|
||||
const hasIssue = (i: [ConnectingType, boolean | undefined]) =>
|
||||
i[1] === undefined ? i[1] : !i[1]
|
||||
|
||||
const [issues, setIssues] = useState<
|
||||
Record<ConnectingTypeGroup, boolean | undefined>
|
||||
>({
|
||||
[ConnectingTypeGroup.WebSocket]: undefined,
|
||||
[ConnectingTypeGroup.ICE]: undefined,
|
||||
[ConnectingTypeGroup.WebRTC]: undefined,
|
||||
})
|
||||
|
||||
const [hasIssues, setHasIssues] = useState<boolean | undefined>(undefined)
|
||||
useEffect(() => {
|
||||
setOverallState(
|
||||
!internetConnected
|
||||
? NetworkHealthState.Disconnected
|
||||
: hasIssues || hasIssues === undefined
|
||||
? NetworkHealthState.Issue
|
||||
: pingPongHealth === 'TIMEOUT'
|
||||
? NetworkHealthState.Weak
|
||||
: NetworkHealthState.Ok
|
||||
)
|
||||
}, [hasIssues, internetConnected, pingPongHealth])
|
||||
|
||||
useEffect(() => {
|
||||
const onlineCallback = () => {
|
||||
setInternetConnected(true)
|
||||
}
|
||||
const offlineCallback = () => {
|
||||
setInternetConnected(false)
|
||||
setSteps(structuredClone(initialConnectingTypeGroupState))
|
||||
}
|
||||
window.addEventListener('online', onlineCallback)
|
||||
window.addEventListener('offline', offlineCallback)
|
||||
return () => {
|
||||
window.removeEventListener('online', onlineCallback)
|
||||
window.removeEventListener('offline', offlineCallback)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const issues = {
|
||||
[ConnectingTypeGroup.WebSocket]: steps[
|
||||
ConnectingTypeGroup.WebSocket
|
||||
].reduce(
|
||||
(acc: boolean | undefined, a) =>
|
||||
acc === true || acc === undefined ? acc : hasIssue(a),
|
||||
false
|
||||
),
|
||||
[ConnectingTypeGroup.ICE]: steps[ConnectingTypeGroup.ICE].reduce(
|
||||
(acc: boolean | undefined, a) =>
|
||||
acc === true || acc === undefined ? acc : hasIssue(a),
|
||||
false
|
||||
),
|
||||
[ConnectingTypeGroup.WebRTC]: steps[ConnectingTypeGroup.WebRTC].reduce(
|
||||
(acc: boolean | undefined, a) =>
|
||||
acc === true || acc === undefined ? acc : hasIssue(a),
|
||||
false
|
||||
),
|
||||
}
|
||||
setIssues(issues)
|
||||
}, [steps])
|
||||
|
||||
useEffect(() => {
|
||||
setHasIssues(
|
||||
issues[ConnectingTypeGroup.WebSocket] ||
|
||||
issues[ConnectingTypeGroup.ICE] ||
|
||||
issues[ConnectingTypeGroup.WebRTC]
|
||||
)
|
||||
}, [issues])
|
||||
|
||||
useEffect(() => {
|
||||
const onPingPongChange = ({ detail: state }: CustomEvent) => {
|
||||
setPingPongHealth(state)
|
||||
}
|
||||
|
||||
const onConnectionStateChange = ({
|
||||
detail: engineConnectionState,
|
||||
}: CustomEvent) => {
|
||||
setSteps((steps) => {
|
||||
let nextSteps = structuredClone(steps)
|
||||
|
||||
if (
|
||||
engineConnectionState.type === EngineConnectionStateType.Connecting
|
||||
) {
|
||||
const groups = Object.values(nextSteps)
|
||||
for (let group of groups) {
|
||||
for (let step of group) {
|
||||
if (step[0] !== engineConnectionState.value.type) continue
|
||||
step[1] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
engineConnectionState.type === EngineConnectionStateType.Disconnecting
|
||||
) {
|
||||
const groups = Object.values(nextSteps)
|
||||
for (let group of groups) {
|
||||
for (let step of group) {
|
||||
if (
|
||||
engineConnectionState.value.type === DisconnectingType.Error
|
||||
) {
|
||||
if (
|
||||
engineConnectionState.value.value.lastConnectingValue
|
||||
?.type === step[0]
|
||||
) {
|
||||
step[1] = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (engineConnectionState.value.type === DisconnectingType.Error) {
|
||||
setError(engineConnectionState.value.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reset the state of all steps if we have disconnected.
|
||||
if (
|
||||
engineConnectionState.type === EngineConnectionStateType.Disconnected
|
||||
) {
|
||||
return structuredClone(initialConnectingTypeGroupState)
|
||||
}
|
||||
|
||||
return nextSteps
|
||||
})
|
||||
}
|
||||
|
||||
const onEngineAvailable = ({ detail: engineConnection }: CustomEvent) => {
|
||||
engineConnection.addEventListener(
|
||||
EngineConnectionEvents.PingPongChanged,
|
||||
onPingPongChange as EventListener
|
||||
)
|
||||
engineConnection.addEventListener(
|
||||
EngineConnectionEvents.ConnectionStateChanged,
|
||||
onConnectionStateChange as EventListener
|
||||
)
|
||||
|
||||
// Tell EngineConnection to start firing events.
|
||||
window.dispatchEvent(new CustomEvent('use-network-status-ready', {}))
|
||||
}
|
||||
|
||||
engineCommandManager.addEventListener(
|
||||
EngineCommandManagerEvents.EngineAvailable,
|
||||
onEngineAvailable as EventListener
|
||||
)
|
||||
|
||||
return () => {
|
||||
engineCommandManager.removeEventListener(
|
||||
EngineCommandManagerEvents.EngineAvailable,
|
||||
onEngineAvailable as EventListener
|
||||
)
|
||||
|
||||
// When the component is unmounted these should be assigned, but it's possible
|
||||
// the component mounts and unmounts before engine is available.
|
||||
engineCommandManager.engineConnection?.addEventListener(
|
||||
EngineConnectionEvents.PingPongChanged,
|
||||
onPingPongChange as EventListener
|
||||
)
|
||||
engineCommandManager.engineConnection?.addEventListener(
|
||||
EngineConnectionEvents.ConnectionStateChanged,
|
||||
onConnectionStateChange as EventListener
|
||||
)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return {
|
||||
hasIssues,
|
||||
overallState,
|
||||
internetConnected,
|
||||
steps,
|
||||
issues,
|
||||
error,
|
||||
setHasCopied,
|
||||
hasCopied,
|
||||
pingPongHealth,
|
||||
}
|
||||
}
|
@ -43,7 +43,7 @@ export function useSetupEngineManager(
|
||||
engineCommandManager.pool = settings.pool
|
||||
}
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const startEngineInstance = () => {
|
||||
// Load the engine command manager once with the initial width and height,
|
||||
// then we do not want to reload it.
|
||||
const { width: quadWidth, height: quadHeight } = getDimensions(
|
||||
@ -73,7 +73,12 @@ export function useSetupEngineManager(
|
||||
})
|
||||
hasSetNonZeroDimensions.current = true
|
||||
}
|
||||
}, [streamRef?.current?.offsetWidth, streamRef?.current?.offsetHeight])
|
||||
}
|
||||
|
||||
useLayoutEffect(startEngineInstance, [
|
||||
streamRef?.current?.offsetWidth,
|
||||
streamRef?.current?.offsetHeight,
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = deferExecution(() => {
|
||||
@ -96,8 +101,20 @@ export function useSetupEngineManager(
|
||||
}
|
||||
}, 500)
|
||||
|
||||
const onOnline = () => {
|
||||
startEngineInstance()
|
||||
}
|
||||
|
||||
const onOffline = () => {
|
||||
engineCommandManager.tearDown()
|
||||
}
|
||||
|
||||
window.addEventListener('online', onOnline)
|
||||
window.addEventListener('offline', onOffline)
|
||||
window.addEventListener('resize', handleResize)
|
||||
return () => {
|
||||
window.removeEventListener('online', onOnline)
|
||||
window.removeEventListener('offline', onOffline)
|
||||
window.removeEventListener('resize', handleResize)
|
||||
}
|
||||
}, [])
|
||||
|
@ -7,12 +7,10 @@ import { authMachine } from 'machines/authMachine'
|
||||
import { settingsMachine } from 'machines/settingsMachine'
|
||||
import { homeMachine } from 'machines/homeMachine'
|
||||
import { Command, CommandSetConfig, CommandSetSchema } from 'lib/commandTypes'
|
||||
import {
|
||||
NetworkHealthState,
|
||||
useNetworkStatus,
|
||||
} from 'components/NetworkHealthIndicator'
|
||||
import { useKclContext } from 'lang/KclProvider'
|
||||
import { useStore } from 'useStore'
|
||||
import { useNetworkContext } from 'hooks/useNetworkContext'
|
||||
import { NetworkHealthState } from 'hooks/useNetworkStatus'
|
||||
|
||||
// This might not be necessary, AnyStateMachine from xstate is working
|
||||
export type AllMachines =
|
||||
@ -47,7 +45,7 @@ export default function useStateMachineCommands<
|
||||
onCancel,
|
||||
}: UseStateMachineCommandsArgs<T, S>) {
|
||||
const { commandBarSend } = useCommandsContext()
|
||||
const { overallState } = useNetworkStatus()
|
||||
const { overallState } = useNetworkContext()
|
||||
const { isExecuting } = useKclContext()
|
||||
const { isStreamReady } = useStore((s) => ({
|
||||
isStreamReady: s.isStreamReady,
|
||||
@ -55,7 +53,10 @@ export default function useStateMachineCommands<
|
||||
|
||||
useEffect(() => {
|
||||
const disableAllButtons =
|
||||
overallState !== NetworkHealthState.Ok || isExecuting || !isStreamReady
|
||||
(overallState !== NetworkHealthState.Ok &&
|
||||
overallState !== NetworkHealthState.Weak) ||
|
||||
isExecuting ||
|
||||
!isStreamReady
|
||||
const newCommands = state.nextEvents
|
||||
.filter((_) => !allCommandsRequireNetwork || !disableAllButtons)
|
||||
.filter((e) => !['done.', 'error.'].some((n) => e.includes(n)))
|
||||
|
@ -365,13 +365,6 @@ export class KclManager {
|
||||
void this.engineCommandManager.setPlaneHidden(this.defaultPlanes.yz, true)
|
||||
void this.engineCommandManager.setPlaneHidden(this.defaultPlanes.xz, true)
|
||||
}
|
||||
exitEditMode() {
|
||||
this.engineCommandManager.sendSceneCommand({
|
||||
type: 'modeling_cmd_req',
|
||||
cmd_id: uuidv4(),
|
||||
cmd: { type: 'edit_mode_exit' },
|
||||
})
|
||||
}
|
||||
defaultSelectionFilter() {
|
||||
defaultSelectionFilter(this.programMemory, this.engineCommandManager)
|
||||
}
|
||||
@ -386,24 +379,11 @@ function defaultSelectionFilter(
|
||||
) as SketchGroup | ExtrudeGroup
|
||||
firstSketchOrExtrudeGroup &&
|
||||
engineCommandManager.sendSceneCommand({
|
||||
type: 'modeling_cmd_batch_req',
|
||||
batch_id: uuidv4(),
|
||||
responses: false,
|
||||
requests: [
|
||||
{
|
||||
cmd_id: uuidv4(),
|
||||
cmd: {
|
||||
type: 'edit_mode_enter',
|
||||
target: firstSketchOrExtrudeGroup.id,
|
||||
},
|
||||
},
|
||||
{
|
||||
cmd_id: uuidv4(),
|
||||
cmd: {
|
||||
type: 'set_selection_filter',
|
||||
filter: ['face', 'edge', 'solid2d'],
|
||||
},
|
||||
},
|
||||
],
|
||||
type: 'modeling_cmd_req',
|
||||
cmd_id: uuidv4(),
|
||||
cmd: {
|
||||
type: 'set_selection_filter',
|
||||
filter: ['face', 'edge', 'solid2d', 'curve'],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
@ -125,7 +125,7 @@ describe('Testing addSketchTo', () => {
|
||||
'yz'
|
||||
)
|
||||
const str = recast(result.modifiedAst)
|
||||
expect(str).toBe(`const part001 = startSketchOn('YZ')
|
||||
expect(str).toBe(`const sketch001 = startSketchOn('YZ')
|
||||
|> startProfileAt('default', %)
|
||||
|> line('default', %)
|
||||
`)
|
||||
@ -291,14 +291,25 @@ describe('testing sketchOnExtrudedFace', () => {
|
||||
|> extrude(5 + 7, %)`
|
||||
const ast = parse(code)
|
||||
const programMemory = await enginelessExecutor(ast)
|
||||
const snippet = `line([9.7, 9.19], %)`
|
||||
const range: [number, number] = [
|
||||
code.indexOf(snippet),
|
||||
code.indexOf(snippet) + snippet.length,
|
||||
const segmentSnippet = `line([9.7, 9.19], %)`
|
||||
const segmentRange: [number, number] = [
|
||||
code.indexOf(segmentSnippet),
|
||||
code.indexOf(segmentSnippet) + segmentSnippet.length,
|
||||
]
|
||||
const pathToNode = getNodePathFromSourceRange(ast, range)
|
||||
const segmentPathToNode = getNodePathFromSourceRange(ast, segmentRange)
|
||||
const extrudeSnippet = `extrude(5 + 7, %)`
|
||||
const extrudeRange: [number, number] = [
|
||||
code.indexOf(extrudeSnippet),
|
||||
code.indexOf(extrudeSnippet) + extrudeSnippet.length,
|
||||
]
|
||||
const extrudePathToNode = getNodePathFromSourceRange(ast, extrudeRange)
|
||||
|
||||
const { modifiedAst } = sketchOnExtrudedFace(ast, pathToNode, programMemory)
|
||||
const { modifiedAst } = sketchOnExtrudedFace(
|
||||
ast,
|
||||
segmentPathToNode,
|
||||
extrudePathToNode,
|
||||
programMemory
|
||||
)
|
||||
const newCode = recast(modifiedAst)
|
||||
expect(newCode).toContain(`const part001 = startSketchOn('-XZ')
|
||||
|> startProfileAt([3.58, 2.06], %)
|
||||
@ -306,7 +317,7 @@ describe('testing sketchOnExtrudedFace', () => {
|
||||
|> line([8.62, -9.57], %)
|
||||
|> close(%)
|
||||
|> extrude(5 + 7, %)
|
||||
const part002 = startSketchOn(part001, 'seg01')`)
|
||||
const sketch001 = startSketchOn(part001, 'seg01')`)
|
||||
})
|
||||
test('it should be able to extrude on close segments', async () => {
|
||||
const code = `const part001 = startSketchOn('-XZ')
|
||||
@ -317,14 +328,25 @@ const part002 = startSketchOn(part001, 'seg01')`)
|
||||
|> extrude(5 + 7, %)`
|
||||
const ast = parse(code)
|
||||
const programMemory = await enginelessExecutor(ast)
|
||||
const snippet = `close(%)`
|
||||
const range: [number, number] = [
|
||||
code.indexOf(snippet),
|
||||
code.indexOf(snippet) + snippet.length,
|
||||
const segmentSnippet = `close(%)`
|
||||
const segmentRange: [number, number] = [
|
||||
code.indexOf(segmentSnippet),
|
||||
code.indexOf(segmentSnippet) + segmentSnippet.length,
|
||||
]
|
||||
const pathToNode = getNodePathFromSourceRange(ast, range)
|
||||
const segmentPathToNode = getNodePathFromSourceRange(ast, segmentRange)
|
||||
const extrudeSnippet = `extrude(5 + 7, %)`
|
||||
const extrudeRange: [number, number] = [
|
||||
code.indexOf(extrudeSnippet),
|
||||
code.indexOf(extrudeSnippet) + extrudeSnippet.length,
|
||||
]
|
||||
const extrudePathToNode = getNodePathFromSourceRange(ast, extrudeRange)
|
||||
|
||||
const { modifiedAst } = sketchOnExtrudedFace(ast, pathToNode, programMemory)
|
||||
const { modifiedAst } = sketchOnExtrudedFace(
|
||||
ast,
|
||||
segmentPathToNode,
|
||||
extrudePathToNode,
|
||||
programMemory
|
||||
)
|
||||
const newCode = recast(modifiedAst)
|
||||
expect(newCode).toContain(`const part001 = startSketchOn('-XZ')
|
||||
|> startProfileAt([3.58, 2.06], %)
|
||||
@ -332,7 +354,7 @@ const part002 = startSketchOn(part001, 'seg01')`)
|
||||
|> line([8.62, -9.57], %)
|
||||
|> close(%, 'seg01')
|
||||
|> extrude(5 + 7, %)
|
||||
const part002 = startSketchOn(part001, 'seg01')`)
|
||||
const sketch001 = startSketchOn(part001, 'seg01')`)
|
||||
})
|
||||
test('it should be able to extrude on start-end caps', async () => {
|
||||
const code = `const part001 = startSketchOn('-XZ')
|
||||
@ -343,16 +365,23 @@ const part002 = startSketchOn(part001, 'seg01')`)
|
||||
|> extrude(5 + 7, %)`
|
||||
const ast = parse(code)
|
||||
const programMemory = await enginelessExecutor(ast)
|
||||
const snippet = `startProfileAt([3.58, 2.06], %)`
|
||||
const range: [number, number] = [
|
||||
code.indexOf(snippet),
|
||||
code.indexOf(snippet) + snippet.length,
|
||||
const sketchSnippet = `startProfileAt([3.58, 2.06], %)`
|
||||
const sketchRange: [number, number] = [
|
||||
code.indexOf(sketchSnippet),
|
||||
code.indexOf(sketchSnippet) + sketchSnippet.length,
|
||||
]
|
||||
const pathToNode = getNodePathFromSourceRange(ast, range)
|
||||
const sketchPathToNode = getNodePathFromSourceRange(ast, sketchRange)
|
||||
const extrudeSnippet = `extrude(5 + 7, %)`
|
||||
const extrudeRange: [number, number] = [
|
||||
code.indexOf(extrudeSnippet),
|
||||
code.indexOf(extrudeSnippet) + extrudeSnippet.length,
|
||||
]
|
||||
const extrudePathToNode = getNodePathFromSourceRange(ast, extrudeRange)
|
||||
|
||||
const { modifiedAst } = sketchOnExtrudedFace(
|
||||
ast,
|
||||
pathToNode,
|
||||
sketchPathToNode,
|
||||
extrudePathToNode,
|
||||
programMemory,
|
||||
'end'
|
||||
)
|
||||
@ -363,7 +392,47 @@ const part002 = startSketchOn(part001, 'seg01')`)
|
||||
|> line([8.62, -9.57], %)
|
||||
|> close(%)
|
||||
|> extrude(5 + 7, %)
|
||||
const part002 = startSketchOn(part001, 'END')`)
|
||||
const sketch001 = startSketchOn(part001, 'END')`)
|
||||
})
|
||||
test('it should ensure that the new sketch is inserted after the extrude', async () => {
|
||||
const code = `const sketch001 = startSketchOn('-XZ')
|
||||
|> startProfileAt([3.29, 7.86], %)
|
||||
|> line([2.48, 2.44], %)
|
||||
|> line([2.66, 1.17], %)
|
||||
|> line([3.75, 0.46], %)
|
||||
|> line([4.99, -0.46], %)
|
||||
|> line([3.3, -2.12], %)
|
||||
|> line([2.16, -3.33], %)
|
||||
|> line([0.85, -3.08], %)
|
||||
|> line([-0.18, -3.36], %)
|
||||
|> line([-3.86, -2.73], %)
|
||||
|> line([-17.67, 0.85], %)
|
||||
|> close(%)
|
||||
const part001 = extrude(5 + 7, sketch001)`
|
||||
const ast = parse(code)
|
||||
const programMemory = await enginelessExecutor(ast)
|
||||
const segmentSnippet = `line([4.99, -0.46], %)`
|
||||
const segmentRange: [number, number] = [
|
||||
code.indexOf(segmentSnippet),
|
||||
code.indexOf(segmentSnippet) + segmentSnippet.length,
|
||||
]
|
||||
const segmentPathToNode = getNodePathFromSourceRange(ast, segmentRange)
|
||||
const extrudeSnippet = `extrude(5 + 7, sketch001)`
|
||||
const extrudeRange: [number, number] = [
|
||||
code.indexOf(extrudeSnippet),
|
||||
code.indexOf(extrudeSnippet) + extrudeSnippet.length,
|
||||
]
|
||||
const extrudePathToNode = getNodePathFromSourceRange(ast, extrudeRange)
|
||||
|
||||
const { modifiedAst } = sketchOnExtrudedFace(
|
||||
ast,
|
||||
segmentPathToNode,
|
||||
extrudePathToNode,
|
||||
programMemory
|
||||
)
|
||||
const newCode = recast(modifiedAst)
|
||||
expect(newCode).toContain(`const part001 = extrude(5 + 7, sketch001)
|
||||
const sketch002 = startSketchOn(part001, 'seg01')`)
|
||||
})
|
||||
})
|
||||
|
||||
|
@ -34,6 +34,7 @@ import {
|
||||
} from './std/sketchcombos'
|
||||
import { DefaultPlaneStr } from 'clientSideScene/sceneEntities'
|
||||
import { isOverlap, roundOff } from 'lib/utils'
|
||||
import { KCL_DEFAULT_CONSTANT_PREFIXES } from 'lib/constants'
|
||||
import { ConstrainInfo } from './std/stdTypes'
|
||||
|
||||
export function startSketchOnDefault(
|
||||
@ -42,7 +43,8 @@ export function startSketchOnDefault(
|
||||
name = ''
|
||||
): { modifiedAst: Program; id: string; pathToNode: PathToNode } {
|
||||
const _node = { ...node }
|
||||
const _name = name || findUniqueName(node, 'part')
|
||||
const _name =
|
||||
name || findUniqueName(node, KCL_DEFAULT_CONSTANT_PREFIXES.SKETCH)
|
||||
|
||||
const startSketchOn = createCallExpressionStdLib('startSketchOn', [
|
||||
createLiteral(axis),
|
||||
@ -109,7 +111,8 @@ export function addSketchTo(
|
||||
name = ''
|
||||
): { modifiedAst: Program; id: string; pathToNode: PathToNode } {
|
||||
const _node = { ...node }
|
||||
const _name = name || findUniqueName(node, 'part')
|
||||
const _name =
|
||||
name || findUniqueName(node, KCL_DEFAULT_CONSTANT_PREFIXES.SKETCH)
|
||||
|
||||
const startSketchOn = createCallExpressionStdLib('startSketchOn', [
|
||||
createLiteral(axis.toUpperCase()),
|
||||
@ -242,7 +245,7 @@ export function mutateObjExpProp(
|
||||
export function extrudeSketch(
|
||||
node: Program,
|
||||
pathToNode: PathToNode,
|
||||
shouldPipe = true,
|
||||
shouldPipe = false,
|
||||
distance = createLiteral(4) as Value
|
||||
): {
|
||||
modifiedAst: Program
|
||||
@ -293,12 +296,22 @@ export function extrudeSketch(
|
||||
pathToExtrudeArg,
|
||||
}
|
||||
}
|
||||
const name = findUniqueName(node, 'part')
|
||||
|
||||
// We're not creating a pipe expression,
|
||||
// but rather a separate constant for the extrusion
|
||||
const name = findUniqueName(node, KCL_DEFAULT_CONSTANT_PREFIXES.EXTRUDE)
|
||||
const VariableDeclaration = createVariableDeclaration(name, extrudeCall)
|
||||
_node.body.splice(_node.body.length, 0, VariableDeclaration)
|
||||
|
||||
const sketchIndexInPathToNode =
|
||||
pathToDecleration.findIndex((a) => a[0] === 'body') + 1
|
||||
const sketchIndexInBody = pathToDecleration[
|
||||
sketchIndexInPathToNode
|
||||
][0] as number
|
||||
_node.body.splice(sketchIndexInBody + 1, 0, VariableDeclaration)
|
||||
|
||||
const pathToExtrudeArg: PathToNode = [
|
||||
['body', ''],
|
||||
[_node.body.length, 'index'],
|
||||
[sketchIndexInBody + 1, 'index'],
|
||||
['declarations', 'VariableDeclaration'],
|
||||
[0, 'index'],
|
||||
['init', 'VariableDeclarator'],
|
||||
@ -306,7 +319,7 @@ export function extrudeSketch(
|
||||
[0, 'index'],
|
||||
]
|
||||
return {
|
||||
modifiedAst: node,
|
||||
modifiedAst: _node,
|
||||
pathToNode: [...pathToNode.slice(0, -1), [-1, 'index']],
|
||||
pathToExtrudeArg,
|
||||
}
|
||||
@ -314,31 +327,41 @@ export function extrudeSketch(
|
||||
|
||||
export function sketchOnExtrudedFace(
|
||||
node: Program,
|
||||
pathToNode: PathToNode,
|
||||
sketchPathToNode: PathToNode,
|
||||
extrudePathToNode: PathToNode,
|
||||
programMemory: ProgramMemory,
|
||||
cap: 'none' | 'start' | 'end' = 'none'
|
||||
): { modifiedAst: Program; pathToNode: PathToNode } {
|
||||
let _node = { ...node }
|
||||
const newSketchName = findUniqueName(node, 'part')
|
||||
const newSketchName = findUniqueName(
|
||||
node,
|
||||
KCL_DEFAULT_CONSTANT_PREFIXES.SKETCH
|
||||
)
|
||||
const { node: oldSketchNode } = getNodeFromPath<VariableDeclarator>(
|
||||
_node,
|
||||
pathToNode,
|
||||
sketchPathToNode,
|
||||
'VariableDeclarator',
|
||||
true
|
||||
)
|
||||
const oldSketchName = oldSketchNode.id.name
|
||||
const { node: expression } = getNodeFromPath<CallExpression>(
|
||||
_node,
|
||||
pathToNode,
|
||||
sketchPathToNode,
|
||||
'CallExpression'
|
||||
)
|
||||
const { node: extrudeVarDec } = getNodeFromPath<VariableDeclarator>(
|
||||
_node,
|
||||
extrudePathToNode,
|
||||
'VariableDeclarator'
|
||||
)
|
||||
const extrudeName = extrudeVarDec.id?.name
|
||||
|
||||
let _tag = ''
|
||||
if (cap === 'none') {
|
||||
const { modifiedAst, tag } = addTagForSketchOnFace(
|
||||
{
|
||||
previousProgramMemory: programMemory,
|
||||
pathToNode,
|
||||
pathToNode: sketchPathToNode,
|
||||
node: _node,
|
||||
},
|
||||
expression.callee.name
|
||||
@ -352,13 +375,16 @@ export function sketchOnExtrudedFace(
|
||||
const newSketch = createVariableDeclaration(
|
||||
newSketchName,
|
||||
createCallExpressionStdLib('startSketchOn', [
|
||||
createIdentifier(oldSketchName),
|
||||
createIdentifier(extrudeName ? extrudeName : oldSketchName),
|
||||
createLiteral(_tag),
|
||||
]),
|
||||
'const'
|
||||
)
|
||||
|
||||
const expressionIndex = pathToNode[1][0] as number
|
||||
const expressionIndex = Math.max(
|
||||
sketchPathToNode[1][0] as number,
|
||||
extrudePathToNode[1][0] as number
|
||||
)
|
||||
_node.body.splice(expressionIndex + 1, 0, newSketch)
|
||||
const newpathToNode: PathToNode = [
|
||||
['body', ''],
|
||||
|
@ -341,36 +341,29 @@ const setAbsDistanceCreateNode =
|
||||
isXOrYLine = false,
|
||||
index = xOrY === 'x' ? 0 : 1
|
||||
): TransformInfo['createNode'] =>
|
||||
({ tag, forceValueUsedInTransform }) => {
|
||||
return (args, _, referencedSegment) => {
|
||||
const valueUsedInTransform = roundOff(
|
||||
getArgLiteralVal(args?.[index]) - (referencedSegment?.to?.[index] || 0),
|
||||
2
|
||||
)
|
||||
const val =
|
||||
(forceValueUsedInTransform as BinaryPart) ||
|
||||
createLiteral(valueUsedInTransform)
|
||||
if (isXOrYLine) {
|
||||
return createCallWrapper(
|
||||
xOrY === 'x' ? 'xLineTo' : 'yLineTo',
|
||||
val,
|
||||
tag,
|
||||
valueUsedInTransform
|
||||
)
|
||||
}
|
||||
({ tag, forceValueUsedInTransform }) =>
|
||||
(args) => {
|
||||
const valueUsedInTransform = roundOff(getArgLiteralVal(args?.[index]), 2)
|
||||
const val =
|
||||
(forceValueUsedInTransform as BinaryPart) ||
|
||||
createLiteral(valueUsedInTransform)
|
||||
if (isXOrYLine) {
|
||||
return createCallWrapper(
|
||||
'lineTo',
|
||||
!index ? [val, args[1]] : [args[0], val],
|
||||
xOrY === 'x' ? 'xLineTo' : 'yLineTo',
|
||||
val,
|
||||
tag,
|
||||
valueUsedInTransform
|
||||
)
|
||||
}
|
||||
return createCallWrapper(
|
||||
'lineTo',
|
||||
!index ? [val, args[1]] : [args[0], val],
|
||||
tag,
|
||||
valueUsedInTransform
|
||||
)
|
||||
}
|
||||
const setAbsDistanceForAngleLineCreateNode =
|
||||
(
|
||||
xOrY: 'x' | 'y',
|
||||
index = xOrY === 'x' ? 0 : 1
|
||||
): TransformInfo['createNode'] =>
|
||||
(xOrY: 'x' | 'y'): TransformInfo['createNode'] =>
|
||||
({ tag, forceValueUsedInTransform, varValA }) => {
|
||||
return (args) => {
|
||||
const valueUsedInTransform = roundOff(getArgLiteralVal(args?.[1]), 2)
|
||||
|
@ -26,6 +26,22 @@ export function pathMapToSelections(
|
||||
return newSelections
|
||||
}
|
||||
|
||||
export function updatePathToNodeFromMap(
|
||||
oldPath: PathToNode,
|
||||
pathToNodeMap: { [key: number]: PathToNode }
|
||||
): PathToNode {
|
||||
const updatedPathToNode = JSON.parse(JSON.stringify(oldPath))
|
||||
let max = 0
|
||||
Object.values(pathToNodeMap).forEach((path) => {
|
||||
const index = Number(path[1][0])
|
||||
if (index > max) {
|
||||
max = index
|
||||
}
|
||||
})
|
||||
updatedPathToNode[1][0] = max
|
||||
return updatedPathToNode
|
||||
}
|
||||
|
||||
export function isCursorInSketchCommandRange(
|
||||
artifactMap: ArtifactMap,
|
||||
selectionRanges: Selections
|
||||
|
@ -44,5 +44,13 @@ export const RELEVANT_FILE_TYPES = [
|
||||
] as const
|
||||
/** The default name for a tutorial project */
|
||||
export const ONBOARDING_PROJECT_NAME = 'Tutorial Project $nn'
|
||||
/**
|
||||
* The default starting constant name for various modeling operations.
|
||||
* These are used to generate unique names for new objects.
|
||||
* */
|
||||
export const KCL_DEFAULT_CONSTANT_PREFIXES = {
|
||||
SKETCH: 'sketch',
|
||||
EXTRUDE: 'extrude',
|
||||
} as const
|
||||
/** The default KCL length expression */
|
||||
export const KCL_DEFAULT_LENGTH = `5`
|
||||
|
@ -69,6 +69,13 @@ export const getRectangleCallExpressions = (
|
||||
createPipeSubstitution(),
|
||||
createLiteral(tags[2]),
|
||||
]),
|
||||
createCallExpressionStdLib('lineTo', [
|
||||
createArrayExpression([
|
||||
createCallExpressionStdLib('profileStartX', [createPipeSubstitution()]),
|
||||
createCallExpressionStdLib('profileStartY', [createPipeSubstitution()]),
|
||||
]),
|
||||
createPipeSubstitution(),
|
||||
]), // close the rectangle
|
||||
createCallExpressionStdLib('close', [createPipeSubstitution()]),
|
||||
]
|
||||
|
||||
|
@ -5,7 +5,7 @@ import {
|
||||
kclManager,
|
||||
sceneEntitiesManager,
|
||||
} from 'lib/singletons'
|
||||
import { CallExpression, SourceRange, parse, recast } from 'lang/wasm'
|
||||
import { CallExpression, SourceRange, Value, parse, recast } from 'lang/wasm'
|
||||
import { ModelingMachineEvent } from 'machines/modelingMachine'
|
||||
import { uuidv4 } from 'lib/utils'
|
||||
import { EditorSelection } from '@codemirror/state'
|
||||
@ -27,6 +27,7 @@ import {
|
||||
} from 'clientSideScene/sceneEntities'
|
||||
import { Mesh, Object3D, Object3DEventMap } from 'three'
|
||||
import { AXIS_GROUP, X_AXIS } from 'clientSideScene/sceneInfra'
|
||||
import { PathToNodeMap } from 'lang/std/sketchcombos'
|
||||
|
||||
export const X_AXIS_UUID = 'ad792545-7fd3-482a-a602-a93924e3055b'
|
||||
export const Y_AXIS_UUID = '680fd157-266f-4b8a-984f-cdf46b8bdf01'
|
||||
@ -317,7 +318,6 @@ function resetAndSetEngineEntitySelectionCmds(
|
||||
selections: SelectionToEngine[]
|
||||
): Models['WebSocketRequest_type'][] {
|
||||
if (!engineCommandManager.engineConnection?.isReady()) {
|
||||
console.log('engine connection is not ready')
|
||||
return []
|
||||
}
|
||||
return [
|
||||
@ -564,3 +564,22 @@ export function sendSelectEventToEngine(
|
||||
.then((res) => res.data.data)
|
||||
return result
|
||||
}
|
||||
|
||||
export function updateSelections(
|
||||
pathToNodeMap: PathToNodeMap,
|
||||
prevSelectionRanges: Selections,
|
||||
ast: Program
|
||||
): Selections {
|
||||
return {
|
||||
...prevSelectionRanges,
|
||||
codeBasedSelections: Object.entries(pathToNodeMap).map(
|
||||
([index, pathToNode]): Selection => {
|
||||
const node = getNodeFromPath<Value>(ast, pathToNode).node
|
||||
return {
|
||||
range: [node.start, node.end],
|
||||
type: prevSelectionRanges.codeBasedSelections[Number(index)]?.type,
|
||||
}
|
||||
}
|
||||
),
|
||||
}
|
||||
}
|
||||
|
182
src/lib/settings/initialKeybindings.ts
Normal file
@ -0,0 +1,182 @@
|
||||
import { isTauri } from 'lib/isTauri'
|
||||
|
||||
export type InteractionMapItem = {
|
||||
name: string
|
||||
sequence: string
|
||||
title: string
|
||||
description: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Controls both the available names for interaction map categories
|
||||
* and the order in which they are displayed.
|
||||
*/
|
||||
export const interactionMapCategories = [
|
||||
'Sketching',
|
||||
'Modeling',
|
||||
'Command Palette',
|
||||
'Settings',
|
||||
'Panes',
|
||||
'Code Editor',
|
||||
'File Tree',
|
||||
'Miscellaneous',
|
||||
]
|
||||
|
||||
type InteractionMapCategory = (typeof interactionMapCategories)[number]
|
||||
|
||||
/**
|
||||
* A temporary implementation of the interaction map for
|
||||
* display purposes only.
|
||||
* @todo Implement a proper interaction map
|
||||
* that can be edited, saved, and loaded. This is underway in the
|
||||
* franknoirot/editable-hotkeys branch.
|
||||
*/
|
||||
export const interactionMap: Record<
|
||||
InteractionMapCategory,
|
||||
InteractionMapItem[]
|
||||
> = {
|
||||
Settings: [
|
||||
{
|
||||
name: 'toggle-settings',
|
||||
sequence: isTauri() ? 'Meta+,' : 'Shift+Meta+,',
|
||||
title: 'Toggle Settings',
|
||||
description: 'Opens the settings dialog. Always available.',
|
||||
},
|
||||
{
|
||||
name: 'settings-search',
|
||||
sequence: 'Control+.',
|
||||
title: 'Settings Search',
|
||||
description:
|
||||
'Focus the settings search input. Available when settings are open.',
|
||||
},
|
||||
],
|
||||
'Command Palette': [
|
||||
{
|
||||
name: 'toggle-command-palette',
|
||||
sequence: 'Meta+K',
|
||||
title: 'Toggle Command Palette',
|
||||
description: 'Always available. Use Ctrl+/ on Windows/Linux.',
|
||||
},
|
||||
],
|
||||
Panes: [
|
||||
{
|
||||
name: 'toggle-code-pane',
|
||||
sequence: 'Shift+C',
|
||||
title: 'Toggle Code Pane',
|
||||
description:
|
||||
'Available while modeling when not typing in the code editor.',
|
||||
},
|
||||
{
|
||||
name: 'toggle-variables-pane',
|
||||
sequence: 'Shift+V',
|
||||
title: 'Toggle Variables Pane',
|
||||
description:
|
||||
'Available while modeling when not typing in the code editor.',
|
||||
},
|
||||
{
|
||||
name: 'toggle-logs-pane',
|
||||
sequence: 'Shift+L',
|
||||
title: 'Toggle Logs Pane',
|
||||
description:
|
||||
'Available while modeling when not typing in the code editor.',
|
||||
},
|
||||
{
|
||||
name: 'toggle-errors-pane',
|
||||
sequence: 'Shift+E',
|
||||
title: 'Toggle Errors Pane',
|
||||
description:
|
||||
'Available while modeling when not typing in the code editor.',
|
||||
},
|
||||
],
|
||||
Sketching: [
|
||||
{
|
||||
name: 'enter-sketch-mode',
|
||||
sequence: 'S',
|
||||
title: 'Enter Sketch Mode',
|
||||
description:
|
||||
'Available while modeling when not typing in the code editor.',
|
||||
},
|
||||
{
|
||||
name: 'unequip-sketch-tool',
|
||||
sequence: 'Escape',
|
||||
title: 'Unequip Sketch Tool',
|
||||
description:
|
||||
'Unequips the current sketch tool. Available while sketching.',
|
||||
},
|
||||
{
|
||||
name: 'exit-sketch-mode',
|
||||
sequence: 'Escape',
|
||||
title: 'Exit Sketch Mode',
|
||||
description: 'Available while sketching, if no sketch tool is equipped.',
|
||||
},
|
||||
{
|
||||
name: 'toggle-line-tool',
|
||||
sequence: 'L',
|
||||
title: 'Toggle Line Tool',
|
||||
description:
|
||||
'Available while sketching, when not typing in the code editor.',
|
||||
},
|
||||
{
|
||||
name: 'toggle-rectangle-tool',
|
||||
sequence: 'R',
|
||||
title: 'Toggle Rectangle Tool',
|
||||
description:
|
||||
'Available while sketching, when not typing in the code editor.',
|
||||
},
|
||||
{
|
||||
name: 'toggle-arc-tool',
|
||||
sequence: 'A',
|
||||
title: 'Toggle Arc Tool',
|
||||
description:
|
||||
'Available while sketching, when not typing in the code editor.',
|
||||
},
|
||||
],
|
||||
Modeling: [
|
||||
{
|
||||
name: 'extrude',
|
||||
sequence: 'E',
|
||||
title: 'Extrude',
|
||||
description:
|
||||
'Available while modeling with either a face selected or an empty selection, when not typing in the code editor.',
|
||||
},
|
||||
],
|
||||
'Code Editor': [
|
||||
{
|
||||
name: 'format-code',
|
||||
sequence: 'Shift+Alt+F',
|
||||
title: 'Format Code',
|
||||
description:
|
||||
'Nicely formats the KCL code in the editor, available when the editor is focused.',
|
||||
},
|
||||
],
|
||||
'File Tree': [
|
||||
{
|
||||
name: 'rename-file',
|
||||
sequence: 'Enter',
|
||||
title: 'Rename File/Folder',
|
||||
description:
|
||||
'Available when a file or folder is selected in the file tree.',
|
||||
},
|
||||
{
|
||||
name: 'delete-file',
|
||||
sequence: 'Meta+Backspace',
|
||||
title: 'Delete File/Folder',
|
||||
description:
|
||||
'Available when a file or folder is selected in the file tree.',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
/**
|
||||
* Sorts interaction map categories by their order in the
|
||||
* `interactionMapCategories` array.
|
||||
*/
|
||||
export function sortInteractionMapByCategory(
|
||||
[categoryA]: [InteractionMapCategory, InteractionMapItem[]],
|
||||
[categoryB]: [InteractionMapCategory, InteractionMapItem[]]
|
||||
) {
|
||||
return (
|
||||
interactionMapCategories.indexOf(categoryA) -
|
||||
interactionMapCategories.indexOf(categoryB)
|
||||
)
|
||||
}
|
@ -127,3 +127,7 @@ export function isReducedMotion(): boolean {
|
||||
window.matchMedia('(prefers-reduced-motion)').matches
|
||||
)
|
||||
}
|
||||
|
||||
export function XOR(bool1: boolean, bool2: boolean): boolean {
|
||||
return (bool1 || bool2) && !(bool1 && bool2)
|
||||
}
|
||||
|
@ -1,28 +1,17 @@
|
||||
import { ActionButton } from '../components/ActionButton'
|
||||
import { SetEventTypes, SettingsLevel } from 'lib/settings/settingsTypes'
|
||||
import { SettingsLevel } from 'lib/settings/settingsTypes'
|
||||
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { useHotkeys } from 'react-hotkeys-hook'
|
||||
import { paths } from 'lib/paths'
|
||||
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
|
||||
import { useDotDotSlash } from 'hooks/useDotDotSlash'
|
||||
import { createAndOpenNewProject, getSettingsFolderPaths } from 'lib/tauriFS'
|
||||
import { sep } from '@tauri-apps/api/path'
|
||||
import { isTauri } from 'lib/isTauri'
|
||||
import toast from 'react-hot-toast'
|
||||
import { Fragment, useEffect, useRef } from 'react'
|
||||
import { Setting } from 'lib/settings/initialSettings'
|
||||
import decamelize from 'decamelize'
|
||||
import { Dialog, Transition } from '@headlessui/react'
|
||||
import { CustomIcon } from 'components/CustomIcon'
|
||||
import {
|
||||
shouldHideSetting,
|
||||
shouldShowSettingInput,
|
||||
} from 'lib/settings/settingsUtils'
|
||||
import { getInitialDefaultDir, showInFolder } from 'lib/tauri'
|
||||
import { SettingsSearchBar } from 'components/Settings/SettingsSearchBar'
|
||||
import { SettingsTabs } from 'components/Settings/SettingsTabs'
|
||||
import { SettingsSection } from 'components/Settings/SettingsSection'
|
||||
import { SettingsFieldInput } from 'components/Settings/SettingsFieldInput'
|
||||
import { SettingsSectionsList } from 'components/Settings/SettingsSectionsList'
|
||||
import { AllSettingsFields } from 'components/Settings/AllSettingsFields'
|
||||
import { AllKeybindingsFields } from 'components/Settings/AllKeybindingsFields'
|
||||
import { KeybindingsSectionsList } from 'components/Settings/KeybindingsSectionsList'
|
||||
|
||||
export const APP_VERSION = import.meta.env.PACKAGE_VERSION || 'unknown'
|
||||
|
||||
@ -33,40 +22,12 @@ export const Settings = () => {
|
||||
const location = useLocation()
|
||||
const isFileSettings = location.pathname.includes(paths.FILE)
|
||||
const searchParamTab =
|
||||
(searchParams.get('tab') as SettingsLevel) ??
|
||||
(searchParams.get('tab') as SettingsLevel | 'keybindings') ??
|
||||
(isFileSettings ? 'project' : 'user')
|
||||
const projectPath =
|
||||
isFileSettings && isTauri()
|
||||
? decodeURI(
|
||||
location.pathname
|
||||
.replace(paths.FILE + '/', '')
|
||||
.replace(paths.SETTINGS, '')
|
||||
.slice(0, decodeURI(location.pathname).lastIndexOf(sep()))
|
||||
)
|
||||
: undefined
|
||||
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const dotDotSlash = useDotDotSlash()
|
||||
useHotkeys('esc', () => navigate(dotDotSlash()))
|
||||
const {
|
||||
settings: {
|
||||
send,
|
||||
state: { context },
|
||||
},
|
||||
} = useSettingsAuthContext()
|
||||
|
||||
function restartOnboarding() {
|
||||
send({
|
||||
type: `set.app.onboardingStatus`,
|
||||
data: { level: 'user', value: '' },
|
||||
})
|
||||
|
||||
if (isFileSettings) {
|
||||
navigate(dotDotSlash(1) + paths.ONBOARDING.INDEX)
|
||||
} else {
|
||||
createAndOpenNewProject(navigate)
|
||||
}
|
||||
}
|
||||
|
||||
// Scroll to the hash on load if it exists
|
||||
useEffect(() => {
|
||||
@ -137,233 +98,24 @@ export const Settings = () => {
|
||||
gridTemplateRows: '1fr',
|
||||
}}
|
||||
>
|
||||
<div className="flex w-32 flex-col gap-3 pr-2 py-1 border-0 border-r border-r-chalkboard-20 dark:border-r-chalkboard-90">
|
||||
{Object.entries(context)
|
||||
.filter(([_, categorySettings]) =>
|
||||
// Filter out categories that don't have any non-hidden settings
|
||||
Object.values(categorySettings).some(
|
||||
(setting: Setting) =>
|
||||
!shouldHideSetting(setting, searchParamTab)
|
||||
)
|
||||
)
|
||||
.map(([category]) => (
|
||||
<button
|
||||
key={category}
|
||||
onClick={() =>
|
||||
scrollRef.current
|
||||
?.querySelector(`#category-${category}`)
|
||||
?.scrollIntoView({
|
||||
block: 'center',
|
||||
behavior: 'smooth',
|
||||
})
|
||||
}
|
||||
className="capitalize text-left border-none px-1"
|
||||
>
|
||||
{decamelize(category, { separator: ' ' })}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
onClick={() =>
|
||||
scrollRef.current
|
||||
?.querySelector(`#settings-resets`)
|
||||
?.scrollIntoView({
|
||||
block: 'center',
|
||||
behavior: 'smooth',
|
||||
})
|
||||
}
|
||||
className="capitalize text-left border-none px-1"
|
||||
>
|
||||
Resets
|
||||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
scrollRef.current
|
||||
?.querySelector(`#settings-about`)
|
||||
?.scrollIntoView({
|
||||
block: 'center',
|
||||
behavior: 'smooth',
|
||||
})
|
||||
}
|
||||
className="capitalize text-left border-none px-1"
|
||||
>
|
||||
About
|
||||
</button>
|
||||
</div>
|
||||
<div className="relative overflow-y-auto">
|
||||
<div ref={scrollRef} className="flex flex-col gap-4 px-2">
|
||||
{Object.entries(context)
|
||||
.filter(([_, categorySettings]) =>
|
||||
// Filter out categories that don't have any non-hidden settings
|
||||
Object.values(categorySettings).some(
|
||||
(setting) => !shouldHideSetting(setting, searchParamTab)
|
||||
)
|
||||
)
|
||||
.map(([category, categorySettings]) => (
|
||||
<Fragment key={category}>
|
||||
<h2
|
||||
id={`category-${category}`}
|
||||
className="text-xl mt-6 first-of-type:mt-0 capitalize font-bold"
|
||||
>
|
||||
{decamelize(category, { separator: ' ' })}
|
||||
</h2>
|
||||
{Object.entries(categorySettings)
|
||||
.filter(
|
||||
// Filter out settings that don't have a Component or inputType
|
||||
// or are hidden on the current level or the current platform
|
||||
(item: [string, Setting<unknown>]) =>
|
||||
shouldShowSettingInput(item[1], searchParamTab)
|
||||
)
|
||||
.map(([settingName, s]) => {
|
||||
const setting = s as Setting
|
||||
const parentValue =
|
||||
setting[setting.getParentLevel(searchParamTab)]
|
||||
return (
|
||||
<SettingsSection
|
||||
title={decamelize(settingName, {
|
||||
separator: ' ',
|
||||
})}
|
||||
id={settingName}
|
||||
className={
|
||||
location.hash === `#${settingName}`
|
||||
? 'bg-primary/10 dark:bg-chalkboard-90'
|
||||
: ''
|
||||
}
|
||||
key={`${category}-${settingName}-${searchParamTab}`}
|
||||
description={setting.description}
|
||||
settingHasChanged={
|
||||
setting[searchParamTab] !== undefined &&
|
||||
setting[searchParamTab] !==
|
||||
setting.getFallback(searchParamTab)
|
||||
}
|
||||
parentLevel={setting.getParentLevel(
|
||||
searchParamTab
|
||||
)}
|
||||
onFallback={() =>
|
||||
send({
|
||||
type: `set.${category}.${settingName}`,
|
||||
data: {
|
||||
level: searchParamTab,
|
||||
value:
|
||||
parentValue !== undefined
|
||||
? parentValue
|
||||
: setting.getFallback(searchParamTab),
|
||||
},
|
||||
} as SetEventTypes)
|
||||
}
|
||||
>
|
||||
<SettingsFieldInput
|
||||
category={category}
|
||||
settingName={settingName}
|
||||
settingsLevel={searchParamTab}
|
||||
setting={setting}
|
||||
/>
|
||||
</SettingsSection>
|
||||
)
|
||||
})}
|
||||
</Fragment>
|
||||
))}
|
||||
<h2 id="settings-resets" className="text-2xl mt-6 font-bold">
|
||||
Resets
|
||||
</h2>
|
||||
<SettingsSection
|
||||
title="Onboarding"
|
||||
description="Replay the onboarding process"
|
||||
>
|
||||
<ActionButton
|
||||
Element="button"
|
||||
onClick={restartOnboarding}
|
||||
iconStart={{
|
||||
icon: 'refresh',
|
||||
size: 'sm',
|
||||
className: 'p-1',
|
||||
}}
|
||||
>
|
||||
Replay Onboarding
|
||||
</ActionButton>
|
||||
</SettingsSection>
|
||||
<SettingsSection
|
||||
title="Reset settings"
|
||||
description={`Restore settings to their default values. Your settings are saved in
|
||||
${
|
||||
isTauri()
|
||||
? ' a file in the app data folder for your OS.'
|
||||
: " your browser's local storage."
|
||||
}
|
||||
`}
|
||||
>
|
||||
<div className="flex flex-col items-start gap-4">
|
||||
{isTauri() && (
|
||||
<ActionButton
|
||||
Element="button"
|
||||
onClick={async () => {
|
||||
const paths = await getSettingsFolderPaths(
|
||||
projectPath
|
||||
? decodeURIComponent(projectPath)
|
||||
: undefined
|
||||
)
|
||||
showInFolder(paths[searchParamTab])
|
||||
}}
|
||||
iconStart={{
|
||||
icon: 'folder',
|
||||
size: 'sm',
|
||||
className: 'p-1',
|
||||
}}
|
||||
>
|
||||
Show in folder
|
||||
</ActionButton>
|
||||
)}
|
||||
<ActionButton
|
||||
Element="button"
|
||||
onClick={async () => {
|
||||
const defaultDirectory = await getInitialDefaultDir()
|
||||
send({
|
||||
type: 'Reset settings',
|
||||
defaultDirectory,
|
||||
})
|
||||
toast.success('Settings restored to default')
|
||||
}}
|
||||
iconStart={{
|
||||
icon: 'refresh',
|
||||
size: 'sm',
|
||||
className: 'p-1 text-chalkboard-10',
|
||||
bgClassName: 'bg-destroy-70',
|
||||
}}
|
||||
>
|
||||
Restore default settings
|
||||
</ActionButton>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
<h2 id="settings-about" className="text-2xl mt-6 font-bold">
|
||||
About Modeling App
|
||||
</h2>
|
||||
<div className="text-sm mb-12">
|
||||
<p>
|
||||
{/* This uses a Vite plugin, set in vite.config.ts
|
||||
to inject the version from package.json */}
|
||||
App version {APP_VERSION}.{' '}
|
||||
<a
|
||||
href={`https://github.com/KittyCAD/modeling-app/releases/tag/v${APP_VERSION}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
View release on GitHub
|
||||
</a>
|
||||
</p>
|
||||
<p className="max-w-2xl mt-6">
|
||||
Don't see the feature you want? Check to see if it's on{' '}
|
||||
<a
|
||||
href="https://github.com/KittyCAD/modeling-app/discussions"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
our roadmap
|
||||
</a>
|
||||
, and start a discussion if you don't see it! Your
|
||||
feedback will help us prioritize what to build next.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{searchParamTab !== 'keybindings' ? (
|
||||
<>
|
||||
<SettingsSectionsList
|
||||
searchParamTab={searchParamTab}
|
||||
scrollRef={scrollRef}
|
||||
/>
|
||||
<AllSettingsFields
|
||||
searchParamTab={searchParamTab}
|
||||
isFileSettings={isFileSettings}
|
||||
ref={scrollRef}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<KeybindingsSectionsList scrollRef={scrollRef} />
|
||||
<AllKeybindingsFields ref={scrollRef} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
|
1725
src/wasm-lib/Cargo.lock
generated
@ -16,20 +16,21 @@ gloo-utils = "0.2.0"
|
||||
kcl-lib = { path = "kcl" }
|
||||
kittycad = { workspace = true }
|
||||
serde_json = "1.0.116"
|
||||
tokio = { version = "1.37.0", features = ["sync"] }
|
||||
toml = "0.8.13"
|
||||
tokio = { version = "1.38.0", features = ["sync"] }
|
||||
toml = "0.8.14"
|
||||
uuid = { version = "1.8.0", features = ["v4", "js", "serde"] }
|
||||
wasm-bindgen = "0.2.91"
|
||||
wasm-bindgen-futures = "0.4.42"
|
||||
|
||||
[dev-dependencies]
|
||||
anyhow = "1"
|
||||
image = "0.24.9"
|
||||
hyper = { version = "0.14.29", features = ["server", "http1"] }
|
||||
image = { version = "0.25.1", default-features = false, features = ["png"] }
|
||||
kittycad = { workspace = true, default-features = true }
|
||||
pretty_assertions = "1.4.0"
|
||||
reqwest = { version = "0.11.26", default-features = false }
|
||||
tokio = { version = "1.37.0", features = ["rt-multi-thread", "macros", "time"] }
|
||||
twenty-twenty = "0.7"
|
||||
tokio = { version = "1.38.0", features = ["rt-multi-thread", "macros", "time"] }
|
||||
twenty-twenty = "0.8"
|
||||
uuid = { version = "1.8.0", features = ["v4", "js", "serde"] }
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
@ -53,20 +54,21 @@ features = [
|
||||
panic = "abort"
|
||||
debug = true
|
||||
|
||||
[profile.dev]
|
||||
debug = 0
|
||||
|
||||
[profile.test]
|
||||
debug = "line-tables-only"
|
||||
|
||||
[workspace]
|
||||
members = [
|
||||
"derive-docs",
|
||||
"grackle",
|
||||
"kcl",
|
||||
"kcl-macros",
|
||||
]
|
||||
|
||||
[workspace.dependencies]
|
||||
kittycad = { version = "0.3.3", default-features = false, features = ["js", "requests"] }
|
||||
kittycad-execution-plan = "0.1.6"
|
||||
kittycad-execution-plan-macros = "0.1.9"
|
||||
kittycad-execution-plan-traits = "0.1.14"
|
||||
kittycad-modeling-cmds = "0.2.24"
|
||||
kittycad-modeling-session = "0.1.4"
|
||||
|
||||
[[test]]
|
||||
@ -79,8 +81,5 @@ path = "tests/modify/main.rs"
|
||||
|
||||
# Example: how to point modeling-api at a different repo (e.g. a branch or a local clone)
|
||||
#[patch."https://github.com/KittyCAD/modeling-api"]
|
||||
#kittycad-execution-plan = { path = "../../../modeling-api/execution-plan" }
|
||||
#kittycad-execution-plan-macros = { path = "../../../modeling-api/execution-plan-macros" }
|
||||
#kittycad-execution-plan-traits = { path = "../../../modeling-api/execution-plan-traits" }
|
||||
#kittycad-modeling-cmds = { path = "../../../modeling-api/modeling-cmds" }
|
||||
#kittycad-modeling-session = { path = "../../../modeling-api/modeling-session" }
|
||||
|
@ -18,7 +18,7 @@ once_cell = "1.19.0"
|
||||
proc-macro2 = "1"
|
||||
quote = "1"
|
||||
regex = "1.10"
|
||||
serde = { version = "1.0.202", features = ["derive"] }
|
||||
serde = { version = "1.0.203", features = ["derive"] }
|
||||
serde_tokenstream = "0.2"
|
||||
syn = { version = "2.0.66", features = ["full"] }
|
||||
|
||||
|
@ -1,24 +0,0 @@
|
||||
[package]
|
||||
name = "grackle"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "A new executor for KCL which compiles to Execution Plans"
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
||||
image = { version = "0.24.7", default-features = false, features = ["png"] }
|
||||
kcl-lib = { path = "../kcl" }
|
||||
kittycad = { workspace = true }
|
||||
kittycad-execution-plan = { workspace = true }
|
||||
kittycad-execution-plan-traits = { workspace = true }
|
||||
kittycad-execution-plan-macros = { workspace = true }
|
||||
kittycad-modeling-cmds = { workspace = true }
|
||||
kittycad-modeling-session = { workspace = true }
|
||||
thiserror = "1.0.61"
|
||||
tokio = { version = "1.37.0", features = ["macros", "rt"] }
|
||||
twenty-twenty = "0.7.0"
|
||||
uuid = "1.8"
|
||||
|
||||
[dev-dependencies]
|
||||
pretty_assertions = "1"
|
||||
serde_json = "1.0.116"
|
Before Width: | Height: | Size: 80 KiB |
Before Width: | Height: | Size: 82 KiB |
Before Width: | Height: | Size: 79 KiB |
@ -1,270 +0,0 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use kcl_lib::ast::types::{LiteralIdentifier, LiteralValue};
|
||||
use kittycad_execution_plan::constants;
|
||||
use kittycad_execution_plan_traits::Primitive;
|
||||
|
||||
use super::{native_functions, Address};
|
||||
use crate::{CompileError, KclFunction};
|
||||
|
||||
/// KCL values which can be written to KCEP memory.
|
||||
/// This is recursive. For example, the bound value might be an array, which itself contains bound values.
|
||||
#[derive(Debug, Clone)]
|
||||
#[cfg_attr(test, derive(PartialEq))]
|
||||
pub enum EpBinding {
|
||||
/// A KCL value which gets stored in a particular address in KCEP memory.
|
||||
Single(Address),
|
||||
/// A sequence of KCL values, indexed by their position in the sequence.
|
||||
Sequence {
|
||||
length_at: Address,
|
||||
elements: Vec<EpBinding>,
|
||||
},
|
||||
/// A sequence of KCL values, indexed by their identifier.
|
||||
Map {
|
||||
length_at: Address,
|
||||
properties: HashMap<String, EpBinding>,
|
||||
},
|
||||
/// Not associated with a KCEP address.
|
||||
Constant(Primitive),
|
||||
/// Not associated with a KCEP address.
|
||||
Function(KclFunction),
|
||||
/// SketchGroups have their own storage.
|
||||
SketchGroup { index: usize },
|
||||
}
|
||||
|
||||
impl From<KclFunction> for EpBinding {
|
||||
fn from(f: KclFunction) -> Self {
|
||||
Self::Function(f)
|
||||
}
|
||||
}
|
||||
|
||||
impl EpBinding {
|
||||
/// Look up the given property of this binding.
|
||||
pub fn property_of(&self, property: LiteralIdentifier) -> Result<&Self, CompileError> {
|
||||
match property {
|
||||
LiteralIdentifier::Identifier(_) => todo!("Support identifier properties"),
|
||||
LiteralIdentifier::Literal(litval) => match litval.value {
|
||||
// Arrays can be indexed by integers.
|
||||
LiteralValue::IInteger(i) => match self {
|
||||
EpBinding::Sequence { elements, length_at: _ } => {
|
||||
let i = usize::try_from(i).map_err(|_| CompileError::InvalidIndex(i.to_string()))?;
|
||||
elements
|
||||
.get(i)
|
||||
.ok_or(CompileError::IndexOutOfBounds { i, len: elements.len() })
|
||||
}
|
||||
EpBinding::Map { .. } => Err(CompileError::CannotIndex),
|
||||
EpBinding::SketchGroup { .. } => Err(CompileError::CannotIndex),
|
||||
EpBinding::Single(_) => Err(CompileError::CannotIndex),
|
||||
EpBinding::Function(_) => Err(CompileError::CannotIndex),
|
||||
EpBinding::Constant(_) => Err(CompileError::CannotIndex),
|
||||
},
|
||||
// Objects can be indexed by string properties.
|
||||
LiteralValue::String(property) => match self {
|
||||
EpBinding::Single(_) => Err(CompileError::NoProperties),
|
||||
EpBinding::Function(_) => Err(CompileError::NoProperties),
|
||||
EpBinding::Constant(_) => Err(CompileError::CannotIndex),
|
||||
EpBinding::SketchGroup { .. } => Err(CompileError::NoProperties),
|
||||
EpBinding::Sequence { .. } => Err(CompileError::ArrayDoesNotHaveProperties),
|
||||
EpBinding::Map {
|
||||
properties,
|
||||
length_at: _,
|
||||
} => properties
|
||||
.get(&property)
|
||||
.ok_or(CompileError::UndefinedProperty { property }),
|
||||
},
|
||||
// It's never valid to index by a fractional number.
|
||||
LiteralValue::Fractional(num) => Err(CompileError::InvalidIndex(num.to_string())),
|
||||
LiteralValue::Bool(b) => Err(CompileError::InvalidIndex(b.to_string())),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A set of bindings in a particular scope.
|
||||
/// Bindings are KCL values that get "compiled" into KCEP values, which are stored in KCEP memory
|
||||
/// at a particular KCEP address.
|
||||
/// Bindings are referenced by the name of their KCL identifier.
|
||||
///
|
||||
/// KCL has multiple scopes -- each function has a scope for its own local variables and parameters.
|
||||
/// So when referencing a variable, it might be in this scope, or the parent scope. So, each environment
|
||||
/// has to keep track of parent environments. The root environment has no parent, and is used for KCL globals
|
||||
/// (e.g. the prelude of stdlib functions).
|
||||
///
|
||||
/// These are called "Environments" in the "Crafting Interpreters" book.
|
||||
#[derive(Debug)]
|
||||
pub struct BindingScope {
|
||||
// KCL value which are stored in EP memory.
|
||||
ep_bindings: HashMap<String, EpBinding>,
|
||||
/// KCL functions. They do NOT get stored in EP memory.
|
||||
parent: Option<Box<BindingScope>>,
|
||||
}
|
||||
|
||||
impl BindingScope {
|
||||
/// The parent scope for every program, before the user has defined anything.
|
||||
/// Only includes some stdlib functions.
|
||||
/// This is usually known as the "prelude" in other languages. It's the stdlib functions that
|
||||
/// are already imported for you when you start coding.
|
||||
pub fn prelude() -> Self {
|
||||
Self {
|
||||
// TODO: Actually put the stdlib prelude in here,
|
||||
// things like `startSketchAt` and `line`.
|
||||
ep_bindings: HashMap::from([
|
||||
("E".into(), EpBinding::Constant(constants::E)),
|
||||
("PI".into(), EpBinding::Constant(constants::PI)),
|
||||
("id".into(), EpBinding::from(KclFunction::Id(native_functions::Id))),
|
||||
("abs".into(), EpBinding::from(KclFunction::Abs(native_functions::Abs))),
|
||||
(
|
||||
"acos".into(),
|
||||
EpBinding::from(KclFunction::Acos(native_functions::Acos)),
|
||||
),
|
||||
(
|
||||
"asin".into(),
|
||||
EpBinding::from(KclFunction::Asin(native_functions::Asin)),
|
||||
),
|
||||
(
|
||||
"atan".into(),
|
||||
EpBinding::from(KclFunction::Atan(native_functions::Atan)),
|
||||
),
|
||||
(
|
||||
"ceil".into(),
|
||||
EpBinding::from(KclFunction::Ceil(native_functions::Ceil)),
|
||||
),
|
||||
("cos".into(), EpBinding::from(KclFunction::Cos(native_functions::Cos))),
|
||||
(
|
||||
"floor".into(),
|
||||
EpBinding::from(KclFunction::Floor(native_functions::Floor)),
|
||||
),
|
||||
("ln".into(), EpBinding::from(KclFunction::Ln(native_functions::Ln))),
|
||||
(
|
||||
"log10".into(),
|
||||
EpBinding::from(KclFunction::Log10(native_functions::Log10)),
|
||||
),
|
||||
(
|
||||
"log2".into(),
|
||||
EpBinding::from(KclFunction::Log2(native_functions::Log2)),
|
||||
),
|
||||
("sin".into(), EpBinding::from(KclFunction::Sin(native_functions::Sin))),
|
||||
(
|
||||
"sqrt".into(),
|
||||
EpBinding::from(KclFunction::Sqrt(native_functions::Sqrt)),
|
||||
),
|
||||
("tan".into(), EpBinding::from(KclFunction::Tan(native_functions::Tan))),
|
||||
(
|
||||
"toDegrees".into(),
|
||||
EpBinding::from(KclFunction::ToDegrees(native_functions::ToDegrees)),
|
||||
),
|
||||
(
|
||||
"toRadians".into(),
|
||||
EpBinding::from(KclFunction::ToRadians(native_functions::ToRadians)),
|
||||
),
|
||||
("add".into(), EpBinding::from(KclFunction::Add(native_functions::Add))),
|
||||
("log".into(), EpBinding::from(KclFunction::Log(native_functions::Log))),
|
||||
("max".into(), EpBinding::from(KclFunction::Max(native_functions::Max))),
|
||||
("min".into(), EpBinding::from(KclFunction::Min(native_functions::Min))),
|
||||
(
|
||||
"startSketchAt".into(),
|
||||
EpBinding::from(KclFunction::StartSketchAt(native_functions::sketch::StartSketchAt)),
|
||||
),
|
||||
(
|
||||
"lineTo".into(),
|
||||
EpBinding::from(KclFunction::LineTo(native_functions::sketch::LineTo)),
|
||||
),
|
||||
(
|
||||
"line".into(),
|
||||
EpBinding::from(KclFunction::Line(native_functions::sketch::Line)),
|
||||
),
|
||||
(
|
||||
"xLineTo".into(),
|
||||
EpBinding::from(KclFunction::XLineTo(native_functions::sketch::XLineTo)),
|
||||
),
|
||||
(
|
||||
"xLine".into(),
|
||||
EpBinding::from(KclFunction::XLine(native_functions::sketch::XLine)),
|
||||
),
|
||||
(
|
||||
"yLineTo".into(),
|
||||
EpBinding::from(KclFunction::YLineTo(native_functions::sketch::YLineTo)),
|
||||
),
|
||||
(
|
||||
"yLine".into(),
|
||||
EpBinding::from(KclFunction::YLine(native_functions::sketch::YLine)),
|
||||
),
|
||||
(
|
||||
"tangentialArcTo".into(),
|
||||
EpBinding::from(KclFunction::TangentialArcTo(native_functions::sketch::TangentialArcTo)),
|
||||
),
|
||||
(
|
||||
"extrude".into(),
|
||||
EpBinding::from(KclFunction::Extrude(native_functions::sketch::Extrude)),
|
||||
),
|
||||
(
|
||||
"close".into(),
|
||||
EpBinding::from(KclFunction::Close(native_functions::sketch::Close)),
|
||||
),
|
||||
]),
|
||||
parent: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a new scope, e.g. for new function calls.
|
||||
pub fn add_scope(&mut self) {
|
||||
// Move all data from `self` into `this`.
|
||||
let this_parent = self.parent.take();
|
||||
let this_ep_bindings = self.ep_bindings.drain().collect();
|
||||
let this = Self {
|
||||
ep_bindings: this_ep_bindings,
|
||||
parent: this_parent,
|
||||
};
|
||||
// Turn `self` into a new scope, with the old `self` as its parent.
|
||||
self.parent = Some(Box::new(this));
|
||||
}
|
||||
|
||||
//// Remove a scope, e.g. when exiting a function call.
|
||||
pub fn remove_scope(&mut self) {
|
||||
// The scope is finished, so erase all its local variables.
|
||||
self.ep_bindings.clear();
|
||||
// Pop the stack -- the parent scope is now the current scope.
|
||||
let p = self.parent.take().expect("cannot remove the root scope");
|
||||
self.parent = p.parent;
|
||||
self.ep_bindings = p.ep_bindings;
|
||||
}
|
||||
|
||||
/// Add a binding (e.g. defining a new variable)
|
||||
pub fn bind(&mut self, identifier: String, binding: EpBinding) {
|
||||
self.ep_bindings.insert(identifier, binding);
|
||||
}
|
||||
|
||||
/// Look up a binding.
|
||||
pub fn get(&self, identifier: &str) -> Option<&EpBinding> {
|
||||
if let Some(b) = self.ep_bindings.get(identifier) {
|
||||
// The name was found in this scope.
|
||||
Some(b)
|
||||
} else if let Some(ref parent) = self.parent {
|
||||
// Check the next scope outwards.
|
||||
parent.get(identifier)
|
||||
} else {
|
||||
// There's no outer scope, and it wasn't found, so there's nowhere else to look.
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Look up a function bound to the given identifier.
|
||||
pub fn get_fn(&self, identifier: &str) -> GetFnResult {
|
||||
if let Some(x) = self.get(identifier) {
|
||||
match x {
|
||||
EpBinding::Function(f) => GetFnResult::Found(f),
|
||||
_ => GetFnResult::NonCallable,
|
||||
}
|
||||
} else if let Some(ref parent) = self.parent {
|
||||
parent.get_fn(identifier)
|
||||
} else {
|
||||
GetFnResult::NotFound
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub enum GetFnResult<'a> {
|
||||
Found(&'a KclFunction),
|
||||
NonCallable,
|
||||
NotFound,
|
||||
}
|
@ -1,84 +0,0 @@
|
||||
use kcl_lib::ast::types::RequiredParamAfterOptionalParam;
|
||||
use kittycad_execution_plan::{ExecutionError, ExecutionFailed, Instruction};
|
||||
|
||||
use crate::String2;
|
||||
|
||||
#[derive(Debug, thiserror::Error, PartialEq, Clone)]
|
||||
pub enum CompileError {
|
||||
#[error("the name {name} was not defined")]
|
||||
Undefined { name: String },
|
||||
#[error("the function {fn_name} requires at least {required} arguments but you only supplied {actual}")]
|
||||
NotEnoughArgs {
|
||||
fn_name: String2,
|
||||
required: usize,
|
||||
actual: usize,
|
||||
},
|
||||
#[error("the function {fn_name} accepts at most {maximum} arguments but you supplied {actual}")]
|
||||
TooManyArgs {
|
||||
fn_name: String2,
|
||||
maximum: usize,
|
||||
actual: usize,
|
||||
},
|
||||
#[error("you tried to call {name} but it's not a function")]
|
||||
NotCallable { name: String },
|
||||
#[error("you're trying to use an operand that isn't compatible with the given arithmetic operator: {0}")]
|
||||
InvalidOperand(&'static str),
|
||||
#[error("you cannot use the value {0} as an index")]
|
||||
InvalidIndex(String),
|
||||
#[error("you tried to index into a value that isn't an array. Only arrays have numeric indices!")]
|
||||
CannotIndex,
|
||||
#[error("you tried to get the element {i} but that index is out of bounds. The array only has a length of {len}")]
|
||||
IndexOutOfBounds { i: usize, len: usize },
|
||||
#[error("you tried to access the property of a value that doesn't have any properties")]
|
||||
NoProperties,
|
||||
#[error("you tried to access a property of an array, but arrays don't have properties. They do have numeric indexes though, try using an index e.g. [0]")]
|
||||
ArrayDoesNotHaveProperties,
|
||||
#[error(
|
||||
"you tried to read the '.{property}' of an object, but the object doesn't have any properties with that key"
|
||||
)]
|
||||
UndefinedProperty { property: String },
|
||||
#[error("{0}")]
|
||||
BadParamOrder(RequiredParamAfterOptionalParam),
|
||||
#[error("A KCL function cannot have anything after its return value")]
|
||||
MultipleReturns,
|
||||
#[error("A KCL function must end with a return statement, but your function doesn't have one.")]
|
||||
NoReturnStmt,
|
||||
#[error("You used the %, which means \"substitute this argument for the value to the left in this |> pipeline\". But there is no such value, because you're not calling a pipeline.")]
|
||||
NotInPipeline,
|
||||
#[error("The function '{fn_name}' expects a parameter of type {expected} as argument number {arg_number} but you supplied {actual}")]
|
||||
ArgWrongType {
|
||||
fn_name: &'static str,
|
||||
expected: &'static str,
|
||||
actual: String,
|
||||
arg_number: usize,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
pub enum Error {
|
||||
#[error("{0}")]
|
||||
Compile(#[from] CompileError),
|
||||
#[error("Failed on instruction {instruction_index}:\n{error}\n\nInstruction contents were {instruction:#?}")]
|
||||
Execution {
|
||||
error: ExecutionError,
|
||||
instruction: Instruction,
|
||||
instruction_index: usize,
|
||||
},
|
||||
}
|
||||
|
||||
impl From<ExecutionFailed> for Error {
|
||||
fn from(
|
||||
ExecutionFailed {
|
||||
error,
|
||||
instruction,
|
||||
instruction_index,
|
||||
}: ExecutionFailed,
|
||||
) -> Self {
|
||||
Self::Execution {
|
||||
error,
|
||||
instruction: instruction.expect("no instruction"),
|
||||
instruction_index,
|
||||
}
|
||||
}
|
||||
}
|
@ -1,14 +0,0 @@
|
||||
use kcl_lib::ast::{self, types::BinaryPart};
|
||||
|
||||
pub type SingleValue = kcl_lib::ast::types::Value;
|
||||
|
||||
pub fn into_single_value(value: ast::types::BinaryPart) -> SingleValue {
|
||||
match value {
|
||||
BinaryPart::Literal(e) => SingleValue::Literal(e),
|
||||
BinaryPart::Identifier(e) => SingleValue::Identifier(e),
|
||||
BinaryPart::BinaryExpression(e) => SingleValue::BinaryExpression(e),
|
||||
BinaryPart::CallExpression(e) => SingleValue::CallExpression(e),
|
||||
BinaryPart::UnaryExpression(e) => SingleValue::UnaryExpression(e),
|
||||
BinaryPart::MemberExpression(e) => SingleValue::MemberExpression(e),
|
||||
}
|
||||
}
|
@ -1,729 +0,0 @@
|
||||
mod binding_scope;
|
||||
mod error;
|
||||
mod kcl_value_group;
|
||||
mod native_functions;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use kcl_lib::{
|
||||
ast,
|
||||
ast::types::{BodyItem, FunctionExpressionParts, KclNone, LiteralValue, Program},
|
||||
};
|
||||
use kcl_value_group::into_single_value;
|
||||
use kittycad_execution_plan::{
|
||||
self as ep, instruction::SourceRange as KcvmSourceRange, Destination, Instruction, InstructionKind,
|
||||
};
|
||||
use kittycad_execution_plan_traits as ept;
|
||||
use kittycad_execution_plan_traits::{Address, NumericPrimitive};
|
||||
use kittycad_modeling_session::Session;
|
||||
|
||||
use crate::{
|
||||
binding_scope::{BindingScope, EpBinding, GetFnResult},
|
||||
error::{CompileError, Error},
|
||||
kcl_value_group::SingleValue,
|
||||
};
|
||||
|
||||
/// Execute a KCL program by compiling into an execution plan, then running that.
|
||||
pub async fn execute(ast: Program, session: &mut Option<Session>) -> Result<ep::Memory, Error> {
|
||||
let mut planner = Planner::new();
|
||||
let (plan, _retval) = planner.build_plan(ast)?;
|
||||
let mut mem = ep::Memory::default();
|
||||
ep::execute(&mut mem, plan, session).await?;
|
||||
Ok(mem)
|
||||
}
|
||||
|
||||
/// Compiles KCL programs into Execution Plans.
|
||||
#[derive(Debug)]
|
||||
struct Planner {
|
||||
/// Maps KCL identifiers to what they hold, and where in KCEP virtual memory they'll be written to.
|
||||
binding_scope: BindingScope,
|
||||
/// Next available KCVM virtual machine memory address.
|
||||
next_addr: Address,
|
||||
/// Next available KCVM sketch group index.
|
||||
next_sketch_group: usize,
|
||||
}
|
||||
|
||||
impl Planner {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
binding_scope: BindingScope::prelude(),
|
||||
next_addr: Address::ZERO,
|
||||
next_sketch_group: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// If successful, return the KCEP instructions for executing the given program.
|
||||
/// If the program is a function with a return, then it also returns the KCL function's return value.
|
||||
fn build_plan(&mut self, program: Program) -> Result<(Vec<Instruction>, Option<EpBinding>), CompileError> {
|
||||
program
|
||||
.body
|
||||
.into_iter()
|
||||
.try_fold((Vec::new(), None), |(mut instructions, mut retval), item| {
|
||||
if retval.is_some() {
|
||||
return Err(CompileError::MultipleReturns);
|
||||
}
|
||||
let mut ctx = Context::default();
|
||||
let instructions_for_this_node = match item {
|
||||
BodyItem::ExpressionStatement(node) => {
|
||||
self.plan_to_compute_single(&mut ctx, SingleValue::from(node.expression))?
|
||||
.instructions
|
||||
}
|
||||
BodyItem::VariableDeclaration(node) => self.plan_to_bind(node)?,
|
||||
BodyItem::ReturnStatement(node) => {
|
||||
let EvalPlan { instructions, binding } =
|
||||
self.plan_to_compute_single(&mut ctx, SingleValue::from(node.argument))?;
|
||||
retval = Some(binding);
|
||||
instructions
|
||||
}
|
||||
};
|
||||
instructions.extend(instructions_for_this_node);
|
||||
Ok((instructions, retval))
|
||||
})
|
||||
}
|
||||
|
||||
/// Emits instructions which, when run, compute a given KCL value and store it in memory.
|
||||
/// Returns the instructions, and the destination address of the value.
|
||||
fn plan_to_compute_single(&mut self, ctx: &mut Context, value: SingleValue) -> Result<EvalPlan, CompileError> {
|
||||
match value {
|
||||
SingleValue::None(KclNone { start, end }) => {
|
||||
let address = self.next_addr.offset_by(1);
|
||||
Ok(EvalPlan {
|
||||
instructions: vec![Instruction::from_range(
|
||||
InstructionKind::SetPrimitive {
|
||||
address,
|
||||
value: ept::Primitive::Nil,
|
||||
},
|
||||
KcvmSourceRange([start, end]),
|
||||
)],
|
||||
binding: EpBinding::Single(address),
|
||||
})
|
||||
}
|
||||
SingleValue::FunctionExpression(expr) => {
|
||||
let FunctionExpressionParts {
|
||||
start: _,
|
||||
end: _,
|
||||
params_required,
|
||||
params_optional,
|
||||
body,
|
||||
} = expr.into_parts().map_err(CompileError::BadParamOrder)?;
|
||||
Ok(EvalPlan {
|
||||
instructions: Vec::new(),
|
||||
binding: EpBinding::from(KclFunction::UserDefined(UserDefinedFunction {
|
||||
params_optional,
|
||||
params_required,
|
||||
body,
|
||||
})),
|
||||
})
|
||||
}
|
||||
SingleValue::Literal(expr) => {
|
||||
let kcep_val = kcl_literal_to_kcep_literal(expr.value);
|
||||
// KCEP primitives always have size of 1, because each address holds 1 primitive.
|
||||
let size = 1;
|
||||
let address = self.next_addr.offset_by(size);
|
||||
Ok(EvalPlan {
|
||||
instructions: vec![Instruction::from_range(
|
||||
InstructionKind::SetPrimitive {
|
||||
address,
|
||||
value: kcep_val,
|
||||
},
|
||||
KcvmSourceRange([expr.start, expr.end]),
|
||||
)],
|
||||
binding: EpBinding::Single(address),
|
||||
})
|
||||
}
|
||||
SingleValue::Identifier(expr) => {
|
||||
// The KCL parser interprets bools as identifiers.
|
||||
// Consider changing them to be KCL literals instead.
|
||||
let b = if expr.name == "true" {
|
||||
Some(true)
|
||||
} else if expr.name == "false" {
|
||||
Some(false)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(b) = b {
|
||||
let address = self.next_addr.offset_by(1);
|
||||
return Ok(EvalPlan {
|
||||
instructions: vec![Instruction::from_range(
|
||||
InstructionKind::SetPrimitive {
|
||||
address,
|
||||
value: ept::Primitive::Bool(b),
|
||||
},
|
||||
KcvmSourceRange([expr.start, expr.end]),
|
||||
)],
|
||||
binding: EpBinding::Single(address),
|
||||
});
|
||||
}
|
||||
|
||||
// This identifier is just duplicating a binding.
|
||||
// So, don't emit any instructions, because the value has already been computed.
|
||||
// Just return the address that it was stored at after being computed.
|
||||
let previously_bound_to = self
|
||||
.binding_scope
|
||||
.get(&expr.name)
|
||||
.ok_or(CompileError::Undefined { name: expr.name })?;
|
||||
Ok(EvalPlan {
|
||||
instructions: Vec::new(),
|
||||
binding: previously_bound_to.clone(),
|
||||
})
|
||||
}
|
||||
SingleValue::UnaryExpression(expr) => {
|
||||
let operand = self.plan_to_compute_single(ctx, into_single_value(expr.argument))?;
|
||||
let EpBinding::Single(binding) = operand.binding else {
|
||||
return Err(CompileError::InvalidOperand(
|
||||
"you tried to use a composite value (e.g. array or object) as the operand to some math",
|
||||
));
|
||||
};
|
||||
let destination = self.next_addr.offset_by(1);
|
||||
let mut plan = operand.instructions;
|
||||
plan.push(Instruction::from_range(
|
||||
InstructionKind::UnaryArithmetic {
|
||||
arithmetic: ep::UnaryArithmetic {
|
||||
operation: match expr.operator {
|
||||
ast::types::UnaryOperator::Neg => ep::UnaryOperation::Neg,
|
||||
ast::types::UnaryOperator::Not => ep::UnaryOperation::Not,
|
||||
},
|
||||
operand: ep::Operand::Reference(binding),
|
||||
},
|
||||
destination: Destination::Address(destination),
|
||||
},
|
||||
KcvmSourceRange([expr.start, expr.end]),
|
||||
));
|
||||
Ok(EvalPlan {
|
||||
instructions: plan,
|
||||
binding: EpBinding::Single(destination),
|
||||
})
|
||||
}
|
||||
SingleValue::BinaryExpression(expr) => {
|
||||
let l = self.plan_to_compute_single(ctx, into_single_value(expr.left))?;
|
||||
let r = self.plan_to_compute_single(ctx, into_single_value(expr.right))?;
|
||||
let EpBinding::Single(l_binding) = l.binding else {
|
||||
return Err(CompileError::InvalidOperand(
|
||||
"you tried to use a composite value (e.g. array or object) as the operand to some math",
|
||||
));
|
||||
};
|
||||
let EpBinding::Single(r_binding) = r.binding else {
|
||||
return Err(CompileError::InvalidOperand(
|
||||
"you tried to use a composite value (e.g. array or object) as the operand to some math",
|
||||
));
|
||||
};
|
||||
let destination = self.next_addr.offset_by(1);
|
||||
let mut plan = Vec::with_capacity(l.instructions.len() + r.instructions.len() + 1);
|
||||
plan.extend(l.instructions);
|
||||
plan.extend(r.instructions);
|
||||
plan.push(Instruction::from_range(
|
||||
InstructionKind::BinaryArithmetic {
|
||||
arithmetic: ep::BinaryArithmetic {
|
||||
operation: match expr.operator {
|
||||
ast::types::BinaryOperator::Add => ep::BinaryOperation::Add,
|
||||
ast::types::BinaryOperator::Sub => ep::BinaryOperation::Sub,
|
||||
ast::types::BinaryOperator::Mul => ep::BinaryOperation::Mul,
|
||||
ast::types::BinaryOperator::Div => ep::BinaryOperation::Div,
|
||||
ast::types::BinaryOperator::Mod => ep::BinaryOperation::Mod,
|
||||
ast::types::BinaryOperator::Pow => ep::BinaryOperation::Pow,
|
||||
},
|
||||
operand0: ep::Operand::Reference(l_binding),
|
||||
operand1: ep::Operand::Reference(r_binding),
|
||||
},
|
||||
destination: Destination::Address(destination),
|
||||
},
|
||||
KcvmSourceRange([expr.start, expr.end]),
|
||||
));
|
||||
Ok(EvalPlan {
|
||||
instructions: plan,
|
||||
binding: EpBinding::Single(destination),
|
||||
})
|
||||
}
|
||||
SingleValue::CallExpression(expr) => {
|
||||
// Make a plan to compute all the arguments to this call.
|
||||
let (mut instructions, args) = expr.arguments.into_iter().try_fold(
|
||||
(Vec::new(), Vec::new()),
|
||||
|(mut acc_instrs, mut acc_args), argument| {
|
||||
let EvalPlan {
|
||||
instructions: new_instructions,
|
||||
binding: arg,
|
||||
} = self.plan_to_compute_single(ctx, SingleValue::from(argument))?;
|
||||
acc_instrs.extend(new_instructions);
|
||||
acc_args.push(arg);
|
||||
Ok((acc_instrs, acc_args))
|
||||
},
|
||||
)?;
|
||||
// Look up the function being called.
|
||||
let callee = match self.binding_scope.get_fn(&expr.callee.name) {
|
||||
GetFnResult::Found(f) => f,
|
||||
GetFnResult::NonCallable => {
|
||||
return Err(CompileError::NotCallable {
|
||||
name: expr.callee.name.clone(),
|
||||
});
|
||||
}
|
||||
GetFnResult::NotFound => {
|
||||
return Err(CompileError::Undefined {
|
||||
name: expr.callee.name.clone(),
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
// Emit instructions to call that function with the given arguments.
|
||||
use native_functions::Callable;
|
||||
let mut ctx = native_functions::Context {
|
||||
next_address: &mut self.next_addr,
|
||||
next_sketch_group: &mut self.next_sketch_group,
|
||||
};
|
||||
let EvalPlan {
|
||||
instructions: eval_instrs,
|
||||
binding,
|
||||
} = match callee {
|
||||
KclFunction::Id(f) => f.call(&mut ctx, args)?,
|
||||
KclFunction::Abs(f) => f.call(&mut ctx, args)?,
|
||||
KclFunction::Acos(f) => f.call(&mut ctx, args)?,
|
||||
KclFunction::Asin(f) => f.call(&mut ctx, args)?,
|
||||
KclFunction::Atan(f) => f.call(&mut ctx, args)?,
|
||||
KclFunction::Ceil(f) => f.call(&mut ctx, args)?,
|
||||
KclFunction::Cos(f) => f.call(&mut ctx, args)?,
|
||||
KclFunction::Floor(f) => f.call(&mut ctx, args)?,
|
||||
KclFunction::Ln(f) => f.call(&mut ctx, args)?,
|
||||
KclFunction::Log10(f) => f.call(&mut ctx, args)?,
|
||||
KclFunction::Log2(f) => f.call(&mut ctx, args)?,
|
||||
KclFunction::Sin(f) => f.call(&mut ctx, args)?,
|
||||
KclFunction::Sqrt(f) => f.call(&mut ctx, args)?,
|
||||
KclFunction::Tan(f) => f.call(&mut ctx, args)?,
|
||||
KclFunction::ToDegrees(f) => f.call(&mut ctx, args)?,
|
||||
KclFunction::ToRadians(f) => f.call(&mut ctx, args)?,
|
||||
KclFunction::Log(f) => f.call(&mut ctx, args)?,
|
||||
KclFunction::Max(f) => f.call(&mut ctx, args)?,
|
||||
KclFunction::Min(f) => f.call(&mut ctx, args)?,
|
||||
KclFunction::StartSketchAt(f) => f.call(&mut ctx, args)?,
|
||||
KclFunction::Extrude(f) => f.call(&mut ctx, args)?,
|
||||
KclFunction::LineTo(f) => f.call(&mut ctx, args)?,
|
||||
KclFunction::Line(f) => f.call(&mut ctx, args)?,
|
||||
KclFunction::XLineTo(f) => f.call(&mut ctx, args)?,
|
||||
KclFunction::XLine(f) => f.call(&mut ctx, args)?,
|
||||
KclFunction::YLineTo(f) => f.call(&mut ctx, args)?,
|
||||
KclFunction::YLine(f) => f.call(&mut ctx, args)?,
|
||||
KclFunction::TangentialArcTo(f) => f.call(&mut ctx, args)?,
|
||||
KclFunction::Add(f) => f.call(&mut ctx, args)?,
|
||||
KclFunction::Close(f) => f.call(&mut ctx, args)?,
|
||||
KclFunction::UserDefined(f) => {
|
||||
let UserDefinedFunction {
|
||||
params_optional,
|
||||
params_required,
|
||||
body: function_body,
|
||||
} = f.clone();
|
||||
let num_required_params = params_required.len();
|
||||
self.binding_scope.add_scope();
|
||||
|
||||
// Bind the call's arguments to the names of the function's parameters.
|
||||
let num_actual_params = args.len();
|
||||
let mut arg_iter = args.into_iter();
|
||||
let max_params = params_required.len() + params_optional.len();
|
||||
if num_actual_params > max_params {
|
||||
return Err(CompileError::TooManyArgs {
|
||||
fn_name: "".into(),
|
||||
maximum: max_params,
|
||||
actual: num_actual_params,
|
||||
});
|
||||
}
|
||||
|
||||
// Bind required parameters
|
||||
for param in params_required {
|
||||
let arg = arg_iter.next().ok_or(CompileError::NotEnoughArgs {
|
||||
fn_name: "".into(),
|
||||
required: num_required_params,
|
||||
actual: num_actual_params,
|
||||
})?;
|
||||
self.binding_scope.bind(param.identifier.name, arg);
|
||||
}
|
||||
|
||||
// Bind optional parameters
|
||||
for param in params_optional {
|
||||
let Some(arg) = arg_iter.next() else {
|
||||
break;
|
||||
};
|
||||
self.binding_scope.bind(param.identifier.name, arg);
|
||||
}
|
||||
|
||||
let (instructions, retval) = self.build_plan(function_body)?;
|
||||
let Some(retval) = retval else {
|
||||
return Err(CompileError::NoReturnStmt);
|
||||
};
|
||||
self.binding_scope.remove_scope();
|
||||
EvalPlan {
|
||||
instructions,
|
||||
binding: retval,
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Combine the "evaluate arguments" plan with the "call function" plan.
|
||||
instructions.extend(eval_instrs);
|
||||
Ok(EvalPlan { instructions, binding })
|
||||
}
|
||||
SingleValue::MemberExpression(mut expr) => {
|
||||
let source_range = KcvmSourceRange([expr.start, expr.end]);
|
||||
let parse = move || {
|
||||
let mut stack = Vec::new();
|
||||
loop {
|
||||
stack.push((expr.property, expr.computed));
|
||||
match expr.object {
|
||||
ast::types::MemberObject::MemberExpression(subexpr) => {
|
||||
expr = subexpr;
|
||||
}
|
||||
ast::types::MemberObject::Identifier(id) => return (stack, id),
|
||||
}
|
||||
}
|
||||
};
|
||||
let (mut properties, id) = parse();
|
||||
let name = id.name;
|
||||
let mut binding = self.binding_scope.get(&name).ok_or(CompileError::Undefined { name })?;
|
||||
if properties.iter().any(|(_property, computed)| *computed) {
|
||||
// There's a computed property, so the property/index can only be determined at runtime.
|
||||
let mut instructions: Vec<Instruction> = Vec::new();
|
||||
let starting_address = match binding {
|
||||
EpBinding::Sequence { length_at, elements: _ } => *length_at,
|
||||
EpBinding::Map {
|
||||
length_at,
|
||||
properties: _,
|
||||
} => *length_at,
|
||||
_ => return Err(CompileError::CannotIndex),
|
||||
};
|
||||
let mut structure_start = ep::Operand::Literal(starting_address.into());
|
||||
properties.reverse();
|
||||
for (property, _computed) in properties {
|
||||
let source_range = KcvmSourceRange([property.start(), property.end()]);
|
||||
// Where is the member stored?
|
||||
let addr_of_member = match property {
|
||||
// If it's some identifier, then look up where that identifier will be stored.
|
||||
// That's the memory address the index/property should be in.
|
||||
ast::types::LiteralIdentifier::Identifier(id) => {
|
||||
let b = self
|
||||
.binding_scope
|
||||
.get(&id.name)
|
||||
.ok_or(CompileError::Undefined { name: id.name })?;
|
||||
match b {
|
||||
EpBinding::Single(addr) => ep::Operand::Reference(*addr),
|
||||
// TODO use a better error message here
|
||||
other => return Err(CompileError::InvalidIndex(format!("{other:?}"))),
|
||||
}
|
||||
}
|
||||
// If the index is a literal, then just use it.
|
||||
ast::types::LiteralIdentifier::Literal(litval) => {
|
||||
ep::Operand::Literal(kcl_literal_to_kcep_literal(litval.value))
|
||||
}
|
||||
};
|
||||
|
||||
// Find the address of the member, push to stack.
|
||||
instructions.push(Instruction::from_range(
|
||||
InstructionKind::AddrOfMember {
|
||||
member: addr_of_member,
|
||||
start: structure_start,
|
||||
},
|
||||
source_range,
|
||||
));
|
||||
// If there's another member after this one, its starting object is the
|
||||
// address we just pushed to the stack.
|
||||
structure_start = ep::Operand::StackPop;
|
||||
}
|
||||
|
||||
// The final address is on the stack.
|
||||
// Move it to addressable memory.
|
||||
let final_prop_addr = self.next_addr.offset_by(1);
|
||||
instructions.push(Instruction::from_range(
|
||||
InstructionKind::CopyLen {
|
||||
source_range: ep::Operand::StackPop,
|
||||
destination_range: ep::Operand::Literal(final_prop_addr.into()),
|
||||
},
|
||||
source_range,
|
||||
));
|
||||
|
||||
Ok(EvalPlan {
|
||||
instructions,
|
||||
binding: EpBinding::Single(final_prop_addr),
|
||||
})
|
||||
} else {
|
||||
// Compiler optimization:
|
||||
// Because there are no computed properties, we can resolve the property chain
|
||||
// at compile-time. Just jump to the right property at each step in the chain.
|
||||
for (property, _) in properties {
|
||||
binding = binding.property_of(property)?;
|
||||
}
|
||||
Ok(EvalPlan {
|
||||
instructions: Vec::new(),
|
||||
binding: binding.clone(),
|
||||
})
|
||||
}
|
||||
}
|
||||
SingleValue::PipeSubstitution(_expr) => {
|
||||
if let Some(ref binding) = ctx.pipe_substitution {
|
||||
Ok(EvalPlan {
|
||||
instructions: Vec::new(),
|
||||
binding: binding.clone(),
|
||||
})
|
||||
} else {
|
||||
Err(CompileError::NotInPipeline)
|
||||
}
|
||||
}
|
||||
SingleValue::PipeExpression(expr) => {
|
||||
let mut bodies = expr.body.into_iter();
|
||||
|
||||
// Get the first expression (i.e. body) of the pipeline.
|
||||
let first = bodies.next().expect("Pipe expression must have > 1 item");
|
||||
let EvalPlan {
|
||||
mut instructions,
|
||||
binding: mut current_value,
|
||||
} = self.plan_to_compute_single(ctx, SingleValue::from(first))?;
|
||||
|
||||
// Handle the remaining bodies.
|
||||
for body in bodies {
|
||||
let value = SingleValue::from(body);
|
||||
// This body will probably contain a % (pipe substitution character).
|
||||
// So it needs to know what the previous pipeline body's value is,
|
||||
// to replace the % with that value.
|
||||
ctx.pipe_substitution = Some(current_value.clone());
|
||||
let EvalPlan {
|
||||
instructions: instructions_for_this_body,
|
||||
binding,
|
||||
} = self.plan_to_compute_single(ctx, value)?;
|
||||
instructions.extend(instructions_for_this_body);
|
||||
current_value = binding;
|
||||
}
|
||||
// Before we return, clear the pipe substitution, because nothing outside this
|
||||
// pipeline should be able to use it anymore.
|
||||
ctx.pipe_substitution = None;
|
||||
Ok(EvalPlan {
|
||||
instructions,
|
||||
binding: current_value,
|
||||
})
|
||||
}
|
||||
SingleValue::ObjectExpression(expr) => {
|
||||
let length_at = self.next_addr.offset_by(1);
|
||||
let key_count = expr.properties.len();
|
||||
// Compute elements
|
||||
let (instructions_for_each_element, bindings, keys) = expr.properties.into_iter().try_fold(
|
||||
(Vec::new(), HashMap::new(), Vec::with_capacity(key_count)),
|
||||
|(mut acc_instrs, mut acc_properties, mut acc_keys), property| {
|
||||
let key = property.key.name;
|
||||
acc_keys.push(key.clone());
|
||||
|
||||
// Some elements will have their own length header (e.g. arrays).
|
||||
// For all other elements, we'll need to add a length header.
|
||||
let element_has_its_own_header = matches!(
|
||||
SingleValue::from(property.value.clone()),
|
||||
SingleValue::ArrayExpression(_) | SingleValue::ObjectExpression(_)
|
||||
);
|
||||
let element_needs_its_own_header = !element_has_its_own_header;
|
||||
let length_at = element_needs_its_own_header.then(|| self.next_addr.offset_by(1));
|
||||
|
||||
let instrs_for_this_element = {
|
||||
// If this element of the array is a single value, then binding it is
|
||||
// straightforward -- you got a single binding, no need to change anything.
|
||||
let EvalPlan { instructions, binding } =
|
||||
self.plan_to_compute_single(ctx, SingleValue::from(property.value))?;
|
||||
acc_properties.insert(key, binding);
|
||||
instructions
|
||||
};
|
||||
// If we decided to add a length header for this element,
|
||||
// this is where we actually add it.
|
||||
if let Some(length_at) = length_at {
|
||||
let length_of_this_element = (self.next_addr - length_at) - 1;
|
||||
// Append element's length
|
||||
acc_instrs.push(Instruction::from_range(
|
||||
InstructionKind::SetPrimitive {
|
||||
address: length_at,
|
||||
value: length_of_this_element.into(),
|
||||
},
|
||||
KcvmSourceRange([expr.start, expr.end]),
|
||||
));
|
||||
}
|
||||
// Append element's value
|
||||
acc_instrs.extend(instrs_for_this_element);
|
||||
Ok((acc_instrs, acc_properties, acc_keys))
|
||||
},
|
||||
)?;
|
||||
// The array's overall instructions are:
|
||||
// - Write a length header
|
||||
// - Write everything to calculate its elements.
|
||||
let mut instructions = vec![Instruction::from_range(
|
||||
InstructionKind::SetPrimitive {
|
||||
address: length_at,
|
||||
value: ept::ObjectHeader {
|
||||
properties: keys,
|
||||
size: (self.next_addr - length_at) - 1,
|
||||
}
|
||||
.into(),
|
||||
},
|
||||
KcvmSourceRange([expr.start, expr.end]),
|
||||
)];
|
||||
instructions.extend(instructions_for_each_element);
|
||||
let binding = EpBinding::Map {
|
||||
length_at,
|
||||
properties: bindings,
|
||||
};
|
||||
Ok(EvalPlan { instructions, binding })
|
||||
}
|
||||
SingleValue::ArrayExpression(expr) => {
|
||||
let length_at = self.next_addr.offset_by(1);
|
||||
let element_count = expr.elements.len();
|
||||
// Compute elements
|
||||
let (instructions_for_each_element, bindings) = expr.elements.into_iter().try_fold(
|
||||
(Vec::new(), Vec::new()),
|
||||
|(mut acc_instrs, mut acc_bindings), element| {
|
||||
// Some elements will have their own length header (e.g. arrays).
|
||||
// For all other elements, we'll need to add a length header.
|
||||
let element_has_its_own_header = matches!(
|
||||
SingleValue::from(element.clone()),
|
||||
SingleValue::ArrayExpression(_) | SingleValue::ObjectExpression(_)
|
||||
);
|
||||
let element_needs_its_own_header = !element_has_its_own_header;
|
||||
let length_at = element_needs_its_own_header.then(|| self.next_addr.offset_by(1));
|
||||
|
||||
let instrs_for_this_element = {
|
||||
// If this element of the array is a single value, then binding it is
|
||||
// straightforward -- you got a single binding, no need to change anything.
|
||||
let EvalPlan { instructions, binding } =
|
||||
self.plan_to_compute_single(ctx, SingleValue::from(element))?;
|
||||
acc_bindings.push(binding);
|
||||
instructions
|
||||
};
|
||||
// If we decided to add a length header for this element,
|
||||
// this is where we actually add it.
|
||||
if let Some(length_at) = length_at {
|
||||
let length_of_this_element = (self.next_addr - length_at) - 1;
|
||||
// Append element's length
|
||||
acc_instrs.push(Instruction::from_range(
|
||||
InstructionKind::SetPrimitive {
|
||||
address: length_at,
|
||||
value: length_of_this_element.into(),
|
||||
},
|
||||
KcvmSourceRange([expr.start, expr.end]),
|
||||
));
|
||||
}
|
||||
// Append element's value
|
||||
acc_instrs.extend(instrs_for_this_element);
|
||||
Ok((acc_instrs, acc_bindings))
|
||||
},
|
||||
)?;
|
||||
// The array's overall instructions are:
|
||||
// - Write a length header
|
||||
// - Write everything to calculate its elements.
|
||||
let mut instructions = vec![Instruction::from_range(
|
||||
InstructionKind::SetPrimitive {
|
||||
address: length_at,
|
||||
value: ept::ListHeader {
|
||||
count: element_count,
|
||||
size: (self.next_addr - length_at) - 1,
|
||||
}
|
||||
.into(),
|
||||
},
|
||||
KcvmSourceRange([expr.start, expr.end]),
|
||||
)];
|
||||
instructions.extend(instructions_for_each_element);
|
||||
let binding = EpBinding::Sequence {
|
||||
length_at,
|
||||
elements: bindings,
|
||||
};
|
||||
Ok(EvalPlan { instructions, binding })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Emits instructions which, when run, compute a given KCL value and store it in memory.
|
||||
/// Returns the instructions.
|
||||
/// Also binds the value to a name.
|
||||
fn plan_to_bind(
|
||||
&mut self,
|
||||
declarations: ast::types::VariableDeclaration,
|
||||
) -> Result<Vec<Instruction>, CompileError> {
|
||||
let mut ctx = Context::default();
|
||||
declarations
|
||||
.declarations
|
||||
.into_iter()
|
||||
.try_fold(Vec::new(), |mut acc, declaration| {
|
||||
let EvalPlan { instructions, binding } =
|
||||
self.plan_to_compute_single(&mut ctx, SingleValue::from(declaration.init))?;
|
||||
self.binding_scope.bind(declaration.id.name, binding);
|
||||
acc.extend(instructions);
|
||||
Ok(acc)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Every KCL literal value is equivalent to an Execution Plan value, and therefore can be
|
||||
/// bound to some KCL name and Execution Plan address.
|
||||
fn kcl_literal_to_kcep_literal(expr: LiteralValue) -> ept::Primitive {
|
||||
match expr {
|
||||
LiteralValue::IInteger(x) => ept::Primitive::NumericValue(NumericPrimitive::Integer(x)),
|
||||
LiteralValue::Fractional(x) => ept::Primitive::NumericValue(NumericPrimitive::Float(x)),
|
||||
LiteralValue::String(x) => ept::Primitive::String(x),
|
||||
LiteralValue::Bool(b) => ept::Primitive::Bool(b),
|
||||
}
|
||||
}
|
||||
|
||||
/// Instructions that can compute some value.
|
||||
struct EvalPlan {
|
||||
/// The instructions which will compute the value.
|
||||
instructions: Vec<Instruction>,
|
||||
/// Where the value will be stored.
|
||||
binding: EpBinding,
|
||||
}
|
||||
|
||||
/// Either an owned string, or a static string. Either way it can be read and moved around.
|
||||
pub type String2 = std::borrow::Cow<'static, str>;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct UserDefinedFunction {
|
||||
params_optional: Vec<ast::types::Parameter>,
|
||||
params_required: Vec<ast::types::Parameter>,
|
||||
body: ast::types::Program,
|
||||
}
|
||||
|
||||
impl PartialEq for UserDefinedFunction {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.params_optional == other.params_optional && self.params_required == other.params_required
|
||||
}
|
||||
}
|
||||
|
||||
impl Eq for UserDefinedFunction {}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[cfg_attr(test, derive(Eq, PartialEq))]
|
||||
enum KclFunction {
|
||||
Id(native_functions::Id),
|
||||
Abs(native_functions::Abs),
|
||||
Acos(native_functions::Acos),
|
||||
Asin(native_functions::Asin),
|
||||
Atan(native_functions::Atan),
|
||||
Ceil(native_functions::Ceil),
|
||||
Cos(native_functions::Cos),
|
||||
Floor(native_functions::Floor),
|
||||
Ln(native_functions::Ln),
|
||||
Log10(native_functions::Log10),
|
||||
Log2(native_functions::Log2),
|
||||
Sin(native_functions::Sin),
|
||||
Sqrt(native_functions::Sqrt),
|
||||
Tan(native_functions::Tan),
|
||||
ToDegrees(native_functions::ToDegrees),
|
||||
ToRadians(native_functions::ToRadians),
|
||||
StartSketchAt(native_functions::sketch::StartSketchAt),
|
||||
LineTo(native_functions::sketch::LineTo),
|
||||
Line(native_functions::sketch::Line),
|
||||
XLineTo(native_functions::sketch::XLineTo),
|
||||
XLine(native_functions::sketch::XLine),
|
||||
YLineTo(native_functions::sketch::YLineTo),
|
||||
YLine(native_functions::sketch::YLine),
|
||||
TangentialArcTo(native_functions::sketch::TangentialArcTo),
|
||||
Add(native_functions::Add),
|
||||
Log(native_functions::Log),
|
||||
Max(native_functions::Max),
|
||||
Min(native_functions::Min),
|
||||
UserDefined(UserDefinedFunction),
|
||||
Extrude(native_functions::sketch::Extrude),
|
||||
Close(native_functions::sketch::Close),
|
||||
}
|
||||
|
||||
/// Context used when compiling KCL.
|
||||
#[derive(Default, Debug)]
|
||||
struct Context {
|
||||
pipe_substitution: Option<EpBinding>,
|
||||
}
|
@ -1,165 +0,0 @@
|
||||
//! Defines functions which are written in Rust, but called from KCL.
|
||||
//! This includes some of the stdlib, e.g. `startSketchAt`.
|
||||
//! But some other stdlib functions will be written in KCL.
|
||||
|
||||
use kittycad_execution_plan::{
|
||||
BinaryArithmetic, BinaryOperation, Destination, Instruction, InstructionKind, Operand, UnaryArithmetic,
|
||||
UnaryOperation,
|
||||
};
|
||||
use kittycad_execution_plan_traits::Address;
|
||||
|
||||
use crate::{CompileError, EpBinding, EvalPlan};
|
||||
|
||||
pub mod sketch;
|
||||
|
||||
pub trait Callable {
|
||||
fn call(&self, ctx: &mut Context<'_>, args: Vec<EpBinding>) -> Result<EvalPlan, CompileError>;
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Context<'a> {
|
||||
pub next_address: &'a mut Address,
|
||||
pub next_sketch_group: &'a mut usize,
|
||||
}
|
||||
|
||||
impl<'a> Context<'a> {
|
||||
pub fn assign_sketch_group(&mut self) -> usize {
|
||||
let out = *self.next_sketch_group;
|
||||
*self.next_sketch_group += 1;
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// Unary operator macro to quickly create new bindings.
|
||||
macro_rules! define_unary {
|
||||
() => {};
|
||||
($h:ident$( $r:ident)*) => {
|
||||
#[derive(Debug, Clone)]
|
||||
#[cfg_attr(test, derive(Eq, PartialEq))]
|
||||
pub struct $h;
|
||||
|
||||
impl Callable for $h {
|
||||
fn call(&self, ctx: &mut Context<'_>, mut args: Vec<EpBinding>) -> Result<EvalPlan, CompileError> {
|
||||
if args.len() > 1 {
|
||||
return Err(CompileError::TooManyArgs {
|
||||
fn_name: "$h".into(),
|
||||
maximum: 1,
|
||||
actual: args.len(),
|
||||
});
|
||||
}
|
||||
|
||||
let not_enough_args = CompileError::NotEnoughArgs {
|
||||
fn_name: "$h".into(),
|
||||
required: 1,
|
||||
actual: args.len(),
|
||||
};
|
||||
|
||||
let EpBinding::Single(arg0) = args.pop().ok_or(not_enough_args.clone())? else {
|
||||
return Err(CompileError::InvalidOperand("A single value binding is expected"));
|
||||
};
|
||||
|
||||
let destination = ctx.next_address.offset_by(1);
|
||||
let instructions = vec![
|
||||
Instruction::from(InstructionKind::UnaryArithmetic {
|
||||
arithmetic: UnaryArithmetic {
|
||||
operation: UnaryOperation::$h,
|
||||
operand: Operand::Reference(arg0)
|
||||
},
|
||||
destination: Destination::Address(destination)
|
||||
})
|
||||
];
|
||||
|
||||
Ok(EvalPlan {
|
||||
instructions,
|
||||
binding: EpBinding::Single(destination),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
define_unary!($($r)*);
|
||||
};
|
||||
}
|
||||
|
||||
define_unary!(Abs Acos Asin Atan Ceil Cos Floor Ln Log10 Log2 Sin Sqrt Tan ToDegrees ToRadians);
|
||||
|
||||
/// The identity function. Always returns its first input.
|
||||
/// Implemented purely on the KCL side so it doesn't need to be in the
|
||||
/// define_unary! macro above.
|
||||
#[derive(Debug, Clone)]
|
||||
#[cfg_attr(test, derive(Eq, PartialEq))]
|
||||
pub struct Id;
|
||||
|
||||
impl Callable for Id {
|
||||
fn call(&self, _: &mut Context<'_>, args: Vec<EpBinding>) -> Result<EvalPlan, CompileError> {
|
||||
if args.len() > 1 {
|
||||
return Err(CompileError::TooManyArgs {
|
||||
fn_name: "id".into(),
|
||||
maximum: 1,
|
||||
actual: args.len(),
|
||||
});
|
||||
}
|
||||
let arg = args
|
||||
.first()
|
||||
.ok_or(CompileError::NotEnoughArgs {
|
||||
fn_name: "id".into(),
|
||||
required: 1,
|
||||
actual: 0,
|
||||
})?
|
||||
.clone();
|
||||
Ok(EvalPlan {
|
||||
instructions: Vec::new(),
|
||||
binding: arg,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Binary operator macro to quickly create new bindings.
|
||||
macro_rules! define_binary {
|
||||
() => {};
|
||||
($h:ident$( $r:ident)*) => {
|
||||
#[derive(Debug, Clone)]
|
||||
#[cfg_attr(test, derive(Eq, PartialEq))]
|
||||
pub struct $h;
|
||||
|
||||
impl Callable for $h {
|
||||
fn call(&self, ctx: &mut Context<'_>, mut args: Vec<EpBinding>) -> Result<EvalPlan, CompileError> {
|
||||
let len = args.len();
|
||||
if len > 2 {
|
||||
return Err(CompileError::TooManyArgs {
|
||||
fn_name: "$h".into(),
|
||||
maximum: 2,
|
||||
actual: len,
|
||||
});
|
||||
}
|
||||
let not_enough_args = CompileError::NotEnoughArgs {
|
||||
fn_name: "$h".into(),
|
||||
required: 2,
|
||||
actual: len,
|
||||
};
|
||||
const ERR: &str = "cannot use composite values (e.g. array) as arguments to $h";
|
||||
let EpBinding::Single(arg1) = args.pop().ok_or(not_enough_args.clone())? else {
|
||||
return Err(CompileError::InvalidOperand(ERR));
|
||||
};
|
||||
let EpBinding::Single(arg0) = args.pop().ok_or(not_enough_args)? else {
|
||||
return Err(CompileError::InvalidOperand(ERR));
|
||||
};
|
||||
let destination = ctx.next_address.offset_by(1);
|
||||
Ok(EvalPlan {
|
||||
instructions: vec![Instruction::from(InstructionKind::BinaryArithmetic {
|
||||
arithmetic: BinaryArithmetic {
|
||||
operation: BinaryOperation::$h,
|
||||
operand0: Operand::Reference(arg0),
|
||||
operand1: Operand::Reference(arg1),
|
||||
},
|
||||
destination: Destination::Address(destination),
|
||||
})],
|
||||
binding: EpBinding::Single(destination),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
define_binary!($($r)*);
|
||||
};
|
||||
}
|
||||
|
||||
define_binary!(Add Log Max Min);
|
@ -1,8 +0,0 @@
|
||||
//! Native functions for sketching on the plane.
|
||||
|
||||
pub mod helpers;
|
||||
pub mod stdlib_functions;
|
||||
|
||||
pub use stdlib_functions::{
|
||||
Close, Extrude, Line, LineTo, StartSketchAt, TangentialArcTo, XLine, XLineTo, YLine, YLineTo,
|
||||
};
|
@ -1,202 +0,0 @@
|
||||
use kittycad_execution_plan::{api_request::ApiRequest, Destination, Instruction, InstructionKind};
|
||||
use kittycad_execution_plan_traits::{Address, InMemory};
|
||||
use kittycad_modeling_cmds::{id::ModelingCmdId, ModelingCmdEndpoint};
|
||||
|
||||
use crate::{binding_scope::EpBinding, error::CompileError};
|
||||
|
||||
/// Emit instructions for an API call with no parameters.
|
||||
pub fn no_arg_api_call(instrs: &mut Vec<Instruction>, endpoint: ModelingCmdEndpoint, cmd_id: ModelingCmdId) {
|
||||
instrs.push(Instruction::from(InstructionKind::ApiRequest(ApiRequest {
|
||||
endpoint,
|
||||
store_response: None,
|
||||
arguments: vec![],
|
||||
cmd_id,
|
||||
})))
|
||||
}
|
||||
|
||||
/// Emit instructions for an API call with the given parameters.
|
||||
/// The API parameters are stored in the EP memory stack.
|
||||
/// So, they have to be pushed onto the stack in the right order,
|
||||
/// i.e. the reverse order in which the API call's Rust struct defines the fields.
|
||||
pub fn stack_api_call<const N: usize>(
|
||||
instrs: &mut Vec<Instruction>,
|
||||
endpoint: ModelingCmdEndpoint,
|
||||
store_response: Option<Address>,
|
||||
cmd_id: ModelingCmdId,
|
||||
data: [Vec<kittycad_execution_plan_traits::Primitive>; N],
|
||||
) {
|
||||
let arguments = vec![InMemory::StackPop; data.len()];
|
||||
instrs.extend(data.map(|data| Instruction::from(InstructionKind::StackPush { data })));
|
||||
instrs.push(Instruction::from(InstructionKind::ApiRequest(ApiRequest {
|
||||
endpoint,
|
||||
store_response,
|
||||
arguments,
|
||||
cmd_id,
|
||||
})))
|
||||
}
|
||||
|
||||
pub fn sg_binding(
|
||||
b: EpBinding,
|
||||
fn_name: &'static str,
|
||||
expected: &'static str,
|
||||
arg_number: usize,
|
||||
) -> Result<usize, CompileError> {
|
||||
match b {
|
||||
EpBinding::SketchGroup { index } => Ok(index),
|
||||
EpBinding::Single(_) => Err(CompileError::ArgWrongType {
|
||||
fn_name,
|
||||
expected,
|
||||
actual: "single".to_owned(),
|
||||
arg_number,
|
||||
}),
|
||||
EpBinding::Sequence { .. } => Err(CompileError::ArgWrongType {
|
||||
fn_name,
|
||||
expected,
|
||||
actual: "array".to_owned(),
|
||||
arg_number,
|
||||
}),
|
||||
EpBinding::Map { .. } => Err(CompileError::ArgWrongType {
|
||||
fn_name,
|
||||
expected,
|
||||
actual: "object".to_owned(),
|
||||
arg_number,
|
||||
}),
|
||||
EpBinding::Function(_) => Err(CompileError::ArgWrongType {
|
||||
fn_name,
|
||||
expected,
|
||||
actual: "function".to_owned(),
|
||||
arg_number,
|
||||
}),
|
||||
EpBinding::Constant(_) => Err(CompileError::ArgWrongType {
|
||||
fn_name,
|
||||
expected,
|
||||
actual: "constant".to_owned(),
|
||||
arg_number,
|
||||
}),
|
||||
}
|
||||
}
|
||||
pub fn single_binding(
|
||||
b: EpBinding,
|
||||
fn_name: &'static str,
|
||||
expected: &'static str,
|
||||
arg_number: usize,
|
||||
) -> Result<Address, CompileError> {
|
||||
match b {
|
||||
EpBinding::Single(a) => Ok(a),
|
||||
EpBinding::SketchGroup { .. } => Err(CompileError::ArgWrongType {
|
||||
fn_name,
|
||||
expected,
|
||||
actual: "SketchGroup".to_owned(),
|
||||
arg_number,
|
||||
}),
|
||||
EpBinding::Sequence { .. } => Err(CompileError::ArgWrongType {
|
||||
fn_name,
|
||||
expected,
|
||||
actual: "array".to_owned(),
|
||||
arg_number,
|
||||
}),
|
||||
EpBinding::Map { .. } => Err(CompileError::ArgWrongType {
|
||||
fn_name,
|
||||
expected,
|
||||
actual: "object".to_owned(),
|
||||
arg_number,
|
||||
}),
|
||||
EpBinding::Function(_) => Err(CompileError::ArgWrongType {
|
||||
fn_name,
|
||||
expected,
|
||||
actual: "function".to_owned(),
|
||||
arg_number,
|
||||
}),
|
||||
EpBinding::Constant(_) => Err(CompileError::ArgWrongType {
|
||||
fn_name,
|
||||
expected,
|
||||
actual: "constant".to_owned(),
|
||||
arg_number,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn sequence_binding(
|
||||
b: EpBinding,
|
||||
fn_name: &'static str,
|
||||
expected: &'static str,
|
||||
arg_number: usize,
|
||||
) -> Result<Vec<EpBinding>, CompileError> {
|
||||
match b {
|
||||
EpBinding::Sequence { elements, .. } => Ok(elements),
|
||||
EpBinding::Single(_) => Err(CompileError::ArgWrongType {
|
||||
fn_name,
|
||||
expected,
|
||||
actual: "single".to_owned(),
|
||||
arg_number,
|
||||
}),
|
||||
EpBinding::SketchGroup { .. } => Err(CompileError::ArgWrongType {
|
||||
fn_name,
|
||||
expected,
|
||||
actual: "SketchGroup".to_owned(),
|
||||
arg_number,
|
||||
}),
|
||||
EpBinding::Map { .. } => Err(CompileError::ArgWrongType {
|
||||
fn_name,
|
||||
expected,
|
||||
actual: "object".to_owned(),
|
||||
arg_number,
|
||||
}),
|
||||
EpBinding::Function(_) => Err(CompileError::ArgWrongType {
|
||||
fn_name,
|
||||
expected,
|
||||
actual: "function".to_owned(),
|
||||
arg_number,
|
||||
}),
|
||||
EpBinding::Constant(_) => Err(CompileError::ArgWrongType {
|
||||
fn_name,
|
||||
expected,
|
||||
actual: "constant".to_owned(),
|
||||
arg_number,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract a 2D point from an argument to a KCL Function.
|
||||
pub fn arg_point2d(
|
||||
arg: EpBinding,
|
||||
fn_name: &'static str,
|
||||
instructions: &mut Vec<Instruction>,
|
||||
ctx: &mut crate::native_functions::Context<'_>,
|
||||
arg_number: usize,
|
||||
) -> Result<Address, CompileError> {
|
||||
let expected = "2D point (array with length 2)";
|
||||
let elements = sequence_binding(arg, fn_name, "an array of length 2", arg_number)?;
|
||||
if elements.len() != 2 {
|
||||
return Err(CompileError::ArgWrongType {
|
||||
fn_name,
|
||||
expected,
|
||||
actual: format!("array of length {}", elements.len()),
|
||||
arg_number: 0,
|
||||
});
|
||||
}
|
||||
// KCL stores points as an array.
|
||||
// KC API stores them as Rust objects laid flat out in memory.
|
||||
let start = ctx.next_address.offset_by(2);
|
||||
let start_x = start;
|
||||
let start_y = start + 1;
|
||||
let start_z = start + 2;
|
||||
instructions.extend([
|
||||
Instruction::from(InstructionKind::Copy {
|
||||
source: single_binding(elements[0].clone(), fn_name, "number", arg_number)?,
|
||||
destination: Destination::Address(start_x),
|
||||
length: 1,
|
||||
}),
|
||||
Instruction::from(InstructionKind::Copy {
|
||||
source: single_binding(elements[1].clone(), fn_name, "number", arg_number)?,
|
||||
destination: Destination::Address(start_y),
|
||||
length: 1,
|
||||
}),
|
||||
Instruction::from(InstructionKind::SetPrimitive {
|
||||
address: start_z,
|
||||
value: 0.0.into(),
|
||||
}),
|
||||
]);
|
||||
ctx.next_address.offset_by(1); // After we pushed 0.0 here, just above.
|
||||
Ok(start)
|
||||
}
|
@ -1,853 +0,0 @@
|
||||
use kittycad_execution_plan::{
|
||||
api_request::ApiRequest,
|
||||
sketch_types::{self, Axes, BasePath, Plane, SketchGroup},
|
||||
BinaryArithmetic, BinaryOperation, Destination, Instruction, InstructionKind, Operand,
|
||||
};
|
||||
use kittycad_execution_plan_traits::{Address, InMemory, Primitive, Value};
|
||||
use kittycad_modeling_cmds::{
|
||||
shared::{Point3d, Point4d},
|
||||
ModelingCmdEndpoint,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::helpers::{arg_point2d, no_arg_api_call, sg_binding, single_binding, stack_api_call};
|
||||
use crate::{binding_scope::EpBinding, error::CompileError, native_functions::Callable, EvalPlan};
|
||||
|
||||
#[derive(PartialEq)]
|
||||
pub enum At {
|
||||
RelativeXY,
|
||||
AbsoluteXY,
|
||||
RelativeX,
|
||||
AbsoluteX,
|
||||
RelativeY,
|
||||
AbsoluteY,
|
||||
}
|
||||
|
||||
impl At {
|
||||
pub fn is_relative(&self) -> bool {
|
||||
*self == At::RelativeX || *self == At::RelativeY || *self == At::RelativeXY
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[cfg_attr(test, derive(Eq, PartialEq))]
|
||||
pub struct Close;
|
||||
|
||||
impl Callable for Close {
|
||||
fn call(
|
||||
&self,
|
||||
_ctx: &mut crate::native_functions::Context<'_>,
|
||||
args: Vec<EpBinding>,
|
||||
) -> Result<EvalPlan, CompileError> {
|
||||
let mut instructions = Vec::new();
|
||||
let fn_name = "close";
|
||||
// Get all required params.
|
||||
let mut args_iter = args.into_iter();
|
||||
let Some(sketch_group) = args_iter.next() else {
|
||||
return Err(CompileError::NotEnoughArgs {
|
||||
fn_name: fn_name.into(),
|
||||
required: 1,
|
||||
actual: 1,
|
||||
});
|
||||
};
|
||||
// Check param type.
|
||||
let sg = sg_binding(sketch_group, fn_name, "sketch group", 1)?;
|
||||
let cmd_id = Uuid::new_v4().into();
|
||||
instructions.extend([
|
||||
// Push the path ID onto the stack.
|
||||
Instruction::from(InstructionKind::SketchGroupCopyFrom {
|
||||
destination: Destination::StackPush,
|
||||
length: 1,
|
||||
source: sg,
|
||||
offset: SketchGroup::path_id_offset(),
|
||||
}),
|
||||
// Call the 'extrude' API request.
|
||||
Instruction::from(InstructionKind::ApiRequest(ApiRequest {
|
||||
endpoint: ModelingCmdEndpoint::ClosePath,
|
||||
store_response: None,
|
||||
arguments: vec![
|
||||
// Target (path ID)
|
||||
InMemory::StackPop,
|
||||
],
|
||||
cmd_id,
|
||||
})),
|
||||
]);
|
||||
|
||||
Ok(EvalPlan {
|
||||
instructions,
|
||||
binding: EpBinding::SketchGroup { index: sg },
|
||||
})
|
||||
}
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
#[cfg_attr(test, derive(Eq, PartialEq))]
|
||||
pub struct Extrude;
|
||||
|
||||
impl Callable for Extrude {
|
||||
fn call(
|
||||
&self,
|
||||
_ctx: &mut crate::native_functions::Context<'_>,
|
||||
args: Vec<EpBinding>,
|
||||
) -> Result<EvalPlan, CompileError> {
|
||||
let mut instructions = Vec::new();
|
||||
let fn_name = "extrude";
|
||||
// Get all required params.
|
||||
let mut args_iter = args.into_iter();
|
||||
let Some(height) = args_iter.next() else {
|
||||
return Err(CompileError::NotEnoughArgs {
|
||||
fn_name: fn_name.into(),
|
||||
required: 2,
|
||||
actual: 0,
|
||||
});
|
||||
};
|
||||
let Some(sketch_group) = args_iter.next() else {
|
||||
return Err(CompileError::NotEnoughArgs {
|
||||
fn_name: fn_name.into(),
|
||||
required: 2,
|
||||
actual: 1,
|
||||
});
|
||||
};
|
||||
// Check param type.
|
||||
let height = single_binding(height, fn_name, "numeric height", 0)?;
|
||||
let sg = sg_binding(sketch_group, fn_name, "sketch group", 1)?;
|
||||
let cmd_id = Uuid::new_v4().into();
|
||||
instructions.extend([
|
||||
// Push the `cap` bool onto the stack.
|
||||
Instruction::from(InstructionKind::StackPush {
|
||||
data: vec![true.into()],
|
||||
}),
|
||||
// Push the path ID onto the stack.
|
||||
Instruction::from(InstructionKind::SketchGroupCopyFrom {
|
||||
destination: Destination::StackPush,
|
||||
length: 1,
|
||||
source: sg,
|
||||
offset: SketchGroup::path_id_offset(),
|
||||
}),
|
||||
// Call the 'extrude' API request.
|
||||
Instruction::from(InstructionKind::ApiRequest(ApiRequest {
|
||||
endpoint: ModelingCmdEndpoint::Extrude,
|
||||
store_response: None,
|
||||
arguments: vec![
|
||||
// Target
|
||||
InMemory::StackPop,
|
||||
// Height
|
||||
InMemory::Address(height),
|
||||
// Cap
|
||||
InMemory::StackPop,
|
||||
],
|
||||
cmd_id,
|
||||
})),
|
||||
]);
|
||||
|
||||
// TODO: make an ExtrudeGroup and store it.
|
||||
Ok(EvalPlan {
|
||||
instructions,
|
||||
binding: EpBinding::Single(Address::ZERO + 999),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[cfg_attr(test, derive(Eq, PartialEq))]
|
||||
pub struct LineTo;
|
||||
|
||||
impl Callable for LineTo {
|
||||
fn call(
|
||||
&self,
|
||||
ctx: &mut crate::native_functions::Context<'_>,
|
||||
args: Vec<EpBinding>,
|
||||
) -> Result<EvalPlan, CompileError> {
|
||||
LineBare::call(ctx, "lineTo", args, LineBareOptions { at: At::AbsoluteXY })
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[cfg_attr(test, derive(Eq, PartialEq))]
|
||||
pub struct Line;
|
||||
|
||||
impl Callable for Line {
|
||||
fn call(
|
||||
&self,
|
||||
ctx: &mut crate::native_functions::Context<'_>,
|
||||
args: Vec<EpBinding>,
|
||||
) -> Result<EvalPlan, CompileError> {
|
||||
LineBare::call(ctx, "line", args, LineBareOptions { at: At::RelativeXY })
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[cfg_attr(test, derive(Eq, PartialEq))]
|
||||
pub struct XLineTo;
|
||||
|
||||
impl Callable for XLineTo {
|
||||
fn call(
|
||||
&self,
|
||||
ctx: &mut crate::native_functions::Context<'_>,
|
||||
args: Vec<EpBinding>,
|
||||
) -> Result<EvalPlan, CompileError> {
|
||||
LineBare::call(ctx, "xLineTo", args, LineBareOptions { at: At::AbsoluteX })
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[cfg_attr(test, derive(Eq, PartialEq))]
|
||||
pub struct XLine;
|
||||
|
||||
impl Callable for XLine {
|
||||
fn call(
|
||||
&self,
|
||||
ctx: &mut crate::native_functions::Context<'_>,
|
||||
args: Vec<EpBinding>,
|
||||
) -> Result<EvalPlan, CompileError> {
|
||||
LineBare::call(ctx, "xLine", args, LineBareOptions { at: At::RelativeX })
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[cfg_attr(test, derive(Eq, PartialEq))]
|
||||
pub struct YLineTo;
|
||||
|
||||
impl Callable for YLineTo {
|
||||
fn call(
|
||||
&self,
|
||||
ctx: &mut crate::native_functions::Context<'_>,
|
||||
args: Vec<EpBinding>,
|
||||
) -> Result<EvalPlan, CompileError> {
|
||||
LineBare::call(ctx, "yLineTo", args, LineBareOptions { at: At::AbsoluteY })
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[cfg_attr(test, derive(Eq, PartialEq))]
|
||||
pub struct YLine;
|
||||
|
||||
impl Callable for YLine {
|
||||
fn call(
|
||||
&self,
|
||||
ctx: &mut crate::native_functions::Context<'_>,
|
||||
args: Vec<EpBinding>,
|
||||
) -> Result<EvalPlan, CompileError> {
|
||||
LineBare::call(ctx, "yLine", args, LineBareOptions { at: At::RelativeY })
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[cfg_attr(test, derive(Eq, PartialEq))]
|
||||
/// Exposes all the possible arguments the `line` modeling command can take.
|
||||
/// Reduces code for the other line functions needed.
|
||||
/// We do not expose this to the developer since it does not align with
|
||||
/// the documentation (there is no "lineBare").
|
||||
pub struct LineBare;
|
||||
|
||||
/// Used to configure the call to handle different line variants.
|
||||
pub struct LineBareOptions {
|
||||
/// Where to start coordinates at, ex: At::RelativeXY.
|
||||
at: At,
|
||||
}
|
||||
|
||||
impl LineBare {
|
||||
fn call(
|
||||
ctx: &mut crate::native_functions::Context<'_>,
|
||||
fn_name: &'static str,
|
||||
args: Vec<EpBinding>,
|
||||
opts: LineBareOptions,
|
||||
) -> Result<EvalPlan, CompileError> {
|
||||
let mut instructions = Vec::new();
|
||||
|
||||
let required = 2;
|
||||
|
||||
let mut args_iter = args.into_iter();
|
||||
|
||||
let Some(to) = args_iter.next() else {
|
||||
return Err(CompileError::NotEnoughArgs {
|
||||
fn_name: fn_name.into(),
|
||||
required,
|
||||
actual: args_iter.count(),
|
||||
});
|
||||
};
|
||||
|
||||
let Some(sketch_group) = args_iter.next() else {
|
||||
return Err(CompileError::NotEnoughArgs {
|
||||
fn_name: fn_name.into(),
|
||||
required,
|
||||
actual: args_iter.count(),
|
||||
});
|
||||
};
|
||||
|
||||
let tag = match args_iter.next() {
|
||||
Some(a) => a,
|
||||
None => {
|
||||
// Write an empty string and use that.
|
||||
let empty_string_addr = ctx.next_address.offset_by(1);
|
||||
instructions.push(Instruction::from(InstructionKind::SetPrimitive {
|
||||
address: empty_string_addr,
|
||||
value: String::new().into(),
|
||||
}));
|
||||
EpBinding::Single(empty_string_addr)
|
||||
}
|
||||
};
|
||||
|
||||
// Check the type of required params.
|
||||
// We don't check `to` here because it can take on either a
|
||||
// EpBinding::Sequence or EpBinding::Single.
|
||||
|
||||
let sg = sg_binding(sketch_group, fn_name, "sketch group", 1)?;
|
||||
let tag = single_binding(tag, fn_name, "string tag", 2)?;
|
||||
let id = Uuid::new_v4();
|
||||
|
||||
// Start of the path segment (which is a straight line).
|
||||
let length_of_3d_point = Point3d::<f64>::default().into_parts().len();
|
||||
let start_of_line = ctx.next_address.offset_by(1);
|
||||
|
||||
// Reserve space for the line's end, and the `relative: bool` field.
|
||||
ctx.next_address.offset_by(length_of_3d_point + 1);
|
||||
let new_sg_index = ctx.assign_sketch_group();
|
||||
|
||||
// Copy based on the options.
|
||||
match opts {
|
||||
LineBareOptions { at: At::AbsoluteXY, .. } | LineBareOptions { at: At::RelativeXY, .. } => {
|
||||
// Push the `to` 2D point onto the stack.
|
||||
let EpBinding::Sequence { elements, length_at: _ } = to.clone() else {
|
||||
return Err(CompileError::InvalidOperand("Must pass a list of length 2"));
|
||||
};
|
||||
let &[EpBinding::Single(el0), EpBinding::Single(el1)] = elements.as_slice() else {
|
||||
return Err(CompileError::InvalidOperand("Must pass a sequence here."));
|
||||
};
|
||||
instructions.extend([
|
||||
// X
|
||||
Instruction::from(InstructionKind::Copy {
|
||||
source: el0,
|
||||
length: 1,
|
||||
destination: Destination::StackPush,
|
||||
}),
|
||||
// Y
|
||||
Instruction::from(InstructionKind::Copy {
|
||||
source: el1,
|
||||
length: 1,
|
||||
destination: Destination::StackExtend,
|
||||
}),
|
||||
// Z
|
||||
Instruction::from(InstructionKind::StackExtend { data: vec![0.0.into()] }),
|
||||
]);
|
||||
}
|
||||
LineBareOptions { at: At::AbsoluteX, .. } | LineBareOptions { at: At::RelativeX, .. } => {
|
||||
let EpBinding::Single(addr) = to else {
|
||||
return Err(CompileError::InvalidOperand("Must pass a single value here."));
|
||||
};
|
||||
instructions.extend([
|
||||
Instruction::from(InstructionKind::Copy {
|
||||
// X
|
||||
source: addr,
|
||||
length: 1,
|
||||
destination: Destination::StackPush,
|
||||
}),
|
||||
Instruction::from(InstructionKind::StackExtend {
|
||||
data: vec![Primitive::from(0.0)],
|
||||
}), // Y
|
||||
Instruction::from(InstructionKind::StackExtend {
|
||||
data: vec![Primitive::from(0.0)],
|
||||
}), // Z
|
||||
]);
|
||||
}
|
||||
LineBareOptions { at: At::AbsoluteY, .. } | LineBareOptions { at: At::RelativeY, .. } => {
|
||||
let EpBinding::Single(addr) = to else {
|
||||
return Err(CompileError::InvalidOperand("Must pass a single value here."));
|
||||
};
|
||||
instructions.extend([
|
||||
// X
|
||||
Instruction::from(InstructionKind::StackPush {
|
||||
data: vec![Primitive::from(0.0)],
|
||||
}),
|
||||
// Y
|
||||
Instruction::from(InstructionKind::Copy {
|
||||
source: addr,
|
||||
length: 1,
|
||||
destination: Destination::StackExtend,
|
||||
}),
|
||||
// Z
|
||||
Instruction::from(InstructionKind::StackExtend {
|
||||
data: vec![Primitive::from(0.0)],
|
||||
}),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
instructions.extend([
|
||||
// Append the new path segment to memory.
|
||||
// First comes its tag.
|
||||
Instruction::from(InstructionKind::SetPrimitive {
|
||||
address: start_of_line,
|
||||
value: "Line".to_owned().into(),
|
||||
}),
|
||||
// Then its end
|
||||
Instruction::from(InstructionKind::StackPop {
|
||||
destination: Some(Destination::Address(start_of_line + 1)),
|
||||
}),
|
||||
// Then its `relative` field.
|
||||
Instruction::from(InstructionKind::SetPrimitive {
|
||||
address: start_of_line + 1 + length_of_3d_point,
|
||||
value: opts.at.is_relative().into(),
|
||||
}),
|
||||
// Push the path ID onto the stack.
|
||||
Instruction::from(InstructionKind::SketchGroupCopyFrom {
|
||||
destination: Destination::StackPush,
|
||||
length: 1,
|
||||
source: sg,
|
||||
offset: SketchGroup::path_id_offset(),
|
||||
}),
|
||||
// Send the ExtendPath request
|
||||
Instruction::from(InstructionKind::ApiRequest(ApiRequest {
|
||||
endpoint: ModelingCmdEndpoint::ExtendPath,
|
||||
store_response: None,
|
||||
arguments: vec![
|
||||
// Path ID
|
||||
InMemory::StackPop,
|
||||
// Segment
|
||||
InMemory::Address(start_of_line),
|
||||
],
|
||||
cmd_id: id.into(),
|
||||
})),
|
||||
// Push the new segment in SketchGroup format.
|
||||
// Path tag.
|
||||
Instruction::from(InstructionKind::StackPush {
|
||||
data: vec![Primitive::from("ToPoint".to_owned())],
|
||||
}),
|
||||
// `BasePath::from` point.
|
||||
// Place them in the secondary stack to prepare ToPoint structure.
|
||||
Instruction::from(InstructionKind::SketchGroupGetLastPoint {
|
||||
source: sg,
|
||||
destination: Destination::StackExtend,
|
||||
}),
|
||||
]);
|
||||
|
||||
// Reserve space for the segment last point
|
||||
let to_point_from = ctx.next_address.offset_by(2);
|
||||
|
||||
instructions.extend([
|
||||
// Copy to the primary stack as well to be worked with.
|
||||
Instruction::from(InstructionKind::SketchGroupGetLastPoint {
|
||||
source: sg,
|
||||
destination: Destination::Address(to_point_from),
|
||||
}),
|
||||
]);
|
||||
|
||||
// `BasePath::to` point.
|
||||
|
||||
// The copy here depends on the incoming `to` data.
|
||||
// Sometimes it's a list, sometimes it's single datum.
|
||||
// And the relative/not relative matters. When relative, we need to
|
||||
// copy coords from `from` into the new `to` coord that don't change.
|
||||
// At least everything else can be built up from these "primitives".
|
||||
if let EpBinding::Sequence { elements, length_at: _ } = to.clone() {
|
||||
if let &[EpBinding::Single(el0), EpBinding::Single(el1)] = elements.as_slice() {
|
||||
match opts {
|
||||
// ToPoint { from: { x1, y1 }, to: { x1 + x2, y1 + y2 } }
|
||||
LineBareOptions { at: At::RelativeXY, .. } => {
|
||||
instructions.extend([
|
||||
Instruction::from(InstructionKind::BinaryArithmetic {
|
||||
arithmetic: BinaryArithmetic {
|
||||
operation: BinaryOperation::Add,
|
||||
operand0: Operand::Reference(to_point_from + 0),
|
||||
operand1: Operand::Reference(el0),
|
||||
},
|
||||
destination: Destination::StackExtend,
|
||||
}),
|
||||
Instruction::from(InstructionKind::BinaryArithmetic {
|
||||
arithmetic: BinaryArithmetic {
|
||||
operation: BinaryOperation::Add,
|
||||
operand0: Operand::Reference(to_point_from + 1),
|
||||
operand1: Operand::Reference(el1),
|
||||
},
|
||||
destination: Destination::StackExtend,
|
||||
}),
|
||||
]);
|
||||
}
|
||||
// ToPoint { from: { x1, y1 }, to: { x2, y2 } }
|
||||
LineBareOptions { at: At::AbsoluteXY, .. } => {
|
||||
// Otherwise just directly copy the new points.
|
||||
instructions.extend([
|
||||
Instruction::from(InstructionKind::Copy {
|
||||
source: el0,
|
||||
length: 1,
|
||||
destination: Destination::StackExtend,
|
||||
}),
|
||||
Instruction::from(InstructionKind::Copy {
|
||||
source: el1,
|
||||
length: 1,
|
||||
destination: Destination::StackExtend,
|
||||
}),
|
||||
]);
|
||||
}
|
||||
_ => {
|
||||
return Err(CompileError::InvalidOperand(
|
||||
"A Sequence with At::...X or At::...Y is not valid here. Must be At::...XY.",
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if let EpBinding::Single(addr) = to {
|
||||
match opts {
|
||||
// ToPoint { from: { x1, y1 }, to: { x1 + x2, y1 } }
|
||||
LineBareOptions { at: At::RelativeX } => {
|
||||
instructions.extend([
|
||||
Instruction::from(InstructionKind::BinaryArithmetic {
|
||||
arithmetic: BinaryArithmetic {
|
||||
operation: BinaryOperation::Add,
|
||||
operand0: Operand::Reference(to_point_from + 0),
|
||||
operand1: Operand::Reference(addr),
|
||||
},
|
||||
destination: Destination::StackExtend,
|
||||
}),
|
||||
Instruction::from(InstructionKind::Copy {
|
||||
source: to_point_from + 1,
|
||||
length: 1,
|
||||
destination: Destination::StackExtend,
|
||||
}),
|
||||
]);
|
||||
}
|
||||
// ToPoint { from: { x1, y1 }, to: { x2, y1 } }
|
||||
LineBareOptions { at: At::AbsoluteX } => {
|
||||
instructions.extend([
|
||||
Instruction::from(InstructionKind::Copy {
|
||||
source: addr,
|
||||
length: 1,
|
||||
destination: Destination::StackExtend,
|
||||
}),
|
||||
Instruction::from(InstructionKind::Copy {
|
||||
source: to_point_from + 1,
|
||||
length: 1,
|
||||
destination: Destination::StackExtend,
|
||||
}),
|
||||
]);
|
||||
}
|
||||
// ToPoint { from: { x1, y1 }, to: { x1, y1 + y2 } }
|
||||
LineBareOptions { at: At::RelativeY } => {
|
||||
instructions.extend([
|
||||
Instruction::from(InstructionKind::Copy {
|
||||
source: to_point_from + 0,
|
||||
length: 1,
|
||||
destination: Destination::StackExtend,
|
||||
}),
|
||||
Instruction::from(InstructionKind::BinaryArithmetic {
|
||||
arithmetic: BinaryArithmetic {
|
||||
operation: BinaryOperation::Add,
|
||||
operand0: Operand::Reference(to_point_from + 1),
|
||||
operand1: Operand::Reference(addr),
|
||||
},
|
||||
destination: Destination::StackExtend,
|
||||
}),
|
||||
]);
|
||||
}
|
||||
// ToPoint { from: { x1, y1 }, to: { x1, y2 } }
|
||||
LineBareOptions { at: At::AbsoluteY } => {
|
||||
instructions.extend([
|
||||
Instruction::from(InstructionKind::Copy {
|
||||
source: to_point_from + 0,
|
||||
length: 1,
|
||||
destination: Destination::StackExtend,
|
||||
}),
|
||||
Instruction::from(InstructionKind::Copy {
|
||||
source: addr,
|
||||
length: 1,
|
||||
destination: Destination::StackExtend,
|
||||
}),
|
||||
]);
|
||||
}
|
||||
_ => {
|
||||
return Err(CompileError::InvalidOperand(
|
||||
"A Single binding with At::...XY is not valid here.",
|
||||
));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return Err(CompileError::InvalidOperand(
|
||||
"Must be a sequence or single value binding.",
|
||||
));
|
||||
}
|
||||
|
||||
instructions.extend([
|
||||
// `BasePath::name` string.
|
||||
Instruction::from(InstructionKind::Copy {
|
||||
source: tag,
|
||||
length: 1,
|
||||
destination: Destination::StackExtend,
|
||||
}),
|
||||
// Update the SketchGroup with its new segment.
|
||||
Instruction::from(InstructionKind::SketchGroupAddSegment {
|
||||
destination: new_sg_index,
|
||||
segment: InMemory::StackPop,
|
||||
source: sg,
|
||||
}),
|
||||
]);
|
||||
|
||||
Ok(EvalPlan {
|
||||
instructions,
|
||||
binding: EpBinding::SketchGroup { index: new_sg_index },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[cfg_attr(test, derive(Eq, PartialEq))]
|
||||
pub struct StartSketchAt;
|
||||
|
||||
impl Callable for StartSketchAt {
|
||||
fn call(
|
||||
&self,
|
||||
ctx: &mut crate::native_functions::Context<'_>,
|
||||
args: Vec<EpBinding>,
|
||||
) -> Result<EvalPlan, CompileError> {
|
||||
let mut instructions = Vec::new();
|
||||
// First, before we send any API calls, let's validate the arguments to this function.
|
||||
let mut args_iter = args.into_iter();
|
||||
let Some(start) = args_iter.next() else {
|
||||
return Err(CompileError::NotEnoughArgs {
|
||||
fn_name: "startSketchAt".into(),
|
||||
required: 1,
|
||||
actual: 0,
|
||||
});
|
||||
};
|
||||
let start_point = arg_point2d(start, "startSketchAt", &mut instructions, ctx, 0)?;
|
||||
let tag = match args_iter.next() {
|
||||
None => None,
|
||||
Some(b) => Some(single_binding(b, "startSketchAt", "a single string", 1)?),
|
||||
};
|
||||
|
||||
// Define some constants:
|
||||
let axes = Axes {
|
||||
x: Point3d { x: 1.0, y: 0.0, z: 0.0 },
|
||||
y: Point3d { x: 0.0, y: 1.0, z: 0.0 },
|
||||
z: Point3d { x: 0.0, y: 0.0, z: 1.0 },
|
||||
};
|
||||
let origin = Point3d::default();
|
||||
|
||||
// Now the function can start.
|
||||
// First API call: make the plane.
|
||||
let plane_id = Uuid::new_v4();
|
||||
stack_api_call(
|
||||
&mut instructions,
|
||||
ModelingCmdEndpoint::MakePlane,
|
||||
None,
|
||||
plane_id.into(),
|
||||
[
|
||||
Some(true).into_parts(), // hide
|
||||
vec![false.into()], // clobber
|
||||
vec![60.0.into()], // size
|
||||
axes.y.into_parts(),
|
||||
axes.x.into_parts(),
|
||||
origin.into_parts(),
|
||||
],
|
||||
);
|
||||
|
||||
// Next, enter sketch mode.
|
||||
stack_api_call(
|
||||
&mut instructions,
|
||||
ModelingCmdEndpoint::EnableSketchMode,
|
||||
None,
|
||||
Uuid::new_v4().into(),
|
||||
[
|
||||
Some(axes.z).into_parts(),
|
||||
vec![false.into()], // adjust camera
|
||||
vec![false.into()], // animated
|
||||
vec![false.into()], // ortho mode
|
||||
vec![plane_id.into()], // entity id (plane in this case)
|
||||
],
|
||||
);
|
||||
|
||||
// Then start a path
|
||||
let path_id = Uuid::new_v4();
|
||||
no_arg_api_call(&mut instructions, ModelingCmdEndpoint::StartPath, path_id.into());
|
||||
|
||||
// Move the path pen to the given point.
|
||||
instructions.push(Instruction::from(InstructionKind::StackPush {
|
||||
data: vec![path_id.into()],
|
||||
}));
|
||||
instructions.push(Instruction::from(InstructionKind::ApiRequest(ApiRequest {
|
||||
endpoint: ModelingCmdEndpoint::MovePathPen,
|
||||
store_response: None,
|
||||
arguments: vec![InMemory::StackPop, InMemory::Address(start_point)],
|
||||
cmd_id: Uuid::new_v4().into(),
|
||||
})));
|
||||
|
||||
// Starting a sketch creates a sketch group.
|
||||
// Updating the sketch will update this sketch group later.
|
||||
let sketch_group = SketchGroup {
|
||||
id: path_id,
|
||||
position: origin,
|
||||
rotation: Point4d {
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
z: 0.0,
|
||||
w: 1.0,
|
||||
},
|
||||
// Below: Must copy the existing data (from the arguments to this KCL function)
|
||||
// over these values after writing to memory.
|
||||
path_first: BasePath {
|
||||
from: Default::default(),
|
||||
to: Default::default(),
|
||||
name: Default::default(),
|
||||
},
|
||||
path_rest: Vec::new(),
|
||||
on: sketch_types::SketchSurface::Plane(Plane {
|
||||
id: plane_id,
|
||||
value: sketch_types::PlaneType::XY,
|
||||
origin,
|
||||
axes,
|
||||
}),
|
||||
axes,
|
||||
entity_id: Some(plane_id),
|
||||
};
|
||||
let sketch_group_index = ctx.assign_sketch_group();
|
||||
instructions.extend([
|
||||
Instruction::from(InstructionKind::SketchGroupSet {
|
||||
sketch_group,
|
||||
destination: sketch_group_index,
|
||||
}),
|
||||
// As mentioned above: Copy the existing data over the `path_first`.
|
||||
Instruction::from(InstructionKind::SketchGroupSetBasePath {
|
||||
source: sketch_group_index,
|
||||
from: InMemory::Address(start_point),
|
||||
to: InMemory::Address(start_point),
|
||||
name: tag.map(InMemory::Address),
|
||||
}),
|
||||
]);
|
||||
|
||||
Ok(EvalPlan {
|
||||
instructions,
|
||||
binding: EpBinding::SketchGroup {
|
||||
index: sketch_group_index,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[cfg_attr(test, derive(Eq, PartialEq))]
|
||||
pub struct TangentialArcTo;
|
||||
|
||||
impl Callable for TangentialArcTo {
|
||||
fn call(
|
||||
&self,
|
||||
ctx: &mut crate::native_functions::Context<'_>,
|
||||
args: Vec<EpBinding>,
|
||||
) -> Result<EvalPlan, CompileError> {
|
||||
let mut instructions = Vec::new();
|
||||
let fn_name = "tangential_arc_to";
|
||||
// Get both required params.
|
||||
let mut args_iter = args.into_iter();
|
||||
let Some(to) = args_iter.next() else {
|
||||
return Err(CompileError::NotEnoughArgs {
|
||||
fn_name: fn_name.into(),
|
||||
required: 2,
|
||||
actual: 0,
|
||||
});
|
||||
};
|
||||
let Some(sketch_group) = args_iter.next() else {
|
||||
return Err(CompileError::NotEnoughArgs {
|
||||
fn_name: fn_name.into(),
|
||||
required: 2,
|
||||
actual: 1,
|
||||
});
|
||||
};
|
||||
let tag = match args_iter.next() {
|
||||
Some(a) => a,
|
||||
None => {
|
||||
// Write an empty string and use that.
|
||||
let empty_string_addr = ctx.next_address.offset_by(1);
|
||||
instructions.push(Instruction::from(InstructionKind::SetPrimitive {
|
||||
address: empty_string_addr,
|
||||
value: String::new().into(),
|
||||
}));
|
||||
EpBinding::Single(empty_string_addr)
|
||||
}
|
||||
};
|
||||
// Check the type of required params.
|
||||
let to = arg_point2d(to, fn_name, &mut instructions, ctx, 0)?;
|
||||
let sg = sg_binding(sketch_group, fn_name, "sketch group", 1)?;
|
||||
let tag = single_binding(tag, fn_name, "string tag", 2)?;
|
||||
let id = Uuid::new_v4();
|
||||
// Start of the path segment (which is a straight line).
|
||||
let length_of_3d_point = Point3d::<f64>::default().into_parts().len();
|
||||
let start_of_tangential_arc = ctx.next_address.offset_by(1);
|
||||
// Reserve space for the line's end, and the `relative: bool` field.
|
||||
ctx.next_address.offset_by(length_of_3d_point + 1);
|
||||
let new_sg_index = ctx.assign_sketch_group();
|
||||
instructions.extend([
|
||||
// Push the `to` 2D point onto the stack.
|
||||
Instruction::from(InstructionKind::Copy {
|
||||
source: to,
|
||||
length: 2,
|
||||
destination: Destination::StackPush,
|
||||
}),
|
||||
// Make it a 3D point.
|
||||
Instruction::from(InstructionKind::StackExtend { data: vec![0.0.into()] }),
|
||||
// Append the new path segment to memory.
|
||||
// First comes its tag.
|
||||
Instruction::from(InstructionKind::SetPrimitive {
|
||||
address: start_of_tangential_arc,
|
||||
value: "TangentialArcTo".to_owned().into(),
|
||||
}),
|
||||
// Then its to
|
||||
Instruction::from(InstructionKind::StackPop {
|
||||
destination: Some(Destination::Address(start_of_tangential_arc + 1)),
|
||||
}),
|
||||
// Then its `angle_snap_increment` field.
|
||||
Instruction::from(InstructionKind::SetPrimitive {
|
||||
address: start_of_tangential_arc + 1 + length_of_3d_point,
|
||||
value: Primitive::from("None".to_owned()),
|
||||
}),
|
||||
// Push the path ID onto the stack.
|
||||
Instruction::from(InstructionKind::SketchGroupCopyFrom {
|
||||
destination: Destination::StackPush,
|
||||
length: 1,
|
||||
source: sg,
|
||||
offset: SketchGroup::path_id_offset(),
|
||||
}),
|
||||
// Send the ExtendPath request
|
||||
Instruction::from(InstructionKind::ApiRequest(ApiRequest {
|
||||
endpoint: ModelingCmdEndpoint::ExtendPath,
|
||||
store_response: None,
|
||||
arguments: vec![
|
||||
// Path ID
|
||||
InMemory::StackPop,
|
||||
// Segment
|
||||
InMemory::Address(start_of_tangential_arc),
|
||||
],
|
||||
cmd_id: id.into(),
|
||||
})),
|
||||
// Push the new segment in SketchGroup format.
|
||||
// Path tag.
|
||||
Instruction::from(InstructionKind::StackPush {
|
||||
data: vec![Primitive::from("ToPoint".to_owned())],
|
||||
}),
|
||||
// `BasePath::from` point.
|
||||
Instruction::from(InstructionKind::SketchGroupGetLastPoint {
|
||||
source: sg,
|
||||
destination: Destination::StackExtend,
|
||||
}),
|
||||
// `BasePath::to` point.
|
||||
Instruction::from(InstructionKind::Copy {
|
||||
source: start_of_tangential_arc + 1,
|
||||
length: 2,
|
||||
destination: Destination::StackExtend,
|
||||
}),
|
||||
// `BasePath::name` string.
|
||||
Instruction::from(InstructionKind::Copy {
|
||||
source: tag,
|
||||
length: 1,
|
||||
destination: Destination::StackExtend,
|
||||
}),
|
||||
// Update the SketchGroup with its new segment.
|
||||
Instruction::from(InstructionKind::SketchGroupAddSegment {
|
||||
destination: new_sg_index,
|
||||
segment: InMemory::StackPop,
|
||||
source: sg,
|
||||
}),
|
||||
]);
|
||||
|
||||
Ok(EvalPlan {
|
||||
instructions,
|
||||
binding: EpBinding::SketchGroup { index: new_sg_index },
|
||||
})
|
||||
}
|
||||
}
|
@ -25,30 +25,28 @@ futures = { version = "0.3.30" }
|
||||
git_rev = "0.1.0"
|
||||
gltf-json = "1.4.1"
|
||||
kittycad = { workspace = true, features = ["clap"] }
|
||||
kittycad-execution-plan-macros = { workspace = true }
|
||||
kittycad-execution-plan-traits = { workspace = true }
|
||||
lazy_static = "1.4.0"
|
||||
mime_guess = "2.0.4"
|
||||
parse-display = "0.9.0"
|
||||
parse-display = "0.9.1"
|
||||
reqwest = { version = "0.11.26", default-features = false, features = ["stream", "rustls-tls"] }
|
||||
ropey = "1.6.1"
|
||||
schemars = { version = "0.8.17", features = ["impl_json_schema", "url", "uuid1"] }
|
||||
serde = { version = "1.0.202", features = ["derive"] }
|
||||
serde = { version = "1.0.203", features = ["derive"] }
|
||||
serde_json = "1.0.116"
|
||||
sha2 = "0.10.8"
|
||||
thiserror = "1.0.61"
|
||||
toml = "0.8.13"
|
||||
toml = "0.8.14"
|
||||
# TODO: change this to a cargo release once 8.1.1 comes out
|
||||
ts-rs = { git = "https://github.com/Aleph-Alpha/ts-rs", features = ["uuid-impl", "url-impl", "chrono-impl", "no-serde-warnings"] }
|
||||
url = { version = "2.5.0", features = ["serde"] }
|
||||
uuid = { version = "1.8.0", features = ["v4", "js", "serde"] }
|
||||
validator = { version = "0.18.1", features = ["derive"] }
|
||||
winnow = "0.5.40"
|
||||
zip = { version = "1.3.0", default-features = false }
|
||||
zip = { version = "2.0.0", default-features = false }
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
js-sys = { version = "0.3.69" }
|
||||
tokio = { version = "1.37.0", features = ["sync", "time"] }
|
||||
tokio = { version = "1.38.0", features = ["sync", "time"] }
|
||||
tower-lsp = { version = "0.20.0", default-features = false, features = ["runtime-agnostic"] }
|
||||
wasm-bindgen = "0.2.91"
|
||||
wasm-bindgen-futures = "0.4.42"
|
||||
@ -57,8 +55,8 @@ web-sys = { version = "0.3.69", features = ["console"] }
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
approx = "0.5"
|
||||
bson = { version = "2.10.0", features = ["uuid-1", "chrono"] }
|
||||
tokio = { version = "1.37.0", features = ["full"] }
|
||||
tokio-tungstenite = { version = "0.21.0", features = ["rustls-tls-native-roots"] }
|
||||
tokio = { version = "1.38.0", features = ["full"] }
|
||||
tokio-tungstenite = { version = "0.23.0", features = ["rustls-tls-native-roots"] }
|
||||
tower-lsp = { version = "0.20.0", features = ["proposed"] }
|
||||
|
||||
[features]
|
||||
@ -79,12 +77,12 @@ convert_case = "0.6.0"
|
||||
criterion = "0.5.1"
|
||||
expectorate = "1.1.0"
|
||||
iai = "0.1"
|
||||
image = "0.24.9"
|
||||
image = {version = "0.25.1", default-features = false, features = ["png"] }
|
||||
insta = { version = "1.38.0", features = ["json"] }
|
||||
itertools = "0.13.0"
|
||||
pretty_assertions = "1.4.0"
|
||||
tokio = { version = "1.37.0", features = ["rt-multi-thread", "macros", "time"] }
|
||||
twenty-twenty = "0.7.0"
|
||||
twenty-twenty = "0.8.0"
|
||||
|
||||
[[bench]]
|
||||
name = "compiler_benchmark_criterion"
|
||||
|