Compare commits
5 Commits
v0.22.2
...
kurt-test-
Author | SHA1 | Date | |
---|---|---|---|
34f5bccd6a | |||
bf4c666403 | |||
b769aeffe8 | |||
c158ffcb79 | |||
b5e4b7cf2b |
@ -1,3 +1,3 @@
|
||||
[codespell]
|
||||
ignore-words-list: crate,everytime,inout,co-ordinate,ot,nwo,absolutey,atleast,ue
|
||||
ignore-words-list: crate,everytime,inout,co-ordinate,ot,nwo,absolutey,atleast
|
||||
skip: **/target,node_modules,build,**/Cargo.lock,./docs/kcl/*.md,./src-tauri/gen/schemas
|
||||
|
5
.github/workflows/cargo-clippy.yml
vendored
@ -54,8 +54,3 @@ jobs:
|
||||
run: |
|
||||
cd "${{ matrix.dir }}"
|
||||
cargo clippy --all --tests --benches -- -D warnings
|
||||
# If this fails, run "cargo check" to update Cargo.lock,
|
||||
# then add Cargo.lock to the PR.
|
||||
- name: Check Cargo.lock doesn't need updating
|
||||
run: |
|
||||
cargo check --locked || echo "Pls run cargo check and commit the changed Cargo.lock"
|
||||
|
2
.github/workflows/ci.yml
vendored
@ -180,7 +180,9 @@ 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'
|
||||
|
18
.github/workflows/playwright.yml
vendored
@ -46,18 +46,12 @@ 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@v5
|
||||
uses: dawidd6/action-download-artifact@v3
|
||||
continue-on-error: true
|
||||
with:
|
||||
github_token: ${{secrets.GITHUB_TOKEN}}
|
||||
@ -149,20 +143,12 @@ 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@v5
|
||||
uses: dawidd6/action-download-artifact@v3
|
||||
continue-on-error: true
|
||||
with:
|
||||
github_token: ${{secrets.GITHUB_TOKEN}}
|
||||
|
14
Makefile
@ -1,14 +0,0 @@
|
||||
.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
|
36
README.md
@ -197,32 +197,28 @@ For more information on fuzzing you can check out
|
||||
|
||||
### Playwright
|
||||
|
||||
For a portable way to run Playwright you'll need Docker.
|
||||
|
||||
After that, open a terminal and run:
|
||||
First time running plawright locally, you'll need to add the secrets file
|
||||
|
||||
```bash
|
||||
docker run --network host --rm --init -it playwright/chrome:playwright-1.43.1
|
||||
touch ./e2e/playwright/playwright-secrets.env
|
||||
printf 'token="your-token"\nsnapshottoken="your-snapshot-token"' > ./e2e/playwright/playwright-secrets.env
|
||||
```
|
||||
|
||||
and in another terminal, run:
|
||||
|
||||
```bash
|
||||
PW_TEST_CONNECT_WS_ENDPOINT=ws://127.0.0.1:4444/ yarn playwright test --project="Google Chrome" <test suite>
|
||||
```
|
||||
|
||||
An example of a `<test suite>` is: `e2e/playwright/flow-tests.spec.ts`
|
||||
|
||||
YOU WILL NEED A PLAYWRIGHT-SECRETS.ENV FILE:
|
||||
|
||||
|
||||
```bash
|
||||
# ./e2e/playwright/playwright-secrets.env
|
||||
token=<your-token>
|
||||
snapshottoken=<your-snapshot-token>
|
||||
```
|
||||
then replace "your-token" with a dev token from dev.zoo.dev/account/api-tokens
|
||||
|
||||
then:
|
||||
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)
|
||||
|
||||
|
@ -23,7 +23,6 @@ layout: manual
|
||||
* [`atan`](kcl/atan)
|
||||
* [`bezierCurve`](kcl/bezierCurve)
|
||||
* [`ceil`](kcl/ceil)
|
||||
* [`chamfer`](kcl/chamfer)
|
||||
* [`circle`](kcl/circle)
|
||||
* [`close`](kcl/close)
|
||||
* [`cos`](kcl/cos)
|
||||
@ -65,7 +64,6 @@ layout: manual
|
||||
* [`segEndX`](kcl/segEndX)
|
||||
* [`segEndY`](kcl/segEndY)
|
||||
* [`segLen`](kcl/segLen)
|
||||
* [`shell`](kcl/shell)
|
||||
* [`sin`](kcl/sin)
|
||||
* [`sqrt`](kcl/sqrt)
|
||||
* [`startProfileAt`](kcl/startProfileAt)
|
||||
|
@ -9,7 +9,7 @@ A circular pattern on a 2D sketch.
|
||||
|
||||
|
||||
```js
|
||||
patternCircular2d(data: CircularPattern2dData, sketch_group_set: SketchGroupSet) -> [SketchGroup]
|
||||
patternCircular2d(data: CircularPattern2dData, sketch_group: SketchGroup) -> [SketchGroup]
|
||||
```
|
||||
|
||||
### Examples
|
||||
@ -48,7 +48,7 @@ const example = extrude(1, exampleSketch)
|
||||
rotateDuplicates: string,
|
||||
}
|
||||
```
|
||||
* `sketch_group_set`: `SketchGroupSet` - A sketch group or a group of sketch groups. (REQUIRED)
|
||||
* `sketch_group`: `SketchGroup` - A sketch group is a collection of paths. (REQUIRED)
|
||||
```js
|
||||
{
|
||||
// The plane id or face id of the sketch group.
|
||||
@ -129,7 +129,6 @@ const example = extrude(1, exampleSketch)
|
||||
// The to point.
|
||||
to: [number, number],
|
||||
},
|
||||
type: "sketchGroup",
|
||||
// The paths in the sketch group.
|
||||
value: [{
|
||||
// The from point.
|
||||
@ -213,9 +212,6 @@ const example = extrude(1, exampleSketch)
|
||||
y: number,
|
||||
z: number,
|
||||
},
|
||||
} |
|
||||
{
|
||||
type: "sketchGroups",
|
||||
}
|
||||
```
|
||||
|
||||
|
@ -9,7 +9,7 @@ A circular pattern on a 3D model.
|
||||
|
||||
|
||||
```js
|
||||
patternCircular3d(data: CircularPattern3dData, extrude_group_set: ExtrudeGroupSet) -> [ExtrudeGroup]
|
||||
patternCircular3d(data: CircularPattern3dData, extrude_group: ExtrudeGroup) -> [ExtrudeGroup]
|
||||
```
|
||||
|
||||
### Examples
|
||||
@ -47,7 +47,7 @@ const example = extrude(-5, exampleSketch)
|
||||
rotateDuplicates: string,
|
||||
}
|
||||
```
|
||||
* `extrude_group_set`: `ExtrudeGroupSet` - A extrude group or a group of extrude groups. (REQUIRED)
|
||||
* `extrude_group`: `ExtrudeGroup` - An extrude group is a collection of extrude surfaces. (REQUIRED)
|
||||
```js
|
||||
{
|
||||
// The id of the extrusion end cap
|
||||
@ -127,7 +127,6 @@ const example = extrude(-5, exampleSketch)
|
||||
}],
|
||||
// The id of the extrusion start cap
|
||||
startCapId: uuid,
|
||||
type: "extrudeGroup",
|
||||
// The extrude surfaces.
|
||||
value: [{
|
||||
// The face id for the extrude plane.
|
||||
@ -177,9 +176,6 @@ const example = extrude(-5, exampleSketch)
|
||||
y: number,
|
||||
z: number,
|
||||
},
|
||||
} |
|
||||
{
|
||||
type: "extrudeGroups",
|
||||
}
|
||||
```
|
||||
|
||||
|
@ -9,7 +9,7 @@ A linear pattern on a 3D model.
|
||||
|
||||
|
||||
```js
|
||||
patternLinear3d(data: LinearPattern3dData, extrude_group_set: ExtrudeGroupSet) -> [ExtrudeGroup]
|
||||
patternLinear3d(data: LinearPattern3dData, extrude_group: ExtrudeGroup) -> [ExtrudeGroup]
|
||||
```
|
||||
|
||||
### Examples
|
||||
@ -45,7 +45,7 @@ const example = extrude(1, exampleSketch)
|
||||
repetitions: number,
|
||||
}
|
||||
```
|
||||
* `extrude_group_set`: `ExtrudeGroupSet` - A extrude group or a group of extrude groups. (REQUIRED)
|
||||
* `extrude_group`: `ExtrudeGroup` - An extrude group is a collection of extrude surfaces. (REQUIRED)
|
||||
```js
|
||||
{
|
||||
// The id of the extrusion end cap
|
||||
@ -125,7 +125,6 @@ const example = extrude(1, exampleSketch)
|
||||
}],
|
||||
// The id of the extrusion start cap
|
||||
startCapId: uuid,
|
||||
type: "extrudeGroup",
|
||||
// The extrude surfaces.
|
||||
value: [{
|
||||
// The face id for the extrude plane.
|
||||
@ -175,9 +174,6 @@ const example = extrude(1, exampleSketch)
|
||||
y: number,
|
||||
z: number,
|
||||
},
|
||||
} |
|
||||
{
|
||||
type: "extrudeGroups",
|
||||
}
|
||||
```
|
||||
|
||||
|
10252
docs/kcl/std.json
@ -406,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 sketch001 = startSketchOn('XZ')`
|
||||
`const part001 = startSketchOn('XZ')`
|
||||
)
|
||||
|
||||
await page.waitForTimeout(300) // TODO detect animation ending, or disable animation
|
||||
@ -414,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 sketch001 = startSketchOn('XZ')
|
||||
.toHaveText(`const part001 = startSketchOn('XZ')
|
||||
|> startProfileAt([9.06, -12.22], %)`)
|
||||
await page.waitForTimeout(100)
|
||||
|
||||
@ -428,7 +428,7 @@ test('Draft segments should look right', async ({ page, context }) => {
|
||||
await page.waitForTimeout(100)
|
||||
|
||||
await expect(page.locator('.cm-content'))
|
||||
.toHaveText(`const sketch001 = startSketchOn('XZ')
|
||||
.toHaveText(`const part001 = startSketchOn('XZ')
|
||||
|> startProfileAt([9.06, -12.22], %)
|
||||
|> line([9.14, 0], %)`)
|
||||
|
||||
@ -465,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 sketch001 = startSketchOn('XZ')`
|
||||
`const part001 = startSketchOn('XZ')`
|
||||
)
|
||||
|
||||
await page.waitForTimeout(500) // TODO detect animation ending, or disable animation
|
||||
@ -514,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 sketch001 = startSketchOn('XZ')`
|
||||
`const part001 = startSketchOn('XZ')`
|
||||
)
|
||||
|
||||
await page.waitForTimeout(300) // TODO detect animation ending, or disable animation
|
||||
@ -522,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 sketch001 = startSketchOn('XZ')
|
||||
.toHaveText(`const part001 = startSketchOn('XZ')
|
||||
|> startProfileAt([9.06, -12.22], %)`)
|
||||
await page.waitForTimeout(100)
|
||||
|
||||
@ -532,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 sketch001 = startSketchOn('XZ')
|
||||
.toHaveText(`const part001 = startSketchOn('XZ')
|
||||
|> startProfileAt([9.06, -12.22], %)
|
||||
|> line([9.14, 0], %)`)
|
||||
|
||||
@ -542,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 sketch001 = startSketchOn('XZ')
|
||||
.toHaveText(`const part001 = startSketchOn('XZ')
|
||||
|> startProfileAt([9.06, -12.22], %)
|
||||
|> line([9.14, 0], %)
|
||||
|> tangentialArcTo([27.34, -3.08], %)`)
|
||||
@ -617,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 sketch001 = startSketchOn('XZ')`
|
||||
`const part001 = startSketchOn('XZ')`
|
||||
)
|
||||
|
||||
await page.waitForTimeout(300) // TODO detect animation ending, or disable animation
|
||||
@ -625,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 sketch001 = startSketchOn('XZ')
|
||||
.toHaveText(`const part001 = startSketchOn('XZ')
|
||||
|> startProfileAt([230.03, -310.32], %)`)
|
||||
await page.waitForTimeout(100)
|
||||
|
||||
@ -635,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 sketch001 = startSketchOn('XZ')
|
||||
.toHaveText(`const part001 = startSketchOn('XZ')
|
||||
|> startProfileAt([230.03, -310.32], %)
|
||||
|> line([232.2, 0], %)`)
|
||||
|
||||
@ -645,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 sketch001 = startSketchOn('XZ')
|
||||
.toHaveText(`const part001 = startSketchOn('XZ')
|
||||
|> startProfileAt([230.03, -310.32], %)
|
||||
|> line([232.2, 0], %)
|
||||
|> tangentialArcTo([694.43, -78.12], %)`)
|
||||
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 41 KiB |
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 41 KiB |
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 46 KiB |
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 42 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: 66 KiB After Width: | Height: | Size: 65 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: 46 KiB After Width: | Height: | Size: 44 KiB |
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 46 KiB |
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 42 KiB |
@ -50,25 +50,3 @@ 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,6 +1,5 @@
|
||||
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'
|
||||
@ -12,16 +11,14 @@ 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', timeout: 20_000 })
|
||||
await page.getByTestId('loading').waitFor({ state: 'detached' })
|
||||
|
||||
await page.getByTestId('start-sketch').waitFor()
|
||||
}
|
||||
|
||||
async function removeCurrentCode(page: Page) {
|
||||
const hotkey = process.platform === 'darwin' ? 'Meta' : 'Control'
|
||||
await page.locator('.cm-content').click()
|
||||
await page.click('.cm-content')
|
||||
await page.keyboard.down(hotkey)
|
||||
await page.keyboard.press('a')
|
||||
await page.keyboard.up(hotkey)
|
||||
@ -30,12 +27,12 @@ async function removeCurrentCode(page: Page) {
|
||||
}
|
||||
|
||||
async function sendCustomCmd(page: Page, cmd: EngineCommand) {
|
||||
await page.getByTestId('custom-cmd-input').fill(JSON.stringify(cmd))
|
||||
await page.getByTestId('custom-cmd-send-button').click()
|
||||
await page.fill('[data-testid="custom-cmd-input"]', JSON.stringify(cmd))
|
||||
await page.click('[data-testid="custom-cmd-send-button"]')
|
||||
}
|
||||
|
||||
async function clearCommandLogs(page: Page) {
|
||||
await page.getByTestId('clear-commands').click()
|
||||
await page.click('[data-testid="clear-commands"]')
|
||||
}
|
||||
|
||||
async function expectCmdLog(page: Page, locatorStr: string) {
|
||||
@ -99,31 +96,17 @@ 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 async function getUtils(page: Page) {
|
||||
// Chrome devtools protocol session only works in Chromium
|
||||
const browserType = page.context().browser()?.browserType().name()
|
||||
const cdpSession =
|
||||
browserType !== 'chromium' ? null : await page.context().newCDPSession(page)
|
||||
|
||||
let click00rCenter = { x: 0, y: 0 }
|
||||
const click00 = (x: number, y: number) =>
|
||||
page.mouse.click(click00rCenter.x + x, click00rCenter.y + y)
|
||||
let click00rLastPos = { x: 0, y: 0 }
|
||||
|
||||
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
|
||||
@ -142,37 +125,6 @@ export const getMovementUtils = (opts: any) => {
|
||||
// Combine because used often
|
||||
const toSU = (xy: number[]) => fromUToString(toU(xy[0], xy[1]))
|
||||
|
||||
// Make it easier to click around from center ("click [from] zero zero")
|
||||
const click00 = (x: number, y: number) =>
|
||||
opts.page.mouse.click(opts.center.x + x, opts.center.y + y)
|
||||
|
||||
// Relative clicker, must keep state
|
||||
let last = { x: 0, y: 0 }
|
||||
const click00r = (x?: number, y?: number) => {
|
||||
// reset relative coordinates when anything is undefined
|
||||
if (x === undefined || y === undefined) {
|
||||
last.x = 0
|
||||
last.y = 0
|
||||
return
|
||||
}
|
||||
|
||||
const ret = click00(last.x + x, last.y + y)
|
||||
last.x += x
|
||||
last.y += y
|
||||
|
||||
// Returns the new absolute coordinate if you need it.
|
||||
return ret.then(() => [last.x, last.y])
|
||||
}
|
||||
|
||||
return { toSU, click00r }
|
||||
}
|
||||
|
||||
export async function getUtils(page: Page) {
|
||||
// Chrome devtools protocol session only works in Chromium
|
||||
const browserType = page.context().browser()?.browserType().name()
|
||||
const cdpSession =
|
||||
browserType !== 'chromium' ? null : await page.context().newCDPSession(page)
|
||||
|
||||
return {
|
||||
waitForAuthSkipAppStart: () => waitForPageLoad(page),
|
||||
removeCurrentCode: () => removeCurrentCode(page),
|
||||
@ -212,8 +164,8 @@ export async function getUtils(page: Page) {
|
||||
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),
|
||||
x: bbox.x + angleXOffset,
|
||||
y: bbox.y - angleYOffset,
|
||||
}
|
||||
},
|
||||
getAngle: async (locator: string) => {
|
||||
@ -225,7 +177,6 @@ export async function getUtils(page: Page) {
|
||||
.locator(locator)
|
||||
.boundingBox()
|
||||
.then((box) => ({ ...box, x: box?.x || 0, y: box?.y || 0 })),
|
||||
codeLocator: page.locator('.cm-content'),
|
||||
doAndWaitForCmd: async (
|
||||
fn: () => Promise<void>,
|
||||
commandType: string,
|
||||
@ -241,30 +192,6 @@ 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({
|
||||
@ -317,6 +244,51 @@ export async function getUtils(page: Page) {
|
||||
|
||||
cdpSession?.send('Network.emulateNetworkConditions', networkOptions)
|
||||
},
|
||||
expectCodeToBe: async (str: string) => {
|
||||
await expect(page.locator('.cm-content')).toHaveText(str)
|
||||
await page.waitForTimeout(100)
|
||||
},
|
||||
click00rSetCenter: (x: number, y: number) => {
|
||||
click00rCenter = { x, y }
|
||||
},
|
||||
click00r: (x?: number, y?: number) => {
|
||||
// reset relative coordinates when anything is undefined
|
||||
if (x === undefined || y === undefined) {
|
||||
click00rLastPos.x = 0
|
||||
click00rLastPos.y = 0
|
||||
return
|
||||
}
|
||||
|
||||
const ret = click00(click00rLastPos.x + x, click00rLastPos.y + y)
|
||||
click00rLastPos.x += x
|
||||
click00rLastPos.y += y
|
||||
|
||||
// Returns the new absolute coordinate if you need it.
|
||||
return ret.then(() => [click00rLastPos.x, click00rLastPos.y])
|
||||
},
|
||||
toSU,
|
||||
wiggleMove: async (
|
||||
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 y1 = 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 })
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@ -466,8 +438,3 @@ export const doExport = async (
|
||||
outputType: output.type,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the appropriate modifier key for the platform.
|
||||
*/
|
||||
export const metaModifier = os.platform() === 'darwin' ? 'Meta' : 'Control'
|
||||
|
@ -152,7 +152,6 @@ describe('ZMA (Tauri)', () => {
|
||||
})
|
||||
|
||||
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.22.2",
|
||||
"version": "0.21.9",
|
||||
"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.67",
|
||||
"@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,8 +95,7 @@
|
||||
"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)\"",
|
||||
"make:dev": "make dev"
|
||||
"xstate:typegen": "yarn xstate typegen \"src/**/*.ts?(x)\""
|
||||
},
|
||||
"prettier": {
|
||||
"trailingComma": "es5",
|
||||
|
@ -12,13 +12,13 @@ import { defineConfig, devices } from '@playwright/test'
|
||||
export default defineConfig({
|
||||
testDir: './e2e/playwright',
|
||||
/* Run tests in files in parallel */
|
||||
fullyParallel: false,
|
||||
fullyParallel: true,
|
||||
/* 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,
|
||||
/* Different amount of parallelism on CI and local. */
|
||||
workers: process.env.CI ? 1 : 1,
|
||||
/* Opt out of parallel tests on CI. */
|
||||
workers: process.env.CI ? 2 : 1,
|
||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||
reporter: 'html',
|
||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||
@ -34,14 +34,7 @@ export default defineConfig({
|
||||
projects: [
|
||||
{
|
||||
name: 'Google Chrome',
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
channel: 'chrome',
|
||||
contextOptions: {
|
||||
/* Chromium is the only one with these permission types */
|
||||
permissions: ['clipboard-write', 'clipboard-read'],
|
||||
},
|
||||
}, // or 'chrome-beta'
|
||||
use: { ...devices['Desktop Chrome'], channel: 'chrome' }, // or 'chrome-beta'
|
||||
},
|
||||
{
|
||||
name: 'webkit',
|
||||
@ -79,7 +72,7 @@ export default defineConfig({
|
||||
|
||||
/* Run your local dev server before starting the tests */
|
||||
webServer: {
|
||||
command: 'yarn start',
|
||||
command: 'yarn serve',
|
||||
// url: 'http://127.0.0.1:3000',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
},
|
||||
|
1719
src-tauri/Cargo.lock
generated
@ -16,11 +16,11 @@ tauri-build = { version = "2.0.0-beta.13", features = [] }
|
||||
[dependencies]
|
||||
anyhow = "1"
|
||||
kcl-lib = { version = "0.1.53", path = "../src/wasm-lib/kcl" }
|
||||
kittycad = "0.3.5"
|
||||
kittycad = "0.3.0"
|
||||
log = "0.4.21"
|
||||
oauth2 = "4.4.2"
|
||||
serde_json = "1.0"
|
||||
tauri = { version = "2.0.0-beta.22", features = [ "devtools", "unstable"] }
|
||||
tauri = { version = "2.0.0-beta.15", features = [ "devtools", "unstable"] }
|
||||
tauri-plugin-cli = { version = "2.0.0-beta.3" }
|
||||
tauri-plugin-deep-link = { version = "2.0.0-beta.3" }
|
||||
tauri-plugin-dialog = { version = "2.0.0-beta.6" }
|
||||
|
@ -63,17 +63,16 @@
|
||||
"subcommands": {}
|
||||
},
|
||||
"deep-link": {
|
||||
"mobile": [],
|
||||
"desktop": {
|
||||
"schemes": [
|
||||
"app.zoo.dev"
|
||||
]
|
||||
}
|
||||
"domains": [
|
||||
{
|
||||
"host": "app.zoo.dev"
|
||||
}
|
||||
]
|
||||
},
|
||||
"shell": {
|
||||
"open": true
|
||||
}
|
||||
},
|
||||
"productName": "Zoo Modeling App",
|
||||
"version": "0.22.2"
|
||||
"version": "0.21.9"
|
||||
}
|
||||
|
@ -12,8 +12,6 @@ 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'
|
||||
@ -157,11 +155,5 @@ const router = createBrowserRouter([
|
||||
* @returns RouterProvider
|
||||
*/
|
||||
export const Router = () => {
|
||||
const networkStatus = useNetworkStatus()
|
||||
|
||||
return (
|
||||
<NetworkContext.Provider value={networkStatus}>
|
||||
<RouterProvider router={router} />
|
||||
</NetworkContext.Provider>
|
||||
)
|
||||
return <RouterProvider router={router} />
|
||||
}
|
||||
|
@ -3,11 +3,13 @@ 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'
|
||||
@ -36,16 +38,14 @@ export function Toolbar({
|
||||
}, [engineCommandManager.artifactMap, context.selectionRanges])
|
||||
|
||||
const toolbarButtonsRef = useRef<HTMLUListElement>(null)
|
||||
const { overallState } = useNetworkContext()
|
||||
|
||||
const { overallState } = useNetworkStatus()
|
||||
const { isExecuting } = useKclContext()
|
||||
const { isStreamReady } = useStore((s) => ({
|
||||
isStreamReady: s.isStreamReady,
|
||||
}))
|
||||
const disableAllButtons =
|
||||
(overallState !== NetworkHealthState.Ok &&
|
||||
overallState !== NetworkHealthState.Weak) ||
|
||||
isExecuting ||
|
||||
!isStreamReady
|
||||
overallState !== NetworkHealthState.Ok || isExecuting || !isStreamReady
|
||||
|
||||
useHotkeys(
|
||||
'l',
|
||||
|
@ -48,14 +48,12 @@ 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]
|
||||
}
|
||||
|
||||
@ -444,7 +442,7 @@ export class CameraControls {
|
||||
this.handleEnd()
|
||||
return
|
||||
}
|
||||
this.engineCommandManager.sendSceneCommand({
|
||||
this.throttledEngCmd({
|
||||
type: 'modeling_cmd_req',
|
||||
cmd: {
|
||||
type: 'default_camera_zoom',
|
||||
@ -456,11 +454,11 @@ export class CameraControls {
|
||||
return
|
||||
}
|
||||
|
||||
// Else "clientToEngine" (Sketch Mode) or forceUpdate
|
||||
const isTrackpad = Math.abs(event.deltaY) <= 1 || event.deltaY % 1 === 0
|
||||
|
||||
// From onMouseMove zoom handling which seems to be really smooth
|
||||
const zoomSpeed = isTrackpad ? 0.02 : 0.1 // Reduced zoom speed for trackpad
|
||||
this.pendingZoom = this.pendingZoom ? this.pendingZoom : 1
|
||||
this.pendingZoom *= 1 + event.deltaY * 0.01
|
||||
this.pendingZoom *= 1 + (event.deltaY > 0 ? zoomSpeed : -zoomSpeed)
|
||||
this.handleEnd()
|
||||
}
|
||||
|
||||
@ -775,75 +773,6 @@ 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(),
|
||||
@ -1028,11 +957,6 @@ 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,16 +420,12 @@ 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"
|
||||
onClick={() => {
|
||||
send({ type: 'Constrain remove constraints', data: pathToNode })
|
||||
}}
|
||||
>
|
||||
Remove constraints
|
||||
</button>
|
||||
<button
|
||||
className="!border-transparent rounded-sm text-left p-1 text-nowrap"
|
||||
// disabled={dependentSourceRanges.length > 0}
|
||||
title={
|
||||
dependentSourceRanges.length > 0
|
||||
? `At least ${dependentSourceRanges.length} segment rely on this segment's tag.`
|
||||
@ -699,15 +695,6 @@ export const CamDebugSettings = () => {
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div>
|
||||
<button
|
||||
onClick={() => {
|
||||
sceneInfra.camControls.resetCameraPosition()
|
||||
}}
|
||||
>
|
||||
Reset Camera Position
|
||||
</button>
|
||||
</div>
|
||||
{camSettings.type === 'perspective' && (
|
||||
<input
|
||||
type="range"
|
||||
@ -825,71 +812,6 @@ 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,10 +93,7 @@ import { createGridHelper, orthoScale, perspScale } from './helpers'
|
||||
import { Models } from '@kittycad/lib'
|
||||
import { uuidv4 } from 'lib/utils'
|
||||
import { SegmentOverlayPayload, SketchDetails } from 'machines/modelingMachine'
|
||||
import {
|
||||
ArtifactMapCommand,
|
||||
EngineCommandManager,
|
||||
} from 'lang/std/engineConnection'
|
||||
import { EngineCommandManager } from 'lang/std/engineConnection'
|
||||
import {
|
||||
getRectangleCallExpressions,
|
||||
updateRectangleSketch,
|
||||
@ -762,6 +759,14 @@ 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' })
|
||||
@ -1417,30 +1422,6 @@ 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]
|
||||
|
||||
@ -1448,13 +1429,10 @@ export class SceneEntities {
|
||||
if (!faceInfo?.origin || !faceInfo?.z_axis || !faceInfo?.y_axis)
|
||||
return ['other', entity_id]
|
||||
const { z_axis, y_axis, origin } = faceInfo
|
||||
const sketchPathToNode = getNodePathFromSourceRange(
|
||||
const pathToNode = getNodePathFromSourceRange(
|
||||
kclManager.ast,
|
||||
artifact.range
|
||||
)
|
||||
const extrudePathToNode = extrusions?.range
|
||||
? getNodePathFromSourceRange(kclManager.ast, extrusions.range)
|
||||
: []
|
||||
|
||||
sceneInfra.modelingSend({
|
||||
type: 'Select default plane',
|
||||
@ -1465,8 +1443,7 @@ export class SceneEntities {
|
||||
position: [origin.x, origin.y, origin.z].map(
|
||||
(num) => num / sceneInfra._baseUnitMultiplier
|
||||
) as [number, number, number],
|
||||
sketchPathToNode,
|
||||
extrudePathToNode,
|
||||
extrudeSegmentPathToNode: pathToNode,
|
||||
cap:
|
||||
artifact?.additionalData?.type === 'cap'
|
||||
? artifact.additionalData.info
|
||||
@ -1478,6 +1455,7 @@ export class SceneEntities {
|
||||
}
|
||||
|
||||
const faceResult = await checkExtrudeFaceClick()
|
||||
console.log('faceResult', faceResult)
|
||||
if (faceResult[0] === 'face') return
|
||||
|
||||
if (!args || !args.intersects?.[0]) return
|
||||
|
@ -51,6 +51,14 @@ 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(() => {
|
||||
|
@ -1,199 +0,0 @@
|
||||
import toast from 'react-hot-toast'
|
||||
import { ActionIcon, ActionIconProps } from './ActionIcon'
|
||||
import { RefObject, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useHotkeys } from 'react-hotkeys-hook'
|
||||
import { Dialog } from '@headlessui/react'
|
||||
|
||||
interface ContextMenuProps
|
||||
extends Omit<React.HTMLAttributes<HTMLUListElement>, 'children'> {
|
||||
items?: React.ReactElement[]
|
||||
menuTargetElement?: RefObject<HTMLElement>
|
||||
}
|
||||
|
||||
const DefaultContextMenuItems = [
|
||||
<ContextMenuItemRefresh />,
|
||||
<ContextMenuItemCopy />,
|
||||
// add more default context menu items here
|
||||
]
|
||||
|
||||
export function ContextMenu({
|
||||
items = DefaultContextMenuItems,
|
||||
menuTargetElement,
|
||||
className,
|
||||
...props
|
||||
}: ContextMenuProps) {
|
||||
const dialogRef = useRef<HTMLDivElement>(null)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [windowSize, setWindowSize] = useState({
|
||||
width: globalThis?.window?.innerWidth,
|
||||
height: globalThis?.window?.innerHeight,
|
||||
})
|
||||
const [position, setPosition] = useState({ x: 0, y: 0 })
|
||||
useHotkeys('esc', () => setOpen(false), {
|
||||
enabled: open,
|
||||
})
|
||||
|
||||
const dialogPositionStyle = useMemo(() => {
|
||||
if (!dialogRef.current)
|
||||
return {
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 'auto',
|
||||
bottom: 'auto',
|
||||
}
|
||||
|
||||
return {
|
||||
top:
|
||||
position.y + dialogRef.current.clientHeight > windowSize.height
|
||||
? 'auto'
|
||||
: position.y,
|
||||
left:
|
||||
position.x + dialogRef.current.clientWidth > windowSize.width
|
||||
? 'auto'
|
||||
: position.x,
|
||||
right:
|
||||
position.x + dialogRef.current.clientWidth > windowSize.width
|
||||
? windowSize.width - position.x
|
||||
: 'auto',
|
||||
bottom:
|
||||
position.y + dialogRef.current.clientHeight > windowSize.height
|
||||
? windowSize.height - position.y
|
||||
: 'auto',
|
||||
}
|
||||
}, [position, windowSize, dialogRef.current])
|
||||
|
||||
// Listen for window resize to update context menu position
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
setWindowSize({
|
||||
width: globalThis?.window?.innerWidth,
|
||||
height: globalThis?.window?.innerHeight,
|
||||
})
|
||||
}
|
||||
globalThis?.window?.addEventListener('resize', handleResize)
|
||||
return () => {
|
||||
globalThis?.window?.removeEventListener('resize', handleResize)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Add context menu listener to target once mounted
|
||||
useEffect(() => {
|
||||
const handleContextMenu = (e: MouseEvent) => {
|
||||
console.log('context menu', e)
|
||||
e.preventDefault()
|
||||
setPosition({ x: e.x, y: e.y })
|
||||
setOpen(true)
|
||||
}
|
||||
menuTargetElement?.current?.addEventListener(
|
||||
'contextmenu',
|
||||
handleContextMenu
|
||||
)
|
||||
return () => {
|
||||
menuTargetElement?.current?.removeEventListener(
|
||||
'contextmenu',
|
||||
handleContextMenu
|
||||
)
|
||||
}
|
||||
}, [menuTargetElement?.current])
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={() => setOpen(false)}>
|
||||
<div
|
||||
className="fixed inset-0 z-50 w-screen h-screen"
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
>
|
||||
<Dialog.Backdrop className="fixed z-10 inset-0" />
|
||||
<Dialog.Panel
|
||||
ref={dialogRef}
|
||||
className={`w-48 fixed bg-chalkboard-10 dark:bg-chalkboard-90
|
||||
border border-solid border-chalkboard-10 dark:border-chalkboard-90 rounded
|
||||
shadow-lg backdrop:fixed backdrop:inset-0 backdrop:bg-primary ${className}`}
|
||||
style={{
|
||||
...dialogPositionStyle,
|
||||
...props.style,
|
||||
}}
|
||||
>
|
||||
<ul
|
||||
{...props}
|
||||
className="relative flex flex-col gap-0.5 items-stretch content-stretch"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
{...items}
|
||||
</ul>
|
||||
</Dialog.Panel>
|
||||
</div>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export function ContextMenuDivider() {
|
||||
return <hr className="border-chalkboard-20 dark:border-chalkboard-80" />
|
||||
}
|
||||
|
||||
interface ContextMenuItemProps {
|
||||
children: React.ReactNode
|
||||
icon?: ActionIconProps['icon']
|
||||
onClick?: () => void
|
||||
hotkey?: string
|
||||
}
|
||||
|
||||
export function ContextMenuItem({
|
||||
children,
|
||||
icon,
|
||||
onClick,
|
||||
hotkey,
|
||||
}: ContextMenuItemProps) {
|
||||
return (
|
||||
<button
|
||||
className="flex items-center gap-2 py-1 px-2 cursor-pointer hover:bg-chalkboard-20 dark:hover:bg-chalkboard-80 border-none text-left"
|
||||
onClick={onClick}
|
||||
>
|
||||
{icon && <ActionIcon icon={icon} bgClassName="!bg-transparent" />}
|
||||
<div className="flex-1">{children}</div>
|
||||
{hotkey && (
|
||||
<kbd className="px-1.5 py-0.5 rounded bg-primary/10 text-primary dark:bg-chalkboard-80 dark:text-chalkboard-40">
|
||||
{hotkey}
|
||||
</kbd>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export function ContextMenuItemRefresh() {
|
||||
return (
|
||||
<ContextMenuItem
|
||||
icon="arrowRotateRight"
|
||||
onClick={() => globalThis?.window?.location.reload()}
|
||||
>
|
||||
Refresh
|
||||
</ContextMenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
interface ContextMenuItemCopyProps {
|
||||
toBeCopiedContent?: string
|
||||
toBeCopiedLabel?: string
|
||||
}
|
||||
|
||||
export function ContextMenuItemCopy({
|
||||
toBeCopiedContent = globalThis.window?.getSelection()?.toString(),
|
||||
toBeCopiedLabel = 'selection',
|
||||
}: ContextMenuItemCopyProps) {
|
||||
return (
|
||||
<ContextMenuItem
|
||||
icon="clipboardPlus"
|
||||
onClick={() => {
|
||||
if (toBeCopiedContent) {
|
||||
globalThis?.navigator?.clipboard
|
||||
.writeText(toBeCopiedContent)
|
||||
.then(() => toast.success(`Copied ${toBeCopiedLabel} to clipboard`))
|
||||
.catch(() =>
|
||||
toast.error(`Failed to copy ${toBeCopiedLabel} to clipboard`)
|
||||
)
|
||||
}
|
||||
}}
|
||||
>
|
||||
Copy
|
||||
</ContextMenuItem>
|
||||
)
|
||||
}
|
@ -71,16 +71,6 @@ const CustomIconMap = {
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
bug: (
|
||||
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M10.8209 5.99884C10.6403 5.73962 10.3399 5.57001 10 5.57001C9.65984 5.57001 9.35936 5.73984 9.17871 5.99935C9.43724 5.95129 9.71142 5.92578 10.0012 5.92578C10.29 5.92578 10.5633 5.95111 10.8209 5.99884ZM10 4.57001C8.9459 4.57001 8.08227 5.38548 8.00554 6.41997C7.58916 6.65398 7.23724 6.95989 6.95014 7.31304L5.85355 6.21645L5.14645 6.92356L6.40931 8.18642C6.20774 8.62503 6.08043 9.09624 6.0278 9.57001H5V10.57H6.01946C6.06396 11.1581 6.1867 11.8173 6.4071 12.4558L5.14645 13.7165L5.85355 14.4236L6.8408 13.4363C7.46354 14.555 8.47307 15.4258 10.0012 15.4258C11.529 15.4258 12.5378 14.5554 13.16 13.4371L14.1464 14.4236L14.8536 13.7165L13.5934 12.4563C13.8136 11.8177 13.9362 11.1583 13.9806 10.57H15V9.57001H13.9722C13.9197 9.0961 13.7925 8.62474 13.5911 8.18602L14.8536 6.92356L14.1464 6.21645L13.0505 7.31239C12.7633 6.95894 12.4112 6.65285 11.9944 6.41883C11.9171 5.38488 11.0537 4.57001 10 4.57001ZM10.5 14.3801V8.57001H9.5V14.3796C8.72105 14.2298 8.15885 13.7245 7.7428 12.9999C7.22316 12.095 7 10.937 7 10.07C7 8.46381 8.04281 6.92578 10.0012 6.92578C11.9589 6.92578 13 8.4629 13 10.07C13 10.9373 12.7773 12.0954 12.2582 13.0003C11.8422 13.7254 11.2799 14.2309 10.5 14.3801Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
checkmark: (
|
||||
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
@ -267,14 +257,6 @@ 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,8 +18,6 @@ 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})`
|
||||
@ -127,7 +125,6 @@ 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(
|
||||
@ -188,7 +185,7 @@ const FileTreeItem = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="contents" ref={itemRef}>
|
||||
<>
|
||||
{fileOrDir.children === undefined ? (
|
||||
<li
|
||||
className={
|
||||
@ -324,41 +321,7 @@ 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,6 +1,5 @@
|
||||
import { SceneInfra } from 'clientSideScene/sceneInfra'
|
||||
import { sceneInfra } from 'lib/singletons'
|
||||
import { MutableRefObject, useEffect, useRef } from 'react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import {
|
||||
WebGLRenderer,
|
||||
Scene,
|
||||
@ -13,52 +12,21 @@ 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
|
||||
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',
|
||||
const AXIS_COLORS = {
|
||||
x: '#fa6668',
|
||||
y: '#11eb6b',
|
||||
z: '#6689ef',
|
||||
gray: '#c6c7c2',
|
||||
}
|
||||
|
||||
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
|
||||
@ -73,89 +41,35 @@ 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 = () => {
|
||||
const delta = clock.getDelta()
|
||||
requestAnimationFrame(animate)
|
||||
updateCameraOrientation(
|
||||
camera,
|
||||
currentQuaternion,
|
||||
sceneInfra.camControls.camera.quaternion,
|
||||
delta,
|
||||
cameraPassiveUpdateTimer
|
||||
)
|
||||
updateRayCaster(
|
||||
raycasterObjects,
|
||||
raycaster,
|
||||
mouse,
|
||||
camera,
|
||||
raycasterIntersect,
|
||||
delta,
|
||||
raycasterPassiveUpdateTimer
|
||||
clock.getDelta()
|
||||
)
|
||||
renderer.render(scene, camera)
|
||||
requestAnimationFrame(animate)
|
||||
}
|
||||
animate()
|
||||
|
||||
return () => {
|
||||
renderer.dispose()
|
||||
disposeMouseEvents()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
<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>
|
||||
</>
|
||||
<div className="grid place-content-center rounded-full overflow-hidden border border-solid border-primary/50 pointer-events-none">
|
||||
<canvas ref={canvasRef} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const createCamera = (): OrthographicCamera => {
|
||||
const createCamera = () => {
|
||||
return new OrthographicCamera(
|
||||
-FRUSTUM_SIZE,
|
||||
FRUSTUM_SIZE,
|
||||
@ -168,21 +82,21 @@ const createCamera = (): OrthographicCamera => {
|
||||
|
||||
const createGizmo = () => {
|
||||
const gizmoAxes = [
|
||||
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'),
|
||||
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'),
|
||||
]
|
||||
|
||||
const gizmoAxisHeads = [
|
||||
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]),
|
||||
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'),
|
||||
]
|
||||
|
||||
return { gizmoAxes, gizmoAxisHeads }
|
||||
@ -194,9 +108,12 @@ const createAxis = (
|
||||
color: ColorRepresentation,
|
||||
rotation = 0,
|
||||
axis = 'x'
|
||||
): Mesh => {
|
||||
const geometry = new BoxGeometry(length, width, width)
|
||||
geometry.translate(length / 2, 0, 0)
|
||||
) => {
|
||||
const geometry = new BoxGeometry(length, width, width).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
|
||||
@ -204,17 +121,15 @@ const createAxis = (
|
||||
}
|
||||
|
||||
const createAxisHead = (
|
||||
name: AxisNames,
|
||||
length: number,
|
||||
color: ColorRepresentation,
|
||||
position: number[]
|
||||
): Mesh => {
|
||||
const geometry = new SphereGeometry(0.065, 16, 8)
|
||||
rotation = 0,
|
||||
axis = 'x'
|
||||
) => {
|
||||
const geometry = new SphereGeometry(0.065, 16, 8).translate(length, 0, 0)
|
||||
const material = new MeshBasicMaterial({ color: new Color(color) })
|
||||
const mesh = new Mesh(geometry, material)
|
||||
|
||||
mesh.position.set(position[0], position[1], position[2])
|
||||
mesh.updateMatrixWorld()
|
||||
mesh.name = name
|
||||
mesh.rotation[axis as 'x' | 'y' | 'z'] = rotation
|
||||
return mesh
|
||||
}
|
||||
|
||||
@ -222,97 +137,10 @@ const updateCameraOrientation = (
|
||||
camera: OrthographicCamera,
|
||||
currentQuaternion: Quaternion,
|
||||
targetQuaternion: Quaternion,
|
||||
deltaTime: number,
|
||||
cameraPassiveUpdateTimer: MutableRefObject<number>
|
||||
deltaTime: number
|
||||
) => {
|
||||
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
|
||||
}
|
||||
const slerpFactor = 1 - Math.exp(-30 * deltaTime)
|
||||
currentQuaternion.slerp(targetQuaternion, slerpFactor).normalize()
|
||||
camera.position.set(0, 0, 1).applyQuaternion(currentQuaternion)
|
||||
camera.quaternion.copy(currentQuaternion)
|
||||
}
|
||||
|
@ -81,12 +81,6 @@ 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,65 +1,14 @@
|
||||
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 [error, setError] = useState<ConnectionError>(ConnectionError.Unset)
|
||||
|
||||
const [hasLongLoadTime, setHasLongLoadTime] = useState(false)
|
||||
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(() => {
|
||||
setError(ConnectionError.LongLoadingTime)
|
||||
setHasLongLoadTime(true)
|
||||
}, 4000)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}, [error, setError])
|
||||
|
||||
}, [setHasLongLoadTime])
|
||||
return (
|
||||
<div
|
||||
className="body-bg flex flex-col items-center justify-center h-screen"
|
||||
@ -80,10 +29,10 @@ const Loading = ({ children }: React.PropsWithChildren) => {
|
||||
<p
|
||||
className={
|
||||
'text-sm mt-4 text-primary/60 transition-opacity duration-500' +
|
||||
(error !== ConnectionError.Unset ? ' opacity-100' : ' opacity-0')
|
||||
(hasLongLoadTime ? ' opacity-100' : ' opacity-0')
|
||||
}
|
||||
>
|
||||
{CONNECTION_ERROR_TEXT[error]}
|
||||
Loading is taking longer than expected.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
|
@ -33,7 +33,7 @@ export function LowerRightControls(props: React.PropsWithChildren) {
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<CustomIcon
|
||||
name="bug"
|
||||
name="exclamationMark"
|
||||
className={`w-5 h-5 ${linkOverrideClassName}`}
|
||||
/>
|
||||
<Tooltip position="top">Report a bug</Tooltip>
|
||||
|
@ -13,6 +13,7 @@ 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,
|
||||
@ -22,8 +23,6 @@ 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 []
|
||||
@ -87,7 +86,7 @@ export const LspProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
} = useSettingsAuthContext()
|
||||
const token = auth?.context.token
|
||||
const navigate = useNavigate()
|
||||
const { overallState } = useNetworkContext()
|
||||
const { overallState } = useNetworkStatus()
|
||||
const isNetworkOkay = overallState === NetworkHealthState.Ok
|
||||
|
||||
// So this is a bit weird, we need to initialize the lsp server and client.
|
||||
|
@ -453,8 +453,7 @@ export const ModelingMachineProvider = ({
|
||||
const { modifiedAst, pathToNode: pathToNewSketchNode } =
|
||||
sketchOnExtrudedFace(
|
||||
kclManager.ast,
|
||||
data.sketchPathToNode,
|
||||
data.extrudePathToNode,
|
||||
data.extrudeSegmentPathToNode,
|
||||
kclManager.programMemory,
|
||||
data.cap
|
||||
)
|
||||
@ -509,26 +508,13 @@ export const ModelingMachineProvider = ({
|
||||
},
|
||||
'Get horizontal info': async ({
|
||||
selectionRanges,
|
||||
sketchDetails,
|
||||
}): Promise<SetSelections> => {
|
||||
const { modifiedAst, pathToNodeMap } =
|
||||
await applyConstraintHorzVertDistance({
|
||||
constraint: 'setHorzDistance',
|
||||
selectionRanges,
|
||||
})
|
||||
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
|
||||
)
|
||||
await kclManager.updateAst(modifiedAst, true)
|
||||
return {
|
||||
selectionType: 'completeSelection',
|
||||
selection: pathMapToSelections(
|
||||
@ -536,31 +522,17 @@ export const ModelingMachineProvider = ({
|
||||
selectionRanges,
|
||||
pathToNodeMap
|
||||
),
|
||||
updatedPathToNode,
|
||||
}
|
||||
},
|
||||
'Get vertical info': async ({
|
||||
selectionRanges,
|
||||
sketchDetails,
|
||||
}): Promise<SetSelections> => {
|
||||
const { modifiedAst, pathToNodeMap } =
|
||||
await applyConstraintHorzVertDistance({
|
||||
constraint: 'setVertDistance',
|
||||
selectionRanges,
|
||||
})
|
||||
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
|
||||
)
|
||||
await kclManager.updateAst(modifiedAst, true)
|
||||
return {
|
||||
selectionType: 'completeSelection',
|
||||
selection: pathMapToSelections(
|
||||
@ -568,7 +540,6 @@ export const ModelingMachineProvider = ({
|
||||
selectionRanges,
|
||||
pathToNodeMap
|
||||
),
|
||||
updatedPathToNode,
|
||||
}
|
||||
},
|
||||
'Get angle info': async ({
|
||||
@ -610,23 +581,10 @@ export const ModelingMachineProvider = ({
|
||||
},
|
||||
'Get length info': async ({
|
||||
selectionRanges,
|
||||
sketchDetails,
|
||||
}): Promise<SetSelections> => {
|
||||
const { modifiedAst, pathToNodeMap } =
|
||||
await applyConstraintAngleLength({ selectionRanges })
|
||||
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
|
||||
)
|
||||
await kclManager.updateAst(modifiedAst, true)
|
||||
return {
|
||||
selectionType: 'completeSelection',
|
||||
selection: pathMapToSelections(
|
||||
@ -634,31 +592,17 @@ export const ModelingMachineProvider = ({
|
||||
selectionRanges,
|
||||
pathToNodeMap
|
||||
),
|
||||
updatedPathToNode,
|
||||
}
|
||||
},
|
||||
'Get perpendicular distance info': async ({
|
||||
selectionRanges,
|
||||
sketchDetails,
|
||||
}): Promise<SetSelections> => {
|
||||
const { modifiedAst, pathToNodeMap } = await applyConstraintIntersect(
|
||||
{
|
||||
selectionRanges,
|
||||
}
|
||||
)
|
||||
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
|
||||
)
|
||||
await kclManager.updateAst(modifiedAst, true)
|
||||
return {
|
||||
selectionType: 'completeSelection',
|
||||
selection: pathMapToSelections(
|
||||
@ -666,7 +610,6 @@ export const ModelingMachineProvider = ({
|
||||
selectionRanges,
|
||||
pathToNodeMap
|
||||
),
|
||||
updatedPathToNode,
|
||||
}
|
||||
},
|
||||
'Get ABS X info': async ({
|
||||
|
@ -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,7 +19,6 @@ function TestWrap({ children }: { children: React.ReactNode }) {
|
||||
)
|
||||
}
|
||||
|
||||
// Our Playwright tests for this are much more comprehensive.
|
||||
describe('NetworkHealthIndicator tests', () => {
|
||||
test('Renders the network indicator', () => {
|
||||
render(
|
||||
@ -30,7 +29,21 @@ describe('NetworkHealthIndicator tests', () => {
|
||||
|
||||
fireEvent.click(screen.getByTestId('network-toggle'))
|
||||
|
||||
// Starts as disconnected
|
||||
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'))
|
||||
|
||||
expect(screen.getByTestId('network')).toHaveTextContent(
|
||||
NETWORK_HEALTH_TEXT[NetworkHealthState.Disconnected]
|
||||
)
|
||||
|
@ -1,13 +1,26 @@
|
||||
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'
|
||||
import { ConnectingTypeGroup } from '../lang/std/engineConnection'
|
||||
import { useNetworkContext } from '../hooks/useNetworkContext'
|
||||
import { NetworkHealthState } from '../hooks/useNetworkStatus'
|
||||
|
||||
export enum NetworkHealthState {
|
||||
Ok,
|
||||
Issue,
|
||||
Disconnected,
|
||||
}
|
||||
|
||||
export const NETWORK_HEALTH_TEXT: Record<NetworkHealthState, string> = {
|
||||
[NetworkHealthState.Ok]: 'Connected',
|
||||
[NetworkHealthState.Weak]: 'Weak',
|
||||
[NetworkHealthState.Issue]: 'Problem',
|
||||
[NetworkHealthState.Disconnected]: 'Offline',
|
||||
}
|
||||
@ -48,10 +61,6 @@ 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',
|
||||
@ -67,11 +76,125 @@ 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,
|
||||
@ -82,7 +205,7 @@ export const NetworkHealthIndicator = () => {
|
||||
error,
|
||||
setHasCopied,
|
||||
hasCopied,
|
||||
} = useNetworkContext()
|
||||
} = useNetworkStatus()
|
||||
|
||||
return (
|
||||
<Popover className="relative">
|
||||
@ -136,18 +259,18 @@ export const NetworkHealthIndicator = () => {
|
||||
size="lg"
|
||||
icon={
|
||||
hasIssueToIcon[
|
||||
String(issues[name as ConnectingTypeGroup])
|
||||
issues[name as ConnectingTypeGroup].toString()
|
||||
]
|
||||
}
|
||||
iconClassName={
|
||||
hasIssueToIconColors[
|
||||
String(issues[name as ConnectingTypeGroup])
|
||||
issues[name as ConnectingTypeGroup].toString()
|
||||
].icon
|
||||
}
|
||||
bgClassName={
|
||||
'rounded-sm ' +
|
||||
hasIssueToIconColors[
|
||||
String(issues[name as ConnectingTypeGroup])
|
||||
issues[name as ConnectingTypeGroup].toString()
|
||||
].bg
|
||||
}
|
||||
/>
|
||||
|
@ -5,6 +5,7 @@ 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'
|
||||
|
@ -24,9 +24,9 @@ export function RefreshButton() {
|
||||
return (
|
||||
<button
|
||||
onClick={refresh}
|
||||
className="p-1 m-0 bg-chalkboard-10/80 dark:bg-chalkboard-100/50 hover:bg-chalkboard-10 dark:hover:bg-chalkboard-100 rounded-full border border-solid border-chalkboard-20 dark:border-chalkboard-90"
|
||||
className="p-1 m-0 bg-chalkboard-10/80 dark:bg-chalkboard-100/50 hover:bg-chalkboard-10 dark:hover:bg-chalkboard-100 rounded-full border border-solid border-chalkboard-10 dark:border-chalkboard-100"
|
||||
>
|
||||
<CustomIcon name="exclamationMark" className="w-5 h-5" />
|
||||
<CustomIcon name="arrowRotateRight" className="w-5 h-5" />
|
||||
<Tooltip position="bottom-right">
|
||||
<span>Refresh and report</span>
|
||||
<br />
|
||||
|
@ -1,87 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
@ -1,238 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
)
|
@ -1,35 +0,0 @@
|
||||
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,23 +3,11 @@ 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(
|
||||
@ -33,40 +21,29 @@ export function SettingsSearchBar() {
|
||||
const navigate = useNavigate()
|
||||
const [query, setQuery] = useState('')
|
||||
const { settings } = useSettingsAuthContext()
|
||||
const settingsAsSearchable: SettingsSearchItem[] = useMemo(
|
||||
() => [
|
||||
...Object.entries(settings.state.context).flatMap(
|
||||
const settingsAsSearchable = useMemo(
|
||||
() =>
|
||||
Object.entries(settings.state.context).flatMap(
|
||||
([category, categorySettings]) =>
|
||||
Object.entries(categorySettings).flatMap(([settingName, setting]) => {
|
||||
const s = setting as Setting
|
||||
return (['project', 'user'] satisfies SettingsLevel[])
|
||||
return ['project', 'user']
|
||||
.filter((l) => s.hideOnLevel !== l)
|
||||
.map((l) => ({
|
||||
category: decamelize(category, { separator: ' ' }),
|
||||
name: settingName,
|
||||
description: s.description ?? '',
|
||||
displayName: decamelize(settingName, { separator: ' ' }),
|
||||
level: l as ExtendedSettingsLevel,
|
||||
settingName: settingName,
|
||||
settingNameDisplay: decamelize(settingName, { separator: ' ' }),
|
||||
setting: s,
|
||||
level: l,
|
||||
}))
|
||||
})
|
||||
),
|
||||
...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', 'displayName', 'description'],
|
||||
keys: ['category', 'settingNameDisplay', 'setting.description'],
|
||||
includeScore: true,
|
||||
})
|
||||
|
||||
@ -75,8 +52,16 @@ export function SettingsSearchBar() {
|
||||
setSearchResults(query.length > 0 ? results : settingsAsSearchable)
|
||||
}, [query])
|
||||
|
||||
function handleSelection({ level, name }: SettingsSearchItem) {
|
||||
navigate(`?tab=${level}#${name}`)
|
||||
function handleSelection({
|
||||
level,
|
||||
settingName,
|
||||
}: {
|
||||
category: string
|
||||
settingName: string
|
||||
setting: Setting<unknown>
|
||||
level: string
|
||||
}) {
|
||||
navigate(`?tab=${level}#${settingName}`)
|
||||
}
|
||||
|
||||
return (
|
||||
@ -102,18 +87,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.name}-${option.level}`}
|
||||
key={`${option.category}-${option.settingName}-${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.displayName}
|
||||
{option.settingNameDisplay}
|
||||
</p>
|
||||
{option.description && (
|
||||
{option.setting.description && (
|
||||
<p className="text-xs leading-tight text-chalkboard-70 dark:text-chalkboard-50">
|
||||
{option.description}
|
||||
{option.setting.description}
|
||||
</p>
|
||||
)}
|
||||
</Combobox.Option>
|
||||
|
@ -1,68 +0,0 @@
|
||||
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,15 +34,6 @@ export function SettingsTabs({
|
||||
)}
|
||||
</RadioGroup.Option>
|
||||
)}
|
||||
<RadioGroup.Option value="keybindings">
|
||||
{({ checked }) => (
|
||||
<SettingsTabButton
|
||||
checked={checked}
|
||||
icon="keyboard"
|
||||
text="Keybindings"
|
||||
/>
|
||||
)}
|
||||
</RadioGroup.Option>
|
||||
</RadioGroup>
|
||||
)
|
||||
}
|
||||
|
@ -171,9 +171,7 @@ export const SettingsAuthProviderBase = ({
|
||||
})
|
||||
},
|
||||
'Execute AST': () => kclManager.executeCode(true, true),
|
||||
},
|
||||
services: {
|
||||
'Persist settings': (context) =>
|
||||
persistSettings: (context) =>
|
||||
saveSettings(context, loadedProject?.project?.path),
|
||||
},
|
||||
}
|
||||
|
@ -4,9 +4,8 @@ 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'
|
||||
|
||||
@ -29,43 +28,8 @@ export const Stream = ({ className = '' }: { className?: string }) => {
|
||||
}))
|
||||
const { settings } = useSettingsAuthContext()
|
||||
const { state } = useModelingContext()
|
||||
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,
|
||||
})
|
||||
}, [])
|
||||
const { overallState } = useNetworkStatus()
|
||||
const isNetworkOkay = overallState === NetworkHealthState.Ok
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
@ -79,7 +43,6 @@ 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
|
||||
@ -95,7 +58,6 @@ 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
|
||||
@ -110,7 +72,6 @@ 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
|
||||
@ -151,7 +112,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,11 +140,7 @@ export async function applyConstraintIntersect({
|
||||
value: valueUsedInTransform,
|
||||
initialVariableName: 'offset',
|
||||
})
|
||||
if (
|
||||
!variableName &&
|
||||
segName === tagInfo?.tag &&
|
||||
Number(value) === valueUsedInTransform
|
||||
) {
|
||||
if (segName === tagInfo?.tag && Number(value) === valueUsedInTransform) {
|
||||
return {
|
||||
modifiedAst,
|
||||
pathToNodeMap,
|
||||
@ -173,10 +169,6 @@ 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 { Selection, Selections } from 'lib/selections'
|
||||
import { PathToNode, Program, Value } from '../../lang/wasm'
|
||||
import { Selections } from 'lib/selections'
|
||||
import { Program, Value } from '../../lang/wasm'
|
||||
import {
|
||||
getNodePathFromSourceRange,
|
||||
getNodeFromPath,
|
||||
@ -14,30 +14,15 @@ import { kclManager } from 'lib/singletons'
|
||||
|
||||
export function removeConstrainingValuesInfo({
|
||||
selectionRanges,
|
||||
pathToNodes,
|
||||
}: {
|
||||
selectionRanges: Selections
|
||||
pathToNodes?: Array<PathToNode>
|
||||
}) {
|
||||
const paths =
|
||||
pathToNodes ||
|
||||
selectionRanges.codeBasedSelections.map(({ range }) =>
|
||||
getNodePathFromSourceRange(kclManager.ast, range)
|
||||
)
|
||||
const paths = 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' &&
|
||||
@ -46,36 +31,31 @@ export function removeConstrainingValuesInfo({
|
||||
|
||||
try {
|
||||
const transforms = getRemoveConstraintsTransforms(
|
||||
updatedSelectionRanges,
|
||||
selectionRanges,
|
||||
kclManager.ast,
|
||||
'removeConstrainingValues'
|
||||
)
|
||||
|
||||
const enabled = isAllTooltips && transforms.every(Boolean)
|
||||
return { enabled, transforms, updatedSelectionRanges }
|
||||
return { enabled, transforms }
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
return { enabled: false, transforms: [], updatedSelectionRanges }
|
||||
return { enabled: false, transforms: [] }
|
||||
}
|
||||
}
|
||||
|
||||
export function applyRemoveConstrainingValues({
|
||||
selectionRanges,
|
||||
pathToNodes,
|
||||
}: {
|
||||
selectionRanges: Selections
|
||||
pathToNodes?: Array<PathToNode>
|
||||
}): {
|
||||
modifiedAst: Program
|
||||
pathToNodeMap: PathToNodeMap
|
||||
} {
|
||||
const { transforms, updatedSelectionRanges } = removeConstrainingValuesInfo({
|
||||
selectionRanges,
|
||||
pathToNodes,
|
||||
})
|
||||
const { transforms } = removeConstrainingValuesInfo({ selectionRanges })
|
||||
return transformAstSketchLines({
|
||||
ast: kclManager.ast,
|
||||
selectionRanges: updatedSelectionRanges,
|
||||
selectionRanges,
|
||||
transformInfos: transforms,
|
||||
programMemory: kclManager.programMemory,
|
||||
referenceSegName: '',
|
||||
|
@ -106,11 +106,7 @@ export async function applyConstraintHorzVertDistance({
|
||||
value: valueUsedInTransform,
|
||||
initialVariableName: constraint === 'setHorzDistance' ? 'xDis' : 'yDis',
|
||||
} as any)
|
||||
if (
|
||||
!variableName &&
|
||||
segName === tagInfo?.tag &&
|
||||
Number(value) === valueUsedInTransform
|
||||
) {
|
||||
if (segName === tagInfo?.tag && Number(value) === valueUsedInTransform) {
|
||||
return {
|
||||
modifiedAst,
|
||||
pathToNodeMap,
|
||||
@ -137,10 +133,6 @@ 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,
|
||||
|
@ -11,8 +11,30 @@
|
||||
--_p-inline: calc(50% + calc(var(--isRTL) * var(--_triangle-width) / 2));
|
||||
--_p-block: 4px;
|
||||
--_bg: var(--chalkboard-10);
|
||||
--_shadow-alpha: 8%;
|
||||
--_shadow-alpha: 5%;
|
||||
--_theme-alpha: 0.15;
|
||||
--_theme-outline: drop-shadow(
|
||||
0 1px 0
|
||||
oklch(
|
||||
var(--primary-lightness) var(--primary-chroma) var(--primary-hue) /
|
||||
var(--_theme-alpha)
|
||||
)
|
||||
)
|
||||
drop-shadow(
|
||||
0 -1px 0 oklch(var(--primary-lightness) var(--primary-chroma)
|
||||
var(--primary-hue) / var(--_theme-alpha))
|
||||
)
|
||||
drop-shadow(
|
||||
1px 0 0
|
||||
oklch(
|
||||
var(--primary-lightness) var(--primary-chroma) var(--primary-hue) /
|
||||
var(--_theme-alpha)
|
||||
)
|
||||
)
|
||||
drop-shadow(
|
||||
-1px 0 0 oklch(var(--primary-lightness) var(--primary-chroma)
|
||||
var(--primary-hue) / var(--_theme-alpha))
|
||||
);
|
||||
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
@ -39,15 +61,16 @@
|
||||
background: var(--_bg);
|
||||
@apply text-chalkboard-110;
|
||||
will-change: filter;
|
||||
filter: drop-shadow(0 1px 2px hsl(0 0% 0% / var(--_shadow-alpha)))
|
||||
drop-shadow(0 4px 6px hsl(0 0% 0% / calc(var(--_shadow-alpha) / 2)));
|
||||
filter: drop-shadow(0 1px 3px hsl(0 0% 0% / var(--_shadow-alpha)))
|
||||
drop-shadow(0 4px 8px hsl(0 0% 0% / var(--_shadow-alpha)))
|
||||
var(--_theme-outline);
|
||||
}
|
||||
|
||||
:global(.dark) .tooltip {
|
||||
--_bg: var(--chalkboard-90);
|
||||
--_bg: var(--chalkboard-110);
|
||||
--_theme-alpha: 40%;
|
||||
--_shadow-alpha: 16%;
|
||||
@apply text-chalkboard-10;
|
||||
filter: var(--_theme-outline);
|
||||
}
|
||||
|
||||
.tooltip:dir(rtl) {
|
||||
@ -86,7 +109,7 @@
|
||||
}
|
||||
|
||||
/* Sometimes there's no visible label,
|
||||
* so we'll use the tooltip as the label
|
||||
* so we'll use the tooltip as the label
|
||||
*/
|
||||
.tooltip:only-child::before {
|
||||
content: 'Tooltip:';
|
||||
|
@ -7,11 +7,7 @@ import { Selections, processCodeMirrorRanges, Selection } from 'lib/selections'
|
||||
import { undo, redo } from '@codemirror/commands'
|
||||
import { CommandBarMachineEvent } from 'machines/commandBarMachine'
|
||||
import { addLineHighlight } from './highlightextension'
|
||||
import { forEachDiagnostic, setDiagnostics, Diagnostic } from '@codemirror/lint'
|
||||
|
||||
function diagnosticIsEqual(d1: Diagnostic, d2: Diagnostic): boolean {
|
||||
return d1.from === d2.from && d1.to === d2.to && d1.message === d2.message
|
||||
}
|
||||
import { setDiagnostics, Diagnostic } from '@codemirror/lint'
|
||||
|
||||
export default class EditorManager {
|
||||
private _editorView: EditorView | null = null
|
||||
@ -95,38 +91,11 @@ export default class EditorManager {
|
||||
}
|
||||
}
|
||||
|
||||
clearDiagnostics(): void {
|
||||
if (!this.editorView) return
|
||||
this.editorView.dispatch(setDiagnostics(this.editorView.state, []))
|
||||
}
|
||||
|
||||
setDiagnostics(diagnostics: Diagnostic[]): void {
|
||||
if (!this.editorView) return
|
||||
this.editorView.dispatch(setDiagnostics(this.editorView.state, diagnostics))
|
||||
}
|
||||
|
||||
addDiagnostics(diagnostics: Diagnostic[]): void {
|
||||
if (!this.editorView) return
|
||||
|
||||
forEachDiagnostic(this.editorView.state, function (diag) {
|
||||
diagnostics.push(diag)
|
||||
})
|
||||
|
||||
const uniqueDiagnostics = new Set<Diagnostic>()
|
||||
diagnostics.forEach((diagnostic) => {
|
||||
for (const knownDiagnostic of uniqueDiagnostics.values()) {
|
||||
if (diagnosticIsEqual(diagnostic, knownDiagnostic)) {
|
||||
return
|
||||
}
|
||||
}
|
||||
uniqueDiagnostics.add(diagnostic)
|
||||
})
|
||||
|
||||
this.editorView.dispatch(
|
||||
setDiagnostics(this.editorView.state, [...uniqueDiagnostics])
|
||||
)
|
||||
}
|
||||
|
||||
undo() {
|
||||
if (this._editorView) {
|
||||
undo(this._editorView)
|
||||
|
@ -474,13 +474,19 @@ 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) =>
|
||||
new LanguageServerPlugin(options.client, view, options.allowHTMLContent)
|
||||
(plugin = new LanguageServerPlugin(
|
||||
options.client,
|
||||
view,
|
||||
options.allowHTMLContent
|
||||
))
|
||||
),
|
||||
completionDecoration,
|
||||
Prec.highest(completionPlugin(options.client)),
|
||||
|
@ -382,14 +382,9 @@ export class LanguageServerPlugin implements PluginValue {
|
||||
try {
|
||||
switch (notification.method) {
|
||||
case 'textDocument/publishDiagnostics':
|
||||
console.log(
|
||||
'[lsp] [window/publishDiagnostics]',
|
||||
this.client.getName(),
|
||||
notification.params
|
||||
)
|
||||
const params = notification.params as PublishDiagnosticsParams
|
||||
//const params = notification.params as PublishDiagnosticsParams
|
||||
// this is sometimes slower than our actual typing.
|
||||
this.processDiagnostics(params)
|
||||
//this.processDiagnostics(params)
|
||||
break
|
||||
case 'window/logMessage':
|
||||
console.log(
|
||||
|
@ -1,25 +0,0 @@
|
||||
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)
|
||||
}
|
@ -1,228 +0,0 @@
|
||||
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
|
||||
}
|
||||
|
||||
const startEngineInstance = () => {
|
||||
useLayoutEffect(() => {
|
||||
// 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,12 +73,7 @@ export function useSetupEngineManager(
|
||||
})
|
||||
hasSetNonZeroDimensions.current = true
|
||||
}
|
||||
}
|
||||
|
||||
useLayoutEffect(startEngineInstance, [
|
||||
streamRef?.current?.offsetWidth,
|
||||
streamRef?.current?.offsetHeight,
|
||||
])
|
||||
}, [streamRef?.current?.offsetWidth, streamRef?.current?.offsetHeight])
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = deferExecution(() => {
|
||||
@ -101,20 +96,8 @@ 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,10 +7,12 @@ 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 =
|
||||
@ -45,7 +47,7 @@ export default function useStateMachineCommands<
|
||||
onCancel,
|
||||
}: UseStateMachineCommandsArgs<T, S>) {
|
||||
const { commandBarSend } = useCommandsContext()
|
||||
const { overallState } = useNetworkContext()
|
||||
const { overallState } = useNetworkStatus()
|
||||
const { isExecuting } = useKclContext()
|
||||
const { isStreamReady } = useStore((s) => ({
|
||||
isStreamReady: s.isStreamReady,
|
||||
@ -53,10 +55,7 @@ export default function useStateMachineCommands<
|
||||
|
||||
useEffect(() => {
|
||||
const disableAllButtons =
|
||||
(overallState !== NetworkHealthState.Ok &&
|
||||
overallState !== NetworkHealthState.Weak) ||
|
||||
isExecuting ||
|
||||
!isStreamReady
|
||||
overallState !== NetworkHealthState.Ok || isExecuting || !isStreamReady
|
||||
const newCommands = state.nextEvents
|
||||
.filter((_) => !allCommandsRequireNetwork || !disableAllButtons)
|
||||
.filter((e) => !['done.', 'error.'].some((n) => e.includes(n)))
|
||||
|
@ -89,10 +89,9 @@ export class KclManager {
|
||||
return this._kclErrors
|
||||
}
|
||||
set kclErrors(kclErrors) {
|
||||
console.log('[lsp] not lsp, actually typescript: ', kclErrors)
|
||||
this._kclErrors = kclErrors
|
||||
let diagnostics = kclErrorsToDiagnostics(kclErrors)
|
||||
editorManager.addDiagnostics(diagnostics)
|
||||
editorManager.setDiagnostics(diagnostics)
|
||||
this._kclErrorsCallBack(kclErrors)
|
||||
}
|
||||
|
||||
@ -186,11 +185,6 @@ export class KclManager {
|
||||
const currentExecutionId = executionId || Date.now()
|
||||
this._cancelTokens.set(currentExecutionId, false)
|
||||
|
||||
// here we're going to clear diagnostics since we're the first
|
||||
// one in. We're the only location where diagnostics are cleared;
|
||||
// everything from here on out should be *appending*.
|
||||
editorManager.clearDiagnostics()
|
||||
|
||||
this.isExecuting = true
|
||||
await this.ensureWasmInit()
|
||||
const { logs, errors, programMemory } = await executeAst({
|
||||
@ -240,7 +234,6 @@ export class KclManager {
|
||||
} = { updates: 'none' }
|
||||
) {
|
||||
await this.ensureWasmInit()
|
||||
|
||||
const newCode = recast(ast)
|
||||
const newAst = this.safeParse(newCode)
|
||||
if (!newAst) return
|
||||
@ -250,11 +243,6 @@ export class KclManager {
|
||||
await this?.engineCommandManager?.waitForReady
|
||||
this._ast = { ...newAst }
|
||||
|
||||
// here we're going to clear diagnostics since we're the first
|
||||
// one in. We're the only location where diagnostics are cleared;
|
||||
// everything from here on out should be *appending*.
|
||||
editorManager.clearDiagnostics()
|
||||
|
||||
const { logs, errors, programMemory } = await executeAst({
|
||||
ast: newAst,
|
||||
engineCommandManager: this.engineCommandManager,
|
||||
@ -377,6 +365,13 @@ 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)
|
||||
}
|
||||
@ -391,11 +386,24 @@ function defaultSelectionFilter(
|
||||
) as SketchGroup | ExtrudeGroup
|
||||
firstSketchOrExtrudeGroup &&
|
||||
engineCommandManager.sendSceneCommand({
|
||||
type: 'modeling_cmd_req',
|
||||
cmd_id: uuidv4(),
|
||||
cmd: {
|
||||
type: 'set_selection_filter',
|
||||
filter: ['face', 'edge', 'solid2d', 'curve'],
|
||||
},
|
||||
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'],
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
@ -125,7 +125,7 @@ describe('Testing addSketchTo', () => {
|
||||
'yz'
|
||||
)
|
||||
const str = recast(result.modifiedAst)
|
||||
expect(str).toBe(`const sketch001 = startSketchOn('YZ')
|
||||
expect(str).toBe(`const part001 = startSketchOn('YZ')
|
||||
|> startProfileAt('default', %)
|
||||
|> line('default', %)
|
||||
`)
|
||||
@ -291,25 +291,14 @@ describe('testing sketchOnExtrudedFace', () => {
|
||||
|> extrude(5 + 7, %)`
|
||||
const ast = parse(code)
|
||||
const programMemory = await enginelessExecutor(ast)
|
||||
const segmentSnippet = `line([9.7, 9.19], %)`
|
||||
const segmentRange: [number, number] = [
|
||||
code.indexOf(segmentSnippet),
|
||||
code.indexOf(segmentSnippet) + segmentSnippet.length,
|
||||
const snippet = `line([9.7, 9.19], %)`
|
||||
const range: [number, number] = [
|
||||
code.indexOf(snippet),
|
||||
code.indexOf(snippet) + snippet.length,
|
||||
]
|
||||
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 pathToNode = getNodePathFromSourceRange(ast, range)
|
||||
|
||||
const { modifiedAst } = sketchOnExtrudedFace(
|
||||
ast,
|
||||
segmentPathToNode,
|
||||
extrudePathToNode,
|
||||
programMemory
|
||||
)
|
||||
const { modifiedAst } = sketchOnExtrudedFace(ast, pathToNode, programMemory)
|
||||
const newCode = recast(modifiedAst)
|
||||
expect(newCode).toContain(`const part001 = startSketchOn('-XZ')
|
||||
|> startProfileAt([3.58, 2.06], %)
|
||||
@ -317,7 +306,7 @@ describe('testing sketchOnExtrudedFace', () => {
|
||||
|> line([8.62, -9.57], %)
|
||||
|> close(%)
|
||||
|> extrude(5 + 7, %)
|
||||
const sketch001 = startSketchOn(part001, 'seg01')`)
|
||||
const part002 = startSketchOn(part001, 'seg01')`)
|
||||
})
|
||||
test('it should be able to extrude on close segments', async () => {
|
||||
const code = `const part001 = startSketchOn('-XZ')
|
||||
@ -328,25 +317,14 @@ const sketch001 = startSketchOn(part001, 'seg01')`)
|
||||
|> extrude(5 + 7, %)`
|
||||
const ast = parse(code)
|
||||
const programMemory = await enginelessExecutor(ast)
|
||||
const segmentSnippet = `close(%)`
|
||||
const segmentRange: [number, number] = [
|
||||
code.indexOf(segmentSnippet),
|
||||
code.indexOf(segmentSnippet) + segmentSnippet.length,
|
||||
const snippet = `close(%)`
|
||||
const range: [number, number] = [
|
||||
code.indexOf(snippet),
|
||||
code.indexOf(snippet) + snippet.length,
|
||||
]
|
||||
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 pathToNode = getNodePathFromSourceRange(ast, range)
|
||||
|
||||
const { modifiedAst } = sketchOnExtrudedFace(
|
||||
ast,
|
||||
segmentPathToNode,
|
||||
extrudePathToNode,
|
||||
programMemory
|
||||
)
|
||||
const { modifiedAst } = sketchOnExtrudedFace(ast, pathToNode, programMemory)
|
||||
const newCode = recast(modifiedAst)
|
||||
expect(newCode).toContain(`const part001 = startSketchOn('-XZ')
|
||||
|> startProfileAt([3.58, 2.06], %)
|
||||
@ -354,7 +332,7 @@ const sketch001 = startSketchOn(part001, 'seg01')`)
|
||||
|> line([8.62, -9.57], %)
|
||||
|> close(%, 'seg01')
|
||||
|> extrude(5 + 7, %)
|
||||
const sketch001 = startSketchOn(part001, 'seg01')`)
|
||||
const part002 = startSketchOn(part001, 'seg01')`)
|
||||
})
|
||||
test('it should be able to extrude on start-end caps', async () => {
|
||||
const code = `const part001 = startSketchOn('-XZ')
|
||||
@ -365,23 +343,16 @@ const sketch001 = startSketchOn(part001, 'seg01')`)
|
||||
|> extrude(5 + 7, %)`
|
||||
const ast = parse(code)
|
||||
const programMemory = await enginelessExecutor(ast)
|
||||
const sketchSnippet = `startProfileAt([3.58, 2.06], %)`
|
||||
const sketchRange: [number, number] = [
|
||||
code.indexOf(sketchSnippet),
|
||||
code.indexOf(sketchSnippet) + sketchSnippet.length,
|
||||
const snippet = `startProfileAt([3.58, 2.06], %)`
|
||||
const range: [number, number] = [
|
||||
code.indexOf(snippet),
|
||||
code.indexOf(snippet) + snippet.length,
|
||||
]
|
||||
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 pathToNode = getNodePathFromSourceRange(ast, range)
|
||||
|
||||
const { modifiedAst } = sketchOnExtrudedFace(
|
||||
ast,
|
||||
sketchPathToNode,
|
||||
extrudePathToNode,
|
||||
pathToNode,
|
||||
programMemory,
|
||||
'end'
|
||||
)
|
||||
@ -392,47 +363,7 @@ const sketch001 = startSketchOn(part001, 'seg01')`)
|
||||
|> line([8.62, -9.57], %)
|
||||
|> close(%)
|
||||
|> extrude(5 + 7, %)
|
||||
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')`)
|
||||
const part002 = startSketchOn(part001, 'END')`)
|
||||
})
|
||||
})
|
||||
|
||||
|
@ -34,7 +34,6 @@ 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(
|
||||
@ -43,8 +42,7 @@ export function startSketchOnDefault(
|
||||
name = ''
|
||||
): { modifiedAst: Program; id: string; pathToNode: PathToNode } {
|
||||
const _node = { ...node }
|
||||
const _name =
|
||||
name || findUniqueName(node, KCL_DEFAULT_CONSTANT_PREFIXES.SKETCH)
|
||||
const _name = name || findUniqueName(node, 'part')
|
||||
|
||||
const startSketchOn = createCallExpressionStdLib('startSketchOn', [
|
||||
createLiteral(axis),
|
||||
@ -111,8 +109,7 @@ export function addSketchTo(
|
||||
name = ''
|
||||
): { modifiedAst: Program; id: string; pathToNode: PathToNode } {
|
||||
const _node = { ...node }
|
||||
const _name =
|
||||
name || findUniqueName(node, KCL_DEFAULT_CONSTANT_PREFIXES.SKETCH)
|
||||
const _name = name || findUniqueName(node, 'part')
|
||||
|
||||
const startSketchOn = createCallExpressionStdLib('startSketchOn', [
|
||||
createLiteral(axis.toUpperCase()),
|
||||
@ -245,7 +242,7 @@ export function mutateObjExpProp(
|
||||
export function extrudeSketch(
|
||||
node: Program,
|
||||
pathToNode: PathToNode,
|
||||
shouldPipe = false,
|
||||
shouldPipe = true,
|
||||
distance = createLiteral(4) as Value
|
||||
): {
|
||||
modifiedAst: Program
|
||||
@ -296,22 +293,12 @@ export function extrudeSketch(
|
||||
pathToExtrudeArg,
|
||||
}
|
||||
}
|
||||
|
||||
// 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 name = findUniqueName(node, 'part')
|
||||
const VariableDeclaration = createVariableDeclaration(name, extrudeCall)
|
||||
|
||||
const sketchIndexInPathToNode =
|
||||
pathToDecleration.findIndex((a) => a[0] === 'body') + 1
|
||||
const sketchIndexInBody = pathToDecleration[
|
||||
sketchIndexInPathToNode
|
||||
][0] as number
|
||||
_node.body.splice(sketchIndexInBody + 1, 0, VariableDeclaration)
|
||||
|
||||
_node.body.splice(_node.body.length, 0, VariableDeclaration)
|
||||
const pathToExtrudeArg: PathToNode = [
|
||||
['body', ''],
|
||||
[sketchIndexInBody + 1, 'index'],
|
||||
[_node.body.length, 'index'],
|
||||
['declarations', 'VariableDeclaration'],
|
||||
[0, 'index'],
|
||||
['init', 'VariableDeclarator'],
|
||||
@ -319,7 +306,7 @@ export function extrudeSketch(
|
||||
[0, 'index'],
|
||||
]
|
||||
return {
|
||||
modifiedAst: _node,
|
||||
modifiedAst: node,
|
||||
pathToNode: [...pathToNode.slice(0, -1), [-1, 'index']],
|
||||
pathToExtrudeArg,
|
||||
}
|
||||
@ -327,41 +314,31 @@ export function extrudeSketch(
|
||||
|
||||
export function sketchOnExtrudedFace(
|
||||
node: Program,
|
||||
sketchPathToNode: PathToNode,
|
||||
extrudePathToNode: PathToNode,
|
||||
pathToNode: PathToNode,
|
||||
programMemory: ProgramMemory,
|
||||
cap: 'none' | 'start' | 'end' = 'none'
|
||||
): { modifiedAst: Program; pathToNode: PathToNode } {
|
||||
let _node = { ...node }
|
||||
const newSketchName = findUniqueName(
|
||||
node,
|
||||
KCL_DEFAULT_CONSTANT_PREFIXES.SKETCH
|
||||
)
|
||||
const newSketchName = findUniqueName(node, 'part')
|
||||
const { node: oldSketchNode } = getNodeFromPath<VariableDeclarator>(
|
||||
_node,
|
||||
sketchPathToNode,
|
||||
pathToNode,
|
||||
'VariableDeclarator',
|
||||
true
|
||||
)
|
||||
const oldSketchName = oldSketchNode.id.name
|
||||
const { node: expression } = getNodeFromPath<CallExpression>(
|
||||
_node,
|
||||
sketchPathToNode,
|
||||
pathToNode,
|
||||
'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: sketchPathToNode,
|
||||
pathToNode,
|
||||
node: _node,
|
||||
},
|
||||
expression.callee.name
|
||||
@ -375,16 +352,13 @@ export function sketchOnExtrudedFace(
|
||||
const newSketch = createVariableDeclaration(
|
||||
newSketchName,
|
||||
createCallExpressionStdLib('startSketchOn', [
|
||||
createIdentifier(extrudeName ? extrudeName : oldSketchName),
|
||||
createIdentifier(oldSketchName),
|
||||
createLiteral(_tag),
|
||||
]),
|
||||
'const'
|
||||
)
|
||||
|
||||
const expressionIndex = Math.max(
|
||||
sketchPathToNode[1][0] as number,
|
||||
extrudePathToNode[1][0] as number
|
||||
)
|
||||
const expressionIndex = pathToNode[1][0] as number
|
||||
_node.body.splice(expressionIndex + 1, 0, newSketch)
|
||||
const newpathToNode: PathToNode = [
|
||||
['body', ''],
|
||||
|
@ -44,13 +44,5 @@ 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`
|
||||
|
@ -17,17 +17,15 @@ const prependRoutes =
|
||||
)
|
||||
}
|
||||
|
||||
type OnboardingPaths = {
|
||||
[K in keyof typeof onboardingPaths]: `/onboarding${(typeof onboardingPaths)[K]}`
|
||||
}
|
||||
|
||||
export const paths = {
|
||||
INDEX: '/',
|
||||
HOME: '/home',
|
||||
FILE: '/file',
|
||||
SETTINGS: '/settings',
|
||||
SIGN_IN: '/signin',
|
||||
ONBOARDING: prependRoutes(onboardingPaths)('/onboarding') as OnboardingPaths,
|
||||
ONBOARDING: prependRoutes(onboardingPaths)(
|
||||
'/onboarding'
|
||||
) as typeof onboardingPaths,
|
||||
} as const
|
||||
export const BROWSER_PATH = `%2F${BROWSER_PROJECT_NAME}%2F${BROWSER_FILE_NAME}${FILE_EXT}`
|
||||
|
||||
|
@ -318,6 +318,7 @@ function resetAndSetEngineEntitySelectionCmds(
|
||||
selections: SelectionToEngine[]
|
||||
): Models['WebSocketRequest_type'][] {
|
||||
if (!engineCommandManager.engineConnection?.isReady()) {
|
||||
console.log('engine connection is not ready')
|
||||
return []
|
||||
}
|
||||
return [
|
||||
|
@ -1,182 +0,0 @@
|
||||
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,7 +127,3 @@ export function isReducedMotion(): boolean {
|
||||
window.matchMedia('(prefers-reduced-motion)').matches
|
||||
)
|
||||
}
|
||||
|
||||
export function XOR(bool1: boolean, bool2: boolean): boolean {
|
||||
return (bool1 || bool2) && !(bool1 && bool2)
|
||||
}
|
||||
|
@ -11,98 +11,98 @@ import {
|
||||
|
||||
export const settingsMachine = createMachine(
|
||||
{
|
||||
/** @xstate-layout N4IgpgJg5mDOIC5QGUwBc0EsB2VYDpMIAbMAYlnXwEMAHW-Ae2wCNHqAnCHKZNatAFdYAbQAMAXUShajWJizNpIAB6IALAFYAnPgBMARgDsBsQDY969QGYjmzQBoQAT0SnrADnwePY61r0PAwNtMyMAX3CnVAweAiJSCio6BjQACzAAWzAAYUZiRg5xKSQQWXlFbGU1BD1PfFtfE3UzTUNNaydXBCD1b209PTEPTTMtdQNNSOj0LFx4knJKNHxMxggwYh58DYAzakFiNABVbAVi5XKFTCVSmsGxfCMPM08PQaDNU0cXRG1tLwedTaKxif7+UJTKIgGJzPCERZJFYpfDpLJgC6lK6VaqIEx6fBmCw2Do2IJ6MxdRDvTT4MRDdRGEzWbQ6ELTGGzOIIxLLVbrTbbNKYKBpLaitAAUWgcExMjk11uoBqVgM3jMYhsAIMrVs6ipPWChOeYhC9KMFhGHNh3IS5AASnB0AACZZw0SSS4KnF3PFafADTV1YZ2IxiH7dNpGfCaIzAgE+IzWMzBa1c+Y88gxZ3UYjEV3pvBysrem5VX0IFq0y3aTXWOp6JmU34IKMxuz0joGEYWsxp2IZu1kABUxexZdxtRG+EmQMZmne3dNBs0jKewLBsbCwI81n77vwtDAHHksDhBYHeDIEGYYEI2AAbowANZ3o8nzBnm3zMelpWqRAAFp62sJ4jEsZ4AT0UJGwjPFzH6cwNW0AwWXpbRImhbABXgUpvzwL0KgnCtgJMMCII8KCYLsA11EGOkXneDxmXMCk92hfCFlIQjFXLZUgLjddWhaFkgRCaxOhbEYzBnXwXkmOjAjjfduXfU9zzdOIeJ9fiEEA6ckwMClQ2BFpmJXMF9DjYI6hZfxmMw8IgA */
|
||||
/** @xstate-layout N4IgpgJg5mDOIC5QGUwBc0EsB2VYDpMIAbMAYlnXwEMAHW-Ae2wCNHqAnCHKZNatAFdYAbQAMAXUShajWJizNpIAB6IAbAFZN+AOwAWAIwAOYwE4AzGYBM+-ZosAaEAE9Eh62LP51ls+v0LMWt1awMAX3DnVAweAiJSCio6BjQACzAAWzAAYUZiRg5xKSQQWXlFbGU1BA9vQ0N1CwCxdVbdY1DnNwQzPp8zTTFje1D1QwtjSOj0LFx4knJKNHxMxggwYh58DYAzakFiNABVbAVi5XKFTCVSmotNY3w7YysHRuNDTXV1bvdG7w-IKTcbaazWCzTEAxOZ4QiLJIrFL4dJZMAXUpXSrVDTGazPMQPR4GXRBAx-XoGfDWIadMx4n6EqEwuLwxLLVbrTbbNKYKBpLb8tAAUWgcAxMjk11uoHumn0+DEw2sJkMulCgWsFL6YnwfX0ELsYg61jMumZs1ZCXIACU4OgAATLWGiSSXKXYu4aLz4UwWBr6DqBYYUzSePXqUlBLxmyZmC2xeZs8gxB3UYjEJ2W+YSsoem5VL0IKy6z6EsJifQ2Czq0MTHzq8Yjfz6MQmBMu5NkABUuaxBZxCDD+DD5lJgUjxssFP0xl0I+0pm06uMmg8kSiIGwXPgpRZ83dFQHRYAtA0LCO2tZjGIHJNdB5fq5EK3dc1OsNGkrA+oO1bFoe0qFrKiAnlol7BDed5zo+FK+Doc7qhCNaVv4UwbkAA */
|
||||
id: 'Settings',
|
||||
predictableActionArguments: true,
|
||||
context: {} as ReturnType<typeof createSettings>,
|
||||
initial: 'idle',
|
||||
states: {
|
||||
idle: {
|
||||
entry: ['setThemeClass', 'setClientSideSceneUnits'],
|
||||
entry: ['setThemeClass', 'setClientSideSceneUnits', 'persistSettings'],
|
||||
|
||||
on: {
|
||||
'*': {
|
||||
target: 'persisting settings',
|
||||
actions: ['setSettingAtLevel', 'toastSuccess'],
|
||||
target: 'idle',
|
||||
internal: true,
|
||||
actions: ['setSettingAtLevel', 'toastSuccess', 'persistSettings'],
|
||||
},
|
||||
|
||||
'set.app.onboardingStatus': {
|
||||
target: 'persisting settings',
|
||||
|
||||
// No toast
|
||||
actions: ['setSettingAtLevel'],
|
||||
target: 'idle',
|
||||
internal: true,
|
||||
actions: ['setSettingAtLevel', 'persistSettings'], // No toast
|
||||
},
|
||||
'set.app.themeColor': {
|
||||
target: 'persisting settings',
|
||||
|
||||
// No toast
|
||||
actions: ['setSettingAtLevel'],
|
||||
target: 'idle',
|
||||
internal: true,
|
||||
actions: ['setSettingAtLevel', 'persistSettings'], // No toast
|
||||
},
|
||||
|
||||
'set.modeling.defaultUnit': {
|
||||
target: 'persisting settings',
|
||||
|
||||
target: 'idle',
|
||||
internal: true,
|
||||
actions: [
|
||||
'setSettingAtLevel',
|
||||
'toastSuccess',
|
||||
'setClientSideSceneUnits',
|
||||
'Execute AST',
|
||||
'persistSettings',
|
||||
],
|
||||
},
|
||||
|
||||
'set.app.theme': {
|
||||
target: 'persisting settings',
|
||||
|
||||
target: 'idle',
|
||||
internal: true,
|
||||
actions: [
|
||||
'setSettingAtLevel',
|
||||
'toastSuccess',
|
||||
'setThemeClass',
|
||||
'setEngineTheme',
|
||||
'persistSettings',
|
||||
'setClientTheme',
|
||||
],
|
||||
},
|
||||
|
||||
'set.modeling.highlightEdges': {
|
||||
target: 'persisting settings',
|
||||
|
||||
actions: ['setSettingAtLevel', 'toastSuccess', 'setEngineEdges'],
|
||||
target: 'idle',
|
||||
internal: true,
|
||||
actions: [
|
||||
'setSettingAtLevel',
|
||||
'toastSuccess',
|
||||
'setEngineEdges',
|
||||
'persistSettings',
|
||||
],
|
||||
},
|
||||
|
||||
'Reset settings': {
|
||||
target: 'persisting settings',
|
||||
|
||||
target: 'idle',
|
||||
internal: true,
|
||||
actions: [
|
||||
'resetSettings',
|
||||
'setThemeClass',
|
||||
'setEngineTheme',
|
||||
'setClientSideSceneUnits',
|
||||
'Execute AST',
|
||||
'persistSettings',
|
||||
'setClientTheme',
|
||||
],
|
||||
},
|
||||
|
||||
'Set all settings': {
|
||||
target: 'persisting settings',
|
||||
|
||||
target: 'idle',
|
||||
internal: true,
|
||||
actions: [
|
||||
'setAllSettings',
|
||||
'setThemeClass',
|
||||
'setEngineTheme',
|
||||
'setClientSideSceneUnits',
|
||||
'Execute AST',
|
||||
'persistSettings',
|
||||
'setClientTheme',
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
'persisting settings': {
|
||||
invoke: {
|
||||
src: 'Persist settings',
|
||||
id: 'persistSettings',
|
||||
onDone: 'idle',
|
||||
},
|
||||
},
|
||||
},
|
||||
tsTypes: {} as import('./settingsMachine.typegen').Typegen0,
|
||||
schema: {
|
||||
|
@ -3,7 +3,7 @@ import { Outlet, useNavigate } from 'react-router-dom'
|
||||
import Introduction from './Introduction'
|
||||
import Camera from './Camera'
|
||||
import Sketching from './Sketching'
|
||||
import { useCallback, useEffect } from 'react'
|
||||
import { useCallback } from 'react'
|
||||
import makeUrlPathRelative from '../../lib/makeUrlPathRelative'
|
||||
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
|
||||
import Streaming from './Streaming'
|
||||
@ -94,31 +94,17 @@ export function useNextClick(newStatus: string) {
|
||||
export function useDismiss() {
|
||||
const filePath = useAbsoluteFilePath()
|
||||
const {
|
||||
settings: { state, send },
|
||||
settings: { send },
|
||||
} = useSettingsAuthContext()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const settingsCallback = useCallback(() => {
|
||||
return useCallback(() => {
|
||||
send({
|
||||
type: 'set.app.onboardingStatus',
|
||||
data: { level: 'user', value: 'dismissed' },
|
||||
})
|
||||
}, [send])
|
||||
|
||||
/**
|
||||
* A "listener" for the XState to return to "idle" state
|
||||
* when the user dismisses the onboarding, using the callback above
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (
|
||||
state.context.app.onboardingStatus.user === 'dismissed' &&
|
||||
state.matches('idle')
|
||||
) {
|
||||
navigate(filePath)
|
||||
}
|
||||
}, [filePath, navigate, state])
|
||||
|
||||
return settingsCallback
|
||||
navigate(filePath)
|
||||
}, [send, navigate, filePath])
|
||||
}
|
||||
|
||||
// Get the 1-indexed step number of the current onboarding step
|
||||
|
@ -1,17 +1,28 @@
|
||||
import { SettingsLevel } from 'lib/settings/settingsTypes'
|
||||
import { ActionButton } from '../components/ActionButton'
|
||||
import { SetEventTypes, 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 { SettingsSectionsList } from 'components/Settings/SettingsSectionsList'
|
||||
import { AllSettingsFields } from 'components/Settings/AllSettingsFields'
|
||||
import { AllKeybindingsFields } from 'components/Settings/AllKeybindingsFields'
|
||||
import { KeybindingsSectionsList } from 'components/Settings/KeybindingsSectionsList'
|
||||
import { SettingsSection } from 'components/Settings/SettingsSection'
|
||||
import { SettingsFieldInput } from 'components/Settings/SettingsFieldInput'
|
||||
|
||||
export const APP_VERSION = import.meta.env.PACKAGE_VERSION || 'unknown'
|
||||
|
||||
@ -22,12 +33,40 @@ export const Settings = () => {
|
||||
const location = useLocation()
|
||||
const isFileSettings = location.pathname.includes(paths.FILE)
|
||||
const searchParamTab =
|
||||
(searchParams.get('tab') as SettingsLevel | 'keybindings') ??
|
||||
(searchParams.get('tab') as SettingsLevel) ??
|
||||
(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(() => {
|
||||
@ -98,24 +137,233 @@ export const Settings = () => {
|
||||
gridTemplateRows: '1fr',
|
||||
}}
|
||||
>
|
||||
{searchParamTab !== 'keybindings' ? (
|
||||
<>
|
||||
<SettingsSectionsList
|
||||
searchParamTab={searchParamTab}
|
||||
scrollRef={scrollRef}
|
||||
/>
|
||||
<AllSettingsFields
|
||||
searchParamTab={searchParamTab}
|
||||
isFileSettings={isFileSettings}
|
||||
ref={scrollRef}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<KeybindingsSectionsList scrollRef={scrollRef} />
|
||||
<AllKeybindingsFields ref={scrollRef} />
|
||||
</>
|
||||
)}
|
||||
<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>
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
|
1763
src/wasm-lib/Cargo.lock
generated
@ -10,27 +10,26 @@ rust-version = "1.73"
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
bson = { version = "2.11.0", features = ["uuid-1", "chrono"] }
|
||||
clap = "4.5.7"
|
||||
bson = { version = "2.10.0", features = ["uuid-1", "chrono"] }
|
||||
clap = "4.5.4"
|
||||
gloo-utils = "0.2.0"
|
||||
kcl-lib = { path = "kcl" }
|
||||
kittycad.workspace = true
|
||||
kittycad = { workspace = true }
|
||||
serde_json = "1.0.116"
|
||||
tokio = { version = "1.38.0", features = ["sync"] }
|
||||
toml = "0.8.14"
|
||||
tokio = { version = "1.37.0", features = ["sync"] }
|
||||
toml = "0.8.13"
|
||||
uuid = { version = "1.8.0", features = ["v4", "js", "serde"] }
|
||||
wasm-bindgen = "0.2.91"
|
||||
wasm-bindgen-futures = "0.4.42"
|
||||
|
||||
[dev-dependencies]
|
||||
anyhow = "1"
|
||||
hyper = { version = "0.14.29", features = ["server", "http1"] }
|
||||
image = { version = "0.25.1", default-features = false, features = ["png"] }
|
||||
image = "0.24.9"
|
||||
kittycad = { workspace = true, default-features = true }
|
||||
pretty_assertions = "1.4.0"
|
||||
reqwest = { version = "0.11.26", default-features = false }
|
||||
tokio = { version = "1.38.0", features = ["rt-multi-thread", "macros", "time"] }
|
||||
twenty-twenty = "0.8"
|
||||
tokio = { version = "1.37.0", features = ["rt-multi-thread", "macros", "time"] }
|
||||
twenty-twenty = "0.7"
|
||||
uuid = { version = "1.8.0", features = ["v4", "js", "serde"] }
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
@ -54,21 +53,20 @@ 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.5", default-features = false, features = ["js", "requests"] }
|
||||
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]]
|
||||
@ -81,5 +79,8 @@ 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" }
|
||||
|
24
src/wasm-lib/grackle/Cargo.toml
Normal file
@ -0,0 +1,24 @@
|
||||
[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"
|
BIN
src/wasm-lib/grackle/fixtures/cube_lineTo.png
Normal file
After Width: | Height: | Size: 80 KiB |
BIN
src/wasm-lib/grackle/fixtures/cube_tangentialArcTo.png
Normal file
After Width: | Height: | Size: 82 KiB |
BIN
src/wasm-lib/grackle/fixtures/cube_xyLine.png
Normal file
After Width: | Height: | Size: 79 KiB |
270
src/wasm-lib/grackle/src/binding_scope.rs
Normal file
@ -0,0 +1,270 @@
|
||||
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,
|
||||
}
|
84
src/wasm-lib/grackle/src/error.rs
Normal file
@ -0,0 +1,84 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
14
src/wasm-lib/grackle/src/kcl_value_group.rs
Normal file
@ -0,0 +1,14 @@
|
||||
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),
|
||||
}
|
||||
}
|