Compare commits
63 Commits
v0.25.1
...
franknoiro
Author | SHA1 | Date | |
---|---|---|---|
fc95f9e49e | |||
93d9b10e11 | |||
166487433c | |||
5512f99997 | |||
01cc9e751b | |||
bfac6b7dc8 | |||
d1f9a02ffa | |||
d8236dd8da | |||
dabf256e2b | |||
4285e81001 | |||
370375c328 | |||
9f22882c68 | |||
db5331d9b9 | |||
5cc92f0162 | |||
2978e80226 | |||
4a74c60150 | |||
00fa40bbc9 | |||
62b78840b6 | |||
f828c36e58 | |||
8c5b146c94 | |||
61c7d9844d | |||
8d48c17395 | |||
0ff820d4da | |||
c4ff1c2ef1 | |||
b6aba2f29c | |||
7467f7ea50 | |||
0c6d3e0ccf | |||
e82917ea01 | |||
857c1aad3d | |||
dc73acb1b1 | |||
8602e937d3 | |||
a2133d8317 | |||
39ce0da3e5 | |||
f235a950b0 | |||
3cd3e1af72 | |||
8c6266e94b | |||
755a6016c7 | |||
1cbbefba97 | |||
8610d606f4 | |||
728e87a627 | |||
772034af68 | |||
957a9ca4fe | |||
472eb2bafe | |||
88216d4c76 | |||
8b1e4d6708 | |||
769c3ec785 | |||
1c4179a9db | |||
292f89859f | |||
a00800bddc | |||
78ceba6d20 | |||
6776a350af | |||
dd75f06f77 | |||
394872d84e | |||
f9eef6397f | |||
900bac999c | |||
5b2738f826 | |||
dab96577a7 | |||
25443eba31 | |||
0a72d7a39a | |||
5f8d4f8294 | |||
7c2cfba0ac | |||
5ee43bda22 | |||
a1b6bbac7e |
@ -13,6 +13,8 @@
|
||||
"plugin:css-modules/recommended"
|
||||
],
|
||||
"rules": {
|
||||
"@typescript-eslint/no-floating-promises": "error",
|
||||
"@typescript-eslint/no-misused-promises": "error",
|
||||
"semi": [
|
||||
"error",
|
||||
"never"
|
||||
@ -24,7 +26,6 @@
|
||||
{
|
||||
"files": ["e2e/**/*.ts"], // Update the pattern based on your file structure
|
||||
"rules": {
|
||||
"@typescript-eslint/no-floating-promises": "warn",
|
||||
"suggest-no-throw/suggest-no-throw": "off",
|
||||
"testing-library/prefer-screen-queries": "off",
|
||||
"jest/valid-expect": "off"
|
||||
|
60
.github/workflows/build-test-publish-apps.yml
vendored
@ -52,7 +52,6 @@ jobs:
|
||||
VERSION=$(date +'%-y.%-m.%-d') yarn bump-jsons
|
||||
|
||||
# TODO: see if we need to inject updater nightly URL here https://dl.zoo.dev/releases/modeling-app/nightly/last_update.json
|
||||
# TODO: see if we ned to add updater test URL here https://dl.zoo.dev/releases/modeling-app/updater-test/last_update.json
|
||||
|
||||
- uses: actions/upload-artifact@v3
|
||||
with:
|
||||
@ -64,6 +63,17 @@ jobs:
|
||||
- id: export_version
|
||||
run: echo "version=`cat package.json | jq -r '.version'`" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Prepare electron-builder.yml file for updater test
|
||||
if: ${{ env.CUT_RELEASE_PR == 'true' }}
|
||||
run: |
|
||||
yq -i '.publish[0].url = "https://dl.zoo.dev/releases/modeling-app/updater-test"' electron-builder.yml
|
||||
|
||||
- uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: prepared-files-updater-test
|
||||
path: |
|
||||
electron-builder.yml
|
||||
|
||||
|
||||
build-apps:
|
||||
needs: [prepare-files]
|
||||
@ -149,7 +159,27 @@ jobs:
|
||||
|
||||
# TODO: add the 'Build for Mac TestFlight (nightly)' stage back
|
||||
|
||||
# TODO: add the updater tests back
|
||||
- uses: actions/download-artifact@v3
|
||||
if: ${{ env.CUT_RELEASE_PR == 'true' }}
|
||||
name: prepared-files-updater-test
|
||||
|
||||
- name: Copy updated electron-builder.yml file for updater test
|
||||
if: ${{ env.CUT_RELEASE_PR == 'true' }}
|
||||
run: |
|
||||
ls -R prepared-files-updater-test
|
||||
cp prepared-files-updater-test/electron-builder.yml electron-builder.yml
|
||||
|
||||
- name: Build the app (updater-test)
|
||||
if: ${{ env.CUT_RELEASE_PR == 'true' }}
|
||||
run: yarn electron-builder --config ${{ env.BUILD_RELEASE && '--publish always' || '' }}
|
||||
|
||||
- uses: actions/upload-artifact@v3
|
||||
if: ${{ env.CUT_RELEASE_PR == 'true' }}
|
||||
with:
|
||||
name: updater-test-${{ matrix.os }}
|
||||
path: |
|
||||
out/Zoo*.*
|
||||
out/latest*.yml
|
||||
|
||||
|
||||
publish-apps-release:
|
||||
@ -193,8 +223,8 @@ jobs:
|
||||
--arg notes "${NOTES}" \
|
||||
--arg mac_arm64_url "$RELEASE_DIR/${{ env.URL_CODED_NAME }}-${VERSION_NO_V}-arm64-mac.dmg" \
|
||||
--arg mac_x64_url "$RELEASE_DIR/${{ env.URL_CODED_NAME }}-${VERSION_NO_V}-x64-mac.dmg" \
|
||||
--arg windows_arm64_url "$RELEASE_DIR/${{ env.URL_CODED_NAME }}-${VERSION_NO_V}-arm64-win.msi" \
|
||||
--arg windows_x64_url "$RELEASE_DIR/${{ env.URL_CODED_NAME }}-${VERSION_NO_V}-x64-win.msi" \
|
||||
--arg windows_arm64_url "$RELEASE_DIR/${{ env.URL_CODED_NAME }}-${VERSION_NO_V}-arm64-win.exe" \
|
||||
--arg windows_x64_url "$RELEASE_DIR/${{ env.URL_CODED_NAME }}-${VERSION_NO_V}-x64-win.exe" \
|
||||
--arg linux_arm64_url "$RELEASE_DIR/${{ env.URL_CODED_NAME }}-${VERSION_NO_V}-arm64-linux.AppImage" \
|
||||
--arg linux_x64_url "$RELEASE_DIR/${{ env.URL_CODED_NAME }}-${VERSION_NO_V}-x86_64-linux.AppImage" \
|
||||
'{
|
||||
@ -208,10 +238,10 @@ jobs:
|
||||
"dmg-x64": {
|
||||
"url": $mac_x64_url
|
||||
},
|
||||
"msi-arm64": {
|
||||
"exe-arm64": {
|
||||
"url": $windows_arm64_url
|
||||
},
|
||||
"msi-x64": {
|
||||
"exe-x64": {
|
||||
"url": $windows_x64_url
|
||||
},
|
||||
"appimage-arm64": {
|
||||
@ -245,15 +275,6 @@ jobs:
|
||||
parent: false
|
||||
destination: ${{ env.BUCKET_DIR }}
|
||||
|
||||
# TODO: remove workaround introduced in https://github.com/KittyCAD/modeling-app/issues/3817
|
||||
- name: Upload release files to public bucket (test/electron-builder workaround)
|
||||
uses: google-github-actions/upload-cloud-storage@v2.2.0
|
||||
with:
|
||||
path: out
|
||||
glob: 'Zoo*'
|
||||
parent: false
|
||||
destination: '${{ env.BUCKET_DIR }}/test/electron-builder'
|
||||
|
||||
- name: Upload update endpoint to public bucket
|
||||
uses: google-github-actions/upload-cloud-storage@v2.2.0
|
||||
with:
|
||||
@ -262,15 +283,6 @@ jobs:
|
||||
parent: false
|
||||
destination: ${{ env.BUCKET_DIR }}
|
||||
|
||||
# TODO: remove workaround introduced in https://github.com/KittyCAD/modeling-app/issues/3817
|
||||
- name: Upload update endpoint to public bucket (test/electron-builder workaround)
|
||||
uses: google-github-actions/upload-cloud-storage@v2.2.0
|
||||
with:
|
||||
path: out
|
||||
glob: 'latest*'
|
||||
parent: false
|
||||
destination: '${{ env.BUCKET_DIR }}/test/electron-builder'
|
||||
|
||||
- name: Upload download endpoint to public bucket
|
||||
uses: google-github-actions/upload-cloud-storage@v2.2.0
|
||||
with:
|
||||
|
2
.github/workflows/build-test-web.yml
vendored
@ -45,7 +45,7 @@ jobs:
|
||||
- run: yarn xstate:typegen
|
||||
- run: yarn tsc
|
||||
- name: Lint
|
||||
run: yarn eslint --max-warnings 0 src e2e
|
||||
run: yarn eslint --max-warnings 0 src e2e packages/codemirror-lsp-client
|
||||
|
||||
|
||||
check-typos:
|
||||
|
4
.github/workflows/playwright.yml
vendored
@ -34,7 +34,7 @@ jobs:
|
||||
- 'src/wasm-lib/**'
|
||||
|
||||
playwright-chrome:
|
||||
timeout-minutes: ${{ matrix.os == 'macos-14' && 60 || 40 }}
|
||||
timeout-minutes: ${{ matrix.os == 'macos-14' && 60 || 50 }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@ -232,6 +232,7 @@ jobs:
|
||||
exit 0
|
||||
env:
|
||||
CI: true
|
||||
FAIL_ON_CONSOLE_ERRORS: true
|
||||
NODE_ENV: development
|
||||
VITE_KC_DEV_TOKEN: ${{ secrets.KITTYCAD_API_TOKEN_DEV }}
|
||||
VITE_KC_SKIP_AUTH: true
|
||||
@ -410,6 +411,7 @@ jobs:
|
||||
exit 0
|
||||
env:
|
||||
CI: true
|
||||
FAIL_ON_CONSOLE_ERRORS: true
|
||||
NODE_ENV: development
|
||||
VITE_KC_DEV_TOKEN: ${{ secrets.KITTYCAD_API_TOKEN_DEV }}
|
||||
VITE_KC_SKIP_AUTH: true
|
||||
|
@ -22,8 +22,3 @@ once fixed in engine will just start working here with no language changes.
|
||||
|
||||
- **Chamfers**: Chamfers cannot intersect, you will get an error. Only simple
|
||||
chamfer cases work currently.
|
||||
|
||||
Sketching on the chamfered face does not currently work.
|
||||
|
||||
- **Shell**: Shell sometimes does not work when arcs or fillets are involved.
|
||||
We are tracking the engine side bug on this.
|
||||
|
858
docs/kcl/arrayReduce.md
Normal file
@ -19,6 +19,7 @@ layout: manual
|
||||
* [`angledLineToX`](kcl/angledLineToX)
|
||||
* [`angledLineToY`](kcl/angledLineToY)
|
||||
* [`arc`](kcl/arc)
|
||||
* [`arrayReduce`](kcl/arrayReduce)
|
||||
* [`asin`](kcl/asin)
|
||||
* [`assert`](kcl/assert)
|
||||
* [`assertEqual`](kcl/assertEqual)
|
||||
|
18150
docs/kcl/std.json
@ -13,14 +13,16 @@ arrays can hold objects and vice versa.
|
||||
|
||||
`true` or `false` work when defining values.
|
||||
|
||||
## Variable declaration
|
||||
## Constant declaration
|
||||
|
||||
Variables are defined with the `let` keyword like so:
|
||||
Constants are defined with the `let` keyword like so:
|
||||
|
||||
```
|
||||
let myBool = false
|
||||
```
|
||||
|
||||
Currently you cannot redeclare a constant.
|
||||
|
||||
## Array
|
||||
|
||||
An array is defined with `[]` braces. What is inside the brackets can
|
||||
|
@ -8,8 +8,8 @@ import {
|
||||
PERSIST_MODELING_CONTEXT,
|
||||
} from './test-utils'
|
||||
|
||||
test.beforeEach(async ({ context, page }) => {
|
||||
await setup(context, page)
|
||||
test.beforeEach(async ({ context, page }, testInfo) => {
|
||||
await setup(context, page, testInfo)
|
||||
})
|
||||
|
||||
test.afterEach(async ({ page }, testInfo) => {
|
||||
|
@ -3,8 +3,8 @@ import { getUtils, setup, tearDown } from './test-utils'
|
||||
import { EngineCommand } from 'lang/std/artifactGraph'
|
||||
import { uuidv4 } from 'lib/utils'
|
||||
|
||||
test.beforeEach(async ({ context, page }) => {
|
||||
await setup(context, page)
|
||||
test.beforeEach(async ({ context, page }, testInfo) => {
|
||||
await setup(context, page, testInfo)
|
||||
})
|
||||
|
||||
test.afterEach(async ({ page }, testInfo) => {
|
||||
|
@ -12,8 +12,8 @@ import { bracket } from 'lib/exampleKcl'
|
||||
import { TEST_CODE_LONG_WITH_ERROR_OUT_OF_VIEW } from './storageStates'
|
||||
import fsp from 'fs/promises'
|
||||
|
||||
test.beforeEach(async ({ context, page }) => {
|
||||
await setup(context, page)
|
||||
test.beforeEach(async ({ context, page }, testInfo) => {
|
||||
await setup(context, page, testInfo)
|
||||
})
|
||||
|
||||
test.afterEach(async ({ page }, testInfo) => {
|
||||
@ -65,6 +65,8 @@ const extrude001 = extrude(5, sketch001)`
|
||||
test('Opening and closing the code pane will consistently show error diagnostics', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto('http://localhost:3000')
|
||||
|
||||
const u = await getUtils(page)
|
||||
|
||||
// Load the app with the working starter code
|
||||
@ -90,7 +92,7 @@ const extrude001 = extrude(5, sketch001)`
|
||||
|
||||
// Delete a character to break the KCL
|
||||
await u.openKclCodePanel()
|
||||
await page.getByText('extrude(').click()
|
||||
await page.getByText('thickness, bracketLeg1Sketch)').click()
|
||||
await page.keyboard.press('Backspace')
|
||||
|
||||
// Ensure that a badge appears on the button
|
||||
@ -101,7 +103,7 @@ const extrude001 = extrude(5, sketch001)`
|
||||
|
||||
// error text on hover
|
||||
await page.hover('.cm-lint-marker-error')
|
||||
await expect(page.getByText('Unexpected token: |').first()).toBeVisible()
|
||||
await expect(page.locator('.cm-tooltip').first()).toBeVisible()
|
||||
|
||||
// Close the code pane
|
||||
await codePaneButton.click()
|
||||
@ -124,7 +126,7 @@ const extrude001 = extrude(5, sketch001)`
|
||||
|
||||
// error text on hover
|
||||
await page.hover('.cm-lint-marker-error')
|
||||
await expect(page.getByText('Unexpected token: |').first()).toBeVisible()
|
||||
await expect(page.locator('.cm-tooltip').first()).toBeVisible()
|
||||
})
|
||||
|
||||
test('When error is not in view you can click the badge to scroll to it', async ({
|
||||
|
@ -3,8 +3,8 @@ import { test, expect } from '@playwright/test'
|
||||
import { getUtils, setup, tearDown } from './test-utils'
|
||||
import { KCL_DEFAULT_LENGTH } from 'lib/constants'
|
||||
|
||||
test.beforeEach(async ({ context, page }) => {
|
||||
await setup(context, page)
|
||||
test.beforeEach(async ({ context, page }, testInfo) => {
|
||||
await setup(context, page, testInfo)
|
||||
})
|
||||
|
||||
test.afterEach(async ({ page }, testInfo) => {
|
||||
|
@ -1,8 +1,8 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { getUtils, setup, tearDown } from './test-utils'
|
||||
|
||||
test.beforeEach(async ({ context, page }) => {
|
||||
await setup(context, page)
|
||||
test.beforeEach(async ({ context, page }, testInfo) => {
|
||||
await setup(context, page, testInfo)
|
||||
})
|
||||
|
||||
test.afterEach(async ({ page }, testInfo) => {
|
||||
|
@ -2,8 +2,8 @@ import { test, expect } from '@playwright/test'
|
||||
import { uuidv4 } from 'lib/utils'
|
||||
import { getUtils, setup, tearDown } from './test-utils'
|
||||
|
||||
test.beforeEach(async ({ context, page }) => {
|
||||
await setup(context, page)
|
||||
test.beforeEach(async ({ context, page }, testInfo) => {
|
||||
await setup(context, page, testInfo)
|
||||
})
|
||||
|
||||
test.afterEach(async ({ page }, testInfo) => {
|
||||
|
@ -1,9 +1,18 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
import * as fsp from 'fs/promises'
|
||||
import { getUtils, setup, setupElectron, tearDown } from './test-utils'
|
||||
import * as fs from 'fs'
|
||||
import {
|
||||
executorInputPath,
|
||||
getUtils,
|
||||
setup,
|
||||
setupElectron,
|
||||
tearDown,
|
||||
} from './test-utils'
|
||||
import { join } from 'path'
|
||||
import { FILE_EXT } from 'lib/constants'
|
||||
|
||||
test.beforeEach(async ({ context, page }) => {
|
||||
await setup(context, page)
|
||||
test.beforeEach(async ({ context, page }, testInfo) => {
|
||||
await setup(context, page, testInfo)
|
||||
})
|
||||
|
||||
test.afterEach(async ({ page }, testInfo) => {
|
||||
@ -108,7 +117,6 @@ test.describe('when using the file tree to', () => {
|
||||
async ({ browser: _ }, testInfo) => {
|
||||
const { electronApp, page } = await setupElectron({
|
||||
testInfo,
|
||||
folderSetupFn: async () => {},
|
||||
})
|
||||
|
||||
const {
|
||||
@ -151,6 +159,7 @@ test.describe('when using the file tree to', () => {
|
||||
await selectFile(kcl1)
|
||||
await editorTextMatches(kclCube)
|
||||
})
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
await test.step(`Postcondition: ${kcl2} still exists with the original content`, async () => {
|
||||
await selectFile(kcl2)
|
||||
@ -277,3 +286,584 @@ test.describe('when using the file tree to', () => {
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
test.describe('Renaming in the file tree', () => {
|
||||
test(
|
||||
'A file you have open',
|
||||
{ tag: '@electron' },
|
||||
async ({ browser: _ }, testInfo) => {
|
||||
const { electronApp, page, dir } = await setupElectron({
|
||||
testInfo,
|
||||
folderSetupFn: async (dir) => {
|
||||
await fsp.mkdir(join(dir, 'Test Project'), { recursive: true })
|
||||
await fsp.copyFile(
|
||||
executorInputPath('basic_fillet_cube_end.kcl'),
|
||||
join(dir, 'Test Project', 'main.kcl')
|
||||
)
|
||||
await fsp.copyFile(
|
||||
executorInputPath('cylinder.kcl'),
|
||||
join(dir, 'Test Project', 'fileToRename.kcl')
|
||||
)
|
||||
},
|
||||
})
|
||||
const u = await getUtils(page)
|
||||
await page.setViewportSize({ width: 1200, height: 500 })
|
||||
page.on('console', console.log)
|
||||
|
||||
// Constants and locators
|
||||
const projectLink = page.getByText('Test Project')
|
||||
const projectMenuButton = page.getByTestId('project-sidebar-toggle')
|
||||
const checkUnRenamedFS = () => {
|
||||
const filePath = join(dir, 'Test Project', 'fileToRename.kcl')
|
||||
return fs.existsSync(filePath)
|
||||
}
|
||||
const newFileName = 'newFileName'
|
||||
const checkRenamedFS = () => {
|
||||
const filePath = join(dir, 'Test Project', `${newFileName}.kcl`)
|
||||
return fs.existsSync(filePath)
|
||||
}
|
||||
|
||||
const fileToRename = page
|
||||
.getByRole('listitem')
|
||||
.filter({ has: page.getByRole('button', { name: 'fileToRename.kcl' }) })
|
||||
const renamedFile = page
|
||||
.getByRole('listitem')
|
||||
.filter({ has: page.getByRole('button', { name: 'newFileName.kcl' }) })
|
||||
const renameMenuItem = page.getByRole('button', { name: 'Rename' })
|
||||
const renameInput = page.getByPlaceholder('fileToRename.kcl')
|
||||
const codeLocator = page.locator('.cm-content')
|
||||
|
||||
await test.step('Open project and file pane', async () => {
|
||||
await expect(projectLink).toBeVisible()
|
||||
await projectLink.click()
|
||||
await expect(projectMenuButton).toBeVisible()
|
||||
await expect(projectMenuButton).toContainText('main.kcl')
|
||||
|
||||
await u.openFilePanel()
|
||||
await expect(fileToRename).toBeVisible()
|
||||
expect(checkUnRenamedFS()).toBeTruthy()
|
||||
expect(checkRenamedFS()).toBeFalsy()
|
||||
await fileToRename.click()
|
||||
await expect(projectMenuButton).toContainText('fileToRename.kcl')
|
||||
await u.openKclCodePanel()
|
||||
await expect(codeLocator).toContainText('circle(')
|
||||
await u.closeKclCodePanel()
|
||||
})
|
||||
|
||||
await test.step('Rename the file', async () => {
|
||||
await fileToRename.click({ button: 'right' })
|
||||
await renameMenuItem.click()
|
||||
await expect(renameInput).toBeVisible()
|
||||
await renameInput.fill(newFileName)
|
||||
await page.keyboard.press('Enter')
|
||||
})
|
||||
|
||||
await test.step('Verify the file is renamed', async () => {
|
||||
await expect(fileToRename).not.toBeAttached()
|
||||
await expect(renamedFile).toBeVisible()
|
||||
expect(checkUnRenamedFS()).toBeFalsy()
|
||||
expect(checkRenamedFS()).toBeTruthy()
|
||||
})
|
||||
|
||||
await test.step('Verify we navigated', async () => {
|
||||
await expect(projectMenuButton).toContainText(newFileName + FILE_EXT)
|
||||
const url = page.url()
|
||||
expect(url).toContain(newFileName)
|
||||
await expect(projectMenuButton).not.toContainText('fileToRename.kcl')
|
||||
await expect(projectMenuButton).not.toContainText('main.kcl')
|
||||
expect(url).not.toContain('fileToRename.kcl')
|
||||
expect(url).not.toContain('main.kcl')
|
||||
|
||||
await u.openKclCodePanel()
|
||||
await expect(codeLocator).toContainText('circle(')
|
||||
})
|
||||
|
||||
await electronApp.close()
|
||||
}
|
||||
)
|
||||
|
||||
test(
|
||||
'A file you do not have open',
|
||||
{ tag: '@electron' },
|
||||
async ({ browser: _ }, testInfo) => {
|
||||
const { electronApp, page, dir } = await setupElectron({
|
||||
testInfo,
|
||||
folderSetupFn: async (dir) => {
|
||||
await fsp.mkdir(join(dir, 'Test Project'), { recursive: true })
|
||||
await fsp.copyFile(
|
||||
executorInputPath('basic_fillet_cube_end.kcl'),
|
||||
join(dir, 'Test Project', 'main.kcl')
|
||||
)
|
||||
await fsp.copyFile(
|
||||
executorInputPath('cylinder.kcl'),
|
||||
join(dir, 'Test Project', 'fileToRename.kcl')
|
||||
)
|
||||
},
|
||||
})
|
||||
const u = await getUtils(page)
|
||||
await page.setViewportSize({ width: 1200, height: 500 })
|
||||
page.on('console', console.log)
|
||||
|
||||
// Constants and locators
|
||||
const newFileName = 'newFileName'
|
||||
const checkUnRenamedFS = () => {
|
||||
const filePath = join(dir, 'Test Project', 'fileToRename.kcl')
|
||||
return fs.existsSync(filePath)
|
||||
}
|
||||
const checkRenamedFS = () => {
|
||||
const filePath = join(dir, 'Test Project', `${newFileName}.kcl`)
|
||||
return fs.existsSync(filePath)
|
||||
}
|
||||
const projectLink = page.getByText('Test Project')
|
||||
const projectMenuButton = page.getByTestId('project-sidebar-toggle')
|
||||
const fileToRename = page
|
||||
.getByRole('listitem')
|
||||
.filter({ has: page.getByRole('button', { name: 'fileToRename.kcl' }) })
|
||||
const renamedFile = page.getByRole('listitem').filter({
|
||||
has: page.getByRole('button', { name: newFileName + FILE_EXT }),
|
||||
})
|
||||
const renameMenuItem = page.getByRole('button', { name: 'Rename' })
|
||||
const renameInput = page.getByPlaceholder('fileToRename.kcl')
|
||||
const codeLocator = page.locator('.cm-content')
|
||||
|
||||
await test.step('Open project and file pane', async () => {
|
||||
await expect(projectLink).toBeVisible()
|
||||
await projectLink.click()
|
||||
await expect(projectMenuButton).toBeVisible()
|
||||
await expect(projectMenuButton).toContainText('main.kcl')
|
||||
|
||||
await u.openFilePanel()
|
||||
await expect(fileToRename).toBeVisible()
|
||||
expect(checkUnRenamedFS()).toBeTruthy()
|
||||
expect(checkRenamedFS()).toBeFalsy()
|
||||
})
|
||||
|
||||
await test.step('Rename the file', async () => {
|
||||
await fileToRename.click({ button: 'right' })
|
||||
await renameMenuItem.click()
|
||||
await expect(renameInput).toBeVisible()
|
||||
await renameInput.fill(newFileName)
|
||||
await page.keyboard.press('Enter')
|
||||
})
|
||||
|
||||
await test.step('Verify the file is renamed', async () => {
|
||||
await expect(fileToRename).not.toBeAttached()
|
||||
await expect(renamedFile).toBeVisible()
|
||||
expect(checkUnRenamedFS()).toBeFalsy()
|
||||
expect(checkRenamedFS()).toBeTruthy()
|
||||
})
|
||||
|
||||
await test.step('Verify we have not navigated', async () => {
|
||||
await expect(projectMenuButton).toContainText('main.kcl')
|
||||
await expect(projectMenuButton).not.toContainText(
|
||||
newFileName + FILE_EXT
|
||||
)
|
||||
await expect(projectMenuButton).not.toContainText('fileToRename.kcl')
|
||||
|
||||
const url = page.url()
|
||||
expect(url).toContain('main.kcl')
|
||||
expect(url).not.toContain(newFileName)
|
||||
expect(url).not.toContain('fileToRename.kcl')
|
||||
|
||||
await u.openKclCodePanel()
|
||||
await expect(codeLocator).toContainText('fillet(')
|
||||
})
|
||||
|
||||
await electronApp.close()
|
||||
}
|
||||
)
|
||||
|
||||
test(
|
||||
`A folder you're not inside`,
|
||||
{ tag: '@electron' },
|
||||
async ({ browser: _ }, testInfo) => {
|
||||
const { electronApp, page, dir } = await setupElectron({
|
||||
testInfo,
|
||||
folderSetupFn: async (dir) => {
|
||||
await fsp.mkdir(join(dir, 'Test Project'), { recursive: true })
|
||||
await fsp.mkdir(join(dir, 'Test Project', 'folderToRename'), {
|
||||
recursive: true,
|
||||
})
|
||||
await fsp.copyFile(
|
||||
executorInputPath('basic_fillet_cube_end.kcl'),
|
||||
join(dir, 'Test Project', 'main.kcl')
|
||||
)
|
||||
await fsp.copyFile(
|
||||
executorInputPath('cylinder.kcl'),
|
||||
join(dir, 'Test Project', 'folderToRename', 'someFileWithin.kcl')
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
const u = await getUtils(page)
|
||||
await page.setViewportSize({ width: 1200, height: 500 })
|
||||
page.on('console', console.log)
|
||||
|
||||
// Constants and locators
|
||||
const projectLink = page.getByText('Test Project')
|
||||
const projectMenuButton = page.getByTestId('project-sidebar-toggle')
|
||||
const folderToRename = page.getByRole('button', {
|
||||
name: 'folderToRename',
|
||||
})
|
||||
const renamedFolder = page.getByRole('button', { name: 'newFolderName' })
|
||||
const renameMenuItem = page.getByRole('button', { name: 'Rename' })
|
||||
const originalFolderName = 'folderToRename'
|
||||
const renameInput = page.getByPlaceholder(originalFolderName)
|
||||
const newFolderName = 'newFolderName'
|
||||
const checkUnRenamedFolderFS = () => {
|
||||
const folderPath = join(dir, 'Test Project', originalFolderName)
|
||||
return fs.existsSync(folderPath)
|
||||
}
|
||||
const checkRenamedFolderFS = () => {
|
||||
const folderPath = join(dir, 'Test Project', newFolderName)
|
||||
return fs.existsSync(folderPath)
|
||||
}
|
||||
|
||||
await test.step('Open project and file pane', async () => {
|
||||
await expect(projectLink).toBeVisible()
|
||||
await projectLink.click()
|
||||
await expect(projectMenuButton).toBeVisible()
|
||||
await expect(projectMenuButton).toContainText('main.kcl')
|
||||
|
||||
const url = page.url()
|
||||
expect(url).toContain('main.kcl')
|
||||
expect(url).not.toContain('folderToRename')
|
||||
|
||||
await u.openFilePanel()
|
||||
await expect(folderToRename).toBeVisible()
|
||||
expect(checkUnRenamedFolderFS()).toBeTruthy()
|
||||
expect(checkRenamedFolderFS()).toBeFalsy()
|
||||
})
|
||||
|
||||
await test.step('Rename the folder', async () => {
|
||||
await folderToRename.click({ button: 'right' })
|
||||
await expect(renameMenuItem).toBeVisible()
|
||||
await renameMenuItem.click()
|
||||
await expect(renameInput).toBeVisible()
|
||||
await renameInput.fill(newFolderName)
|
||||
await page.keyboard.press('Enter')
|
||||
})
|
||||
|
||||
await test.step('Verify the folder is renamed, and no navigation occurred', async () => {
|
||||
const url = page.url()
|
||||
expect(url).toContain('main.kcl')
|
||||
expect(url).not.toContain('folderToRename')
|
||||
|
||||
await expect(projectMenuButton).toContainText('main.kcl')
|
||||
await expect(renamedFolder).toBeVisible()
|
||||
await expect(folderToRename).not.toBeAttached()
|
||||
expect(checkUnRenamedFolderFS()).toBeFalsy()
|
||||
expect(checkRenamedFolderFS()).toBeTruthy()
|
||||
})
|
||||
|
||||
await electronApp.close()
|
||||
}
|
||||
)
|
||||
|
||||
test(
|
||||
`A folder you are inside`,
|
||||
{ tag: '@electron' },
|
||||
async ({ browser: _ }, testInfo) => {
|
||||
const { electronApp, page, dir } = await setupElectron({
|
||||
testInfo,
|
||||
folderSetupFn: async (dir) => {
|
||||
await fsp.mkdir(join(dir, 'Test Project'), { recursive: true })
|
||||
await fsp.mkdir(join(dir, 'Test Project', 'folderToRename'), {
|
||||
recursive: true,
|
||||
})
|
||||
await fsp.copyFile(
|
||||
executorInputPath('basic_fillet_cube_end.kcl'),
|
||||
join(dir, 'Test Project', 'main.kcl')
|
||||
)
|
||||
await fsp.copyFile(
|
||||
executorInputPath('cylinder.kcl'),
|
||||
join(dir, 'Test Project', 'folderToRename', 'someFileWithin.kcl')
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
const u = await getUtils(page)
|
||||
await page.setViewportSize({ width: 1200, height: 500 })
|
||||
page.on('console', console.log)
|
||||
|
||||
// Constants and locators
|
||||
const projectLink = page.getByText('Test Project')
|
||||
const projectMenuButton = page.getByTestId('project-sidebar-toggle')
|
||||
const folderToRename = page.getByRole('button', {
|
||||
name: 'folderToRename',
|
||||
})
|
||||
const renamedFolder = page.getByRole('button', { name: 'newFolderName' })
|
||||
const fileWithinFolder = page.getByRole('listitem').filter({
|
||||
has: page.getByRole('button', { name: 'someFileWithin.kcl' }),
|
||||
})
|
||||
const renameMenuItem = page.getByRole('button', { name: 'Rename' })
|
||||
const originalFolderName = 'folderToRename'
|
||||
const renameInput = page.getByPlaceholder(originalFolderName)
|
||||
const newFolderName = 'newFolderName'
|
||||
const checkUnRenamedFolderFS = () => {
|
||||
const folderPath = join(dir, 'Test Project', originalFolderName)
|
||||
return fs.existsSync(folderPath)
|
||||
}
|
||||
const checkRenamedFolderFS = () => {
|
||||
const folderPath = join(dir, 'Test Project', newFolderName)
|
||||
return fs.existsSync(folderPath)
|
||||
}
|
||||
|
||||
await test.step('Open project and navigate into folder', async () => {
|
||||
await expect(projectLink).toBeVisible()
|
||||
await projectLink.click()
|
||||
await expect(projectMenuButton).toBeVisible()
|
||||
await expect(projectMenuButton).toContainText('main.kcl')
|
||||
|
||||
const url = page.url()
|
||||
expect(url).toContain('main.kcl')
|
||||
expect(url).not.toContain('folderToRename')
|
||||
|
||||
await u.openFilePanel()
|
||||
await expect(folderToRename).toBeVisible()
|
||||
await folderToRename.click()
|
||||
await expect(fileWithinFolder).toBeVisible()
|
||||
await fileWithinFolder.click()
|
||||
|
||||
await expect(projectMenuButton).toContainText('someFileWithin.kcl')
|
||||
const newUrl = page.url()
|
||||
expect(newUrl).toContain('folderToRename')
|
||||
expect(newUrl).toContain('someFileWithin.kcl')
|
||||
expect(newUrl).not.toContain('main.kcl')
|
||||
expect(checkUnRenamedFolderFS()).toBeTruthy()
|
||||
expect(checkRenamedFolderFS()).toBeFalsy()
|
||||
})
|
||||
|
||||
await test.step('Rename the folder', async () => {
|
||||
await page.waitForTimeout(60000)
|
||||
await folderToRename.click({ button: 'right' })
|
||||
await expect(renameMenuItem).toBeVisible()
|
||||
await renameMenuItem.click()
|
||||
await expect(renameInput).toBeVisible()
|
||||
await renameInput.fill(newFolderName)
|
||||
await page.keyboard.press('Enter')
|
||||
})
|
||||
|
||||
await test.step('Verify the folder is renamed, and navigated to new path', async () => {
|
||||
const urlSnippet = encodeURIComponent(
|
||||
join(newFolderName, 'someFileWithin.kcl')
|
||||
)
|
||||
await page.waitForURL(new RegExp(urlSnippet))
|
||||
await expect(projectMenuButton).toContainText('someFileWithin.kcl')
|
||||
await expect(renamedFolder).toBeVisible()
|
||||
await expect(folderToRename).not.toBeAttached()
|
||||
|
||||
// URL is synchronous, so we check the other stuff first
|
||||
const url = page.url()
|
||||
expect(url).not.toContain('main.kcl')
|
||||
expect(url).toContain(newFolderName)
|
||||
expect(url).toContain('someFileWithin.kcl')
|
||||
expect(checkUnRenamedFolderFS()).toBeFalsy()
|
||||
expect(checkRenamedFolderFS()).toBeTruthy()
|
||||
})
|
||||
|
||||
await electronApp.close()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
test.describe('Deleting items from the file pane', () => {
|
||||
test(
|
||||
`delete file when main.kcl exists, navigate to main.kcl`,
|
||||
{ tag: '@electron' },
|
||||
async ({ browserName }, testInfo) => {
|
||||
const { electronApp, page } = await setupElectron({
|
||||
testInfo,
|
||||
folderSetupFn: async (dir) => {
|
||||
const testDir = join(dir, 'testProject')
|
||||
await fsp.mkdir(testDir, { recursive: true })
|
||||
await fsp.copyFile(
|
||||
executorInputPath('cylinder.kcl'),
|
||||
join(testDir, 'main.kcl')
|
||||
)
|
||||
await fsp.copyFile(
|
||||
executorInputPath('basic_fillet_cube_end.kcl'),
|
||||
join(testDir, 'fileToDelete.kcl')
|
||||
)
|
||||
},
|
||||
})
|
||||
const u = await getUtils(page)
|
||||
await page.setViewportSize({ width: 1200, height: 500 })
|
||||
page.on('console', console.log)
|
||||
|
||||
// Constants and locators
|
||||
const projectCard = page.getByText('testProject')
|
||||
const projectMenuButton = page.getByTestId('project-sidebar-toggle')
|
||||
const fileToDelete = page
|
||||
.getByRole('listitem')
|
||||
.filter({ has: page.getByRole('button', { name: 'fileToDelete.kcl' }) })
|
||||
const deleteMenuItem = page.getByRole('button', { name: 'Delete' })
|
||||
const deleteConfirmation = page.getByTestId('delete-confirmation')
|
||||
|
||||
await test.step('Open project and navigate to fileToDelete.kcl', async () => {
|
||||
await projectCard.click()
|
||||
await u.waitForPageLoad()
|
||||
await u.openFilePanel()
|
||||
|
||||
await fileToDelete.click()
|
||||
await u.waitForPageLoad()
|
||||
await u.openKclCodePanel()
|
||||
await expect(u.codeLocator).toContainText('getOppositeEdge(thing)')
|
||||
await u.closeKclCodePanel()
|
||||
})
|
||||
|
||||
await test.step('Delete fileToDelete.kcl', async () => {
|
||||
await fileToDelete.click({ button: 'right' })
|
||||
await expect(deleteMenuItem).toBeVisible()
|
||||
await deleteMenuItem.click()
|
||||
await expect(deleteConfirmation).toBeVisible()
|
||||
await deleteConfirmation.click()
|
||||
})
|
||||
|
||||
await test.step('Check deletion and navigation', async () => {
|
||||
await u.waitForPageLoad()
|
||||
await expect(fileToDelete).not.toBeVisible()
|
||||
await u.closeFilePanel()
|
||||
await u.openKclCodePanel()
|
||||
await expect(u.codeLocator).toContainText('circle(')
|
||||
await expect(projectMenuButton).toContainText('main.kcl')
|
||||
})
|
||||
|
||||
await electronApp.close()
|
||||
}
|
||||
)
|
||||
|
||||
test.fixme(
|
||||
'TODO - delete file we have open when main.kcl does not exist',
|
||||
async () => {}
|
||||
)
|
||||
|
||||
test(
|
||||
`Delete folder we are not in, don't navigate`,
|
||||
{ tag: '@electron' },
|
||||
async ({ browserName }, testInfo) => {
|
||||
const { electronApp, page } = await setupElectron({
|
||||
testInfo,
|
||||
folderSetupFn: async (dir) => {
|
||||
await fsp.mkdir(join(dir, 'Test Project'), { recursive: true })
|
||||
await fsp.mkdir(join(dir, 'Test Project', 'folderToDelete'), {
|
||||
recursive: true,
|
||||
})
|
||||
await fsp.copyFile(
|
||||
executorInputPath('basic_fillet_cube_end.kcl'),
|
||||
join(dir, 'Test Project', 'main.kcl')
|
||||
)
|
||||
await fsp.copyFile(
|
||||
executorInputPath('cylinder.kcl'),
|
||||
join(dir, 'Test Project', 'folderToDelete', 'someFileWithin.kcl')
|
||||
)
|
||||
},
|
||||
})
|
||||
const u = await getUtils(page)
|
||||
await page.setViewportSize({ width: 1200, height: 500 })
|
||||
page.on('console', console.log)
|
||||
|
||||
// Constants and locators
|
||||
const projectCard = page.getByText('Test Project')
|
||||
const projectMenuButton = page.getByTestId('project-sidebar-toggle')
|
||||
const folderToDelete = page.getByRole('button', {
|
||||
name: 'folderToDelete',
|
||||
})
|
||||
const deleteMenuItem = page.getByRole('button', { name: 'Delete' })
|
||||
const deleteConfirmation = page.getByTestId('delete-confirmation')
|
||||
|
||||
await test.step('Open project and open project pane', async () => {
|
||||
await projectCard.click()
|
||||
await u.waitForPageLoad()
|
||||
await expect(projectMenuButton).toContainText('main.kcl')
|
||||
await u.closeKclCodePanel()
|
||||
await u.openFilePanel()
|
||||
})
|
||||
|
||||
await test.step('Delete folderToDelete', async () => {
|
||||
await folderToDelete.click({ button: 'right' })
|
||||
await expect(deleteMenuItem).toBeVisible()
|
||||
await deleteMenuItem.click()
|
||||
await expect(deleteConfirmation).toBeVisible()
|
||||
await deleteConfirmation.click()
|
||||
})
|
||||
|
||||
await test.step('Check deletion and no navigation', async () => {
|
||||
await expect(folderToDelete).not.toBeAttached()
|
||||
await expect(projectMenuButton).toContainText('main.kcl')
|
||||
})
|
||||
|
||||
await electronApp.close()
|
||||
}
|
||||
)
|
||||
|
||||
test(
|
||||
`Delete folder we are in, navigate to main.kcl`,
|
||||
{ tag: '@electron' },
|
||||
async ({ browserName }, testInfo) => {
|
||||
const { electronApp, page } = await setupElectron({
|
||||
testInfo,
|
||||
folderSetupFn: async (dir) => {
|
||||
await fsp.mkdir(join(dir, 'Test Project'), { recursive: true })
|
||||
await fsp.mkdir(join(dir, 'Test Project', 'folderToDelete'), {
|
||||
recursive: true,
|
||||
})
|
||||
await fsp.copyFile(
|
||||
executorInputPath('basic_fillet_cube_end.kcl'),
|
||||
join(dir, 'Test Project', 'main.kcl')
|
||||
)
|
||||
await fsp.copyFile(
|
||||
executorInputPath('cylinder.kcl'),
|
||||
join(dir, 'Test Project', 'folderToDelete', 'someFileWithin.kcl')
|
||||
)
|
||||
},
|
||||
})
|
||||
const u = await getUtils(page)
|
||||
await page.setViewportSize({ width: 1200, height: 500 })
|
||||
page.on('console', console.log)
|
||||
|
||||
// Constants and locators
|
||||
const projectCard = page.getByText('Test Project')
|
||||
const projectMenuButton = page.getByTestId('project-sidebar-toggle')
|
||||
const folderToDelete = page.getByRole('button', {
|
||||
name: 'folderToDelete',
|
||||
})
|
||||
const fileWithinFolder = page.getByRole('listitem').filter({
|
||||
has: page.getByRole('button', { name: 'someFileWithin.kcl' }),
|
||||
})
|
||||
const deleteMenuItem = page.getByRole('button', { name: 'Delete' })
|
||||
const deleteConfirmation = page.getByTestId('delete-confirmation')
|
||||
|
||||
await test.step('Open project and navigate into folderToDelete', async () => {
|
||||
await projectCard.click()
|
||||
await u.waitForPageLoad()
|
||||
await expect(projectMenuButton).toContainText('main.kcl')
|
||||
await u.closeKclCodePanel()
|
||||
await u.openFilePanel()
|
||||
|
||||
await folderToDelete.click()
|
||||
await expect(fileWithinFolder).toBeVisible()
|
||||
await fileWithinFolder.click()
|
||||
await expect(projectMenuButton).toContainText('someFileWithin.kcl')
|
||||
})
|
||||
|
||||
await test.step('Delete folderToDelete', async () => {
|
||||
await folderToDelete.click({ button: 'right' })
|
||||
await expect(deleteMenuItem).toBeVisible()
|
||||
await deleteMenuItem.click()
|
||||
await expect(deleteConfirmation).toBeVisible()
|
||||
await deleteConfirmation.click()
|
||||
})
|
||||
|
||||
await test.step('Check deletion and navigation to main.kcl', async () => {
|
||||
await expect(folderToDelete).not.toBeAttached()
|
||||
await expect(fileWithinFolder).not.toBeAttached()
|
||||
await expect(projectMenuButton).toContainText('main.kcl')
|
||||
})
|
||||
|
||||
await electronApp.close()
|
||||
}
|
||||
)
|
||||
|
||||
test.fixme('TODO - delete folder we are in, with no main.kcl', async () => {})
|
||||
})
|
||||
|
270
e2e/playwright/lib/console-error-whitelist.ts
Normal file
@ -0,0 +1,270 @@
|
||||
export const isErrorWhitelisted = (exception: Error) => {
|
||||
// due to the way webkit/Google Chrome report errors, it was necessary
|
||||
// to whitelist similar errors separately for each project
|
||||
let whitelist: {
|
||||
name: string
|
||||
message: string
|
||||
stack: string
|
||||
foundInSpec: string
|
||||
project: 'webkit' | 'Google Chrome'
|
||||
}[] = [
|
||||
{
|
||||
name: '',
|
||||
message: 'undefined',
|
||||
stack: '',
|
||||
foundInSpec: `e2e/playwright/sketch-tests.spec.ts Existing sketch with bad code delete user's code`,
|
||||
project: 'Google Chrome',
|
||||
},
|
||||
{
|
||||
name: '"{"kind"',
|
||||
message:
|
||||
'"engine","sourceRanges":[[0,0]],"msg":"Failed to get string from response from engine: `JsValue(undefined)`"}"',
|
||||
stack: '',
|
||||
foundInSpec: 'e2e/playwright/testing-settings.spec.ts',
|
||||
project: 'Google Chrome',
|
||||
},
|
||||
{
|
||||
name: '',
|
||||
message: 'false',
|
||||
stack: '',
|
||||
foundInSpec: 'e2e/playwright/testing-segment-overlays.spec.ts',
|
||||
project: 'Google Chrome',
|
||||
},
|
||||
{
|
||||
name: '{"kind"',
|
||||
// eslint-disable-next-line no-useless-escape
|
||||
message: 'no connection to send on',
|
||||
stack: '',
|
||||
foundInSpec: 'e2e/playwright/various.spec.ts',
|
||||
project: 'Google Chrome',
|
||||
},
|
||||
{
|
||||
name: '',
|
||||
message: 'sketchGroup not found',
|
||||
stack: '',
|
||||
foundInSpec:
|
||||
'e2e/playwright/testing-selections.spec.ts Deselecting line tool should mean nothing happens on click',
|
||||
project: 'Google Chrome',
|
||||
},
|
||||
{
|
||||
name: 'engine error',
|
||||
message:
|
||||
'[{"error_code":"bad_request","message":"Cannot set the camera position with these values"}]',
|
||||
stack: '',
|
||||
foundInSpec:
|
||||
'e2e/playwright/can-create-sketches-on-all-planes-and-their-back-sides.spec.ts XY',
|
||||
project: 'Google Chrome',
|
||||
},
|
||||
{
|
||||
name: '',
|
||||
message: 'no connection to send on',
|
||||
stack: '',
|
||||
foundInSpec:
|
||||
'e2e/playwright/can-create-sketches-on-all-planes-and-their-back-sides.spec.ts XY',
|
||||
project: 'Google Chrome',
|
||||
},
|
||||
{
|
||||
name: 'RangeError',
|
||||
message: 'Position 160 is out of range for changeset of length 0',
|
||||
stack: `RangeError: Position 160 is out of range for changeset of length 0
|
||||
at _ChangeSet.mapPos (http://localhost:3000/node_modules/.vite/deps/chunk-3BHLKIA4.js?v=412eae63:756:13)
|
||||
at findSharedChunks (http://localhost:3000/node_modules/.vite/deps/chunk-3BHLKIA4.js?v=412eae63:3045:49)
|
||||
at _RangeSet.compare (http://localhost:3000/node_modules/.vite/deps/chunk-3BHLKIA4.js?v=412eae63:2840:24)
|
||||
at findChangedDeco (http://localhost:3000/node_modules/.vite/deps/chunk-IZYF444B.js?v=412eae63:3320:12)
|
||||
at DocView.update (http://localhost:3000/node_modules/.vite/deps/chunk-IZYF444B.js?v=412eae63:2774:20)
|
||||
at _EditorView.update (http://localhost:3000/node_modules/.vite/deps/chunk-IZYF444B.js?v=412eae63:7056:30)
|
||||
at DOMObserver.flush (http://localhost:3000/node_modules/.vite/deps/chunk-IZYF444B.js?v=412eae63:6621:17)
|
||||
at MutationObserver.<anonymous> (http://localhost:3000/node_modules/.vite/deps/chunk-IZYF444B.js?v=412eae63:6322:14)`,
|
||||
foundInSpec: 'e2e/playwright/editor-tests.spec.ts fold gutters work',
|
||||
project: 'Google Chrome',
|
||||
},
|
||||
{
|
||||
name: 'RangeError',
|
||||
message: 'Selection points outside of document',
|
||||
stack: `RangeError: Selection points outside of document
|
||||
+ at checkSelection (http://localhost:3000/node_modules/.vite/deps/chunk-3BHLKIA4.js?v=412eae63:1453:13)
|
||||
+ at new _Transaction (http://localhost:3000/node_modules/.vite/deps/chunk-3BHLKIA4.js?v=412eae63:2014:7)
|
||||
+ at _Transaction.create (http://localhost:3000/node_modules/.vite/deps/chunk-3BHLKIA4.js?v=412eae63:2022:12)
|
||||
+ at resolveTransaction (http://localhost:3000/node_modules/.vite/deps/chunk-3BHLKIA4.js?v=412eae63:2155:24)
|
||||
+ at _EditorState.update (http://localhost:3000/node_modules/.vite/deps/chunk-3BHLKIA4.js?v=412eae63:2281:12)
|
||||
+ at _EditorView.dispatch (http://localhost:3000/node_modules/.vite/deps/chunk-IZYF444B.js?v=412eae63:6988:148)
|
||||
+ at EditorManager.selectRange (http://localhost:3000/src/editor/manager.ts:182:22)
|
||||
+ at AST extrude (http://localhost:3000/src/machines/modelingMachine.ts:828:25)`,
|
||||
foundInSpec: 'e2e/playwright/editor-tests.spec.ts',
|
||||
project: 'Google Chrome',
|
||||
},
|
||||
{
|
||||
name: 'Unhandled Promise Rejection',
|
||||
message: "TypeError: null is not an object (evaluating 'sg.value')",
|
||||
stack: `Unhandled Promise Rejection: TypeError: null is not an object (evaluating 'sg.value')
|
||||
at unknown (http://localhost:3000/src/clientSideScene/sceneEntities.ts:466:23)
|
||||
at unknown (http://localhost:3000/src/clientSideScene/sceneEntities.ts:454:32)
|
||||
at set up draft line without teardown (http://localhost:3000/src/machines/modelingMachine.ts:983:47)
|
||||
at unknown (http://localhost:3000/node_modules/.vite/deps/chunk-6FRHHHSJ.js?v=0de2e74f:1877:24)
|
||||
at handleAction (http://localhost:3000/node_modules/.vite/deps/chunk-6FRHHHSJ.js?v=0de2e74f:1064:26)
|
||||
at processBlock (http://localhost:3000/node_modules/.vite/deps/chunk-6FRHHHSJ.js?v=0de2e74f:1087:36)
|
||||
at map ([native code]:0:0)
|
||||
at resolveActions (http://localhost:3000/node_modules/.vite/deps/chunk-6FRHHHSJ.js?v=0de2e74f:1109:49)
|
||||
at unknown (http://localhost:3000/node_modules/.vite/deps/chunk-6FRHHHSJ.js?v=0de2e74f:3639:37)
|
||||
at provide (http://localhost:3000/node_modules/.vite/deps/chunk-6FRHHHSJ.js?v=0de2e74f:1117:18)
|
||||
at unknown (http://localhost:3000/node_modules/.vite/deps/chunk-6FRHHHSJ.js?v=0de2e74f:2452:30)
|
||||
at unknown (http://localhost:3000/node_modules/.vite/deps/chunk-6FRHHHSJ.js?v=0de2e74f:1831:43)
|
||||
at unknown (http://localhost:3000/node_modules/.vite/deps/chunk-6FRHHHSJ.js?v=0de2e74f:1659:17)
|
||||
at unknown (http://localhost:3000/node_modules/.vite/deps/chunk-6FRHHHSJ.js?v=0de2e74f:1643:19)
|
||||
at unknown (http://localhost:3000/node_modules/.vite/deps/chunk-6FRHHHSJ.js?v=0de2e74f:1829:33)
|
||||
at unknown (http://localhost:3000/src/clientSideScene/sceneEntities.ts:263:19)`,
|
||||
foundInSpec: `e2e/playwright/testing-camera-movement.spec.ts Zoom should be consistent when exiting or entering sketches`,
|
||||
project: 'webkit',
|
||||
},
|
||||
{
|
||||
name: 'Unhandled Promise Rejection',
|
||||
message: 'false',
|
||||
stack: `Unhandled Promise Rejection: false
|
||||
at unknown (http://localhost:3000/src/clientSideScene/ClientSideSceneComp.tsx:455:78)`,
|
||||
foundInSpec: `e2e/playwright/testing-segment-overlays.spec.ts line-[tagOutsideSketch]`,
|
||||
project: 'webkit',
|
||||
},
|
||||
{
|
||||
name: 'Unhandled Promise Rejection',
|
||||
message: `TypeError: null is not an object (evaluating 'programMemory.get(variableDeclarationName).value')`,
|
||||
stack: ` + stack:Unhandled Promise Rejection: TypeError: null is not an object (evaluating 'programMemory.get(variableDeclarationName).value')
|
||||
+ at unknown (http://localhost:3000/src/machines/modelingMachine.ts:911:49)`,
|
||||
foundInSpec: `e2e/playwright/can-create-sketches-on-all-planes-and-their-back-sides.spec.ts`,
|
||||
project: 'webkit',
|
||||
},
|
||||
{
|
||||
name: 'Unhandled Promise Rejection',
|
||||
message: `null is not an object (evaluating 'programMemory.get(variableDeclarationName).value')`,
|
||||
stack: `Unhandled Promise Rejection: TypeError: null is not an object (evaluating 'programMemory.get(variableDeclarationName).value')
|
||||
at unknown (http://localhost:3000/src/machines/modelingMachine.ts:911:49)`,
|
||||
foundInSpec: `e2e/playwright/testing-camera-movement.spec.ts Zoom should be consistent when exiting or entering sketches`,
|
||||
project: 'webkit',
|
||||
},
|
||||
{
|
||||
name: 'TypeError',
|
||||
message: `null is not an object (evaluating 'gl.getShaderPrecisionFormat(gl.VERTEX_SHADER, gl.HIGH_FLOAT).precision')`,
|
||||
stack: `TypeError: null is not an object (evaluating 'gl.getShaderPrecisionFormat(gl.VERTEX_SHADER, gl.HIGH_FLOAT).precision')
|
||||
at getMaxPrecision (http://localhost:3000/node_modules/.vite/deps/chunk-DEEFU7IG.js?v=d328572b:9557:71)
|
||||
at WebGLCapabilities (http://localhost:3000/node_modules/.vite/deps/chunk-DEEFU7IG.js?v=d328572b:9570:39)
|
||||
at initGLContext (http://localhost:3000/node_modules/.vite/deps/chunk-DEEFU7IG.js?v=d328572b:16993:43)
|
||||
at WebGLRenderer (http://localhost:3000/node_modules/.vite/deps/chunk-DEEFU7IG.js?v=d328572b:17024:18)
|
||||
at SceneInfra (http://localhost:3000/src/clientSideScene/sceneInfra.ts:185:38)
|
||||
at module code (http://localhost:3000/src/lib/singletons.ts:14:41)`,
|
||||
foundInSpec: `e2e/playwright/testing-segment-overlays.spec.ts angledLineToX`,
|
||||
project: 'webkit',
|
||||
},
|
||||
{
|
||||
name: 'Unhandled Promise Rejection',
|
||||
message:
|
||||
'{"kind":"engine","sourceRanges":[[0,0]],"msg":"Failed to get string from response from engine: `JsValue(undefined)`"}',
|
||||
stack: `Unhandled Promise Rejection: {"kind":"engine","sourceRanges":[[0,0]],"msg":"Failed to get string from response from engine: \`JsValue(undefined)\`"}
|
||||
at unknown (http://localhost:3000/src/lang/std/engineConnection.ts:1245:26)`,
|
||||
foundInSpec:
|
||||
'e2e/playwright/onboarding-tests.spec.ts Click through each onboarding step',
|
||||
project: 'webkit',
|
||||
},
|
||||
{
|
||||
name: 'Unhandled Promise Rejection',
|
||||
message: 'undefined',
|
||||
stack: '',
|
||||
foundInSpec: `e2e/playwright/sketch-tests.spec.ts Existing sketch with bad code delete user's code`,
|
||||
project: 'webkit',
|
||||
},
|
||||
{
|
||||
name: 'Fetch API cannot load https',
|
||||
message: '/api.dev.zoo.dev/logout due to access control checks.',
|
||||
stack: `Fetch API cannot load https://api.dev.zoo.dev/logout due to access control checks.
|
||||
at goToSignInPage (http://localhost:3000/src/components/SettingsAuthProvider.tsx:229:15)
|
||||
at unknown (http://localhost:3000/node_modules/.vite/deps/chunk-6FRHHHSJ.js?v=d328572b:1877:24)
|
||||
at handleAction (http://localhost:3000/node_modules/.vite/deps/chunk-6FRHHHSJ.js?v=d328572b:1064:26)
|
||||
at processBlock (http://localhost:3000/node_modules/.vite/deps/chunk-6FRHHHSJ.js?v=d328572b:1087:36)
|
||||
at map (:1:11)
|
||||
at resolveActions (http://localhost:3000/node_modules/.vite/deps/chunk-6FRHHHSJ.js?v=d328572b:1109:49)
|
||||
at unknown (http://localhost:3000/node_modules/.vite/deps/chunk-6FRHHHSJ.js?v=d328572b:3639:37)
|
||||
at provide (http://localhost:3000/node_modules/.vite/deps/chunk-6FRHHHSJ.js?v=d328572b:1117:18)
|
||||
at unknown (http://localhost:3000/node_modules/.vite/deps/chunk-6FRHHHSJ.js?v=d328572b:2452:30)
|
||||
at unknown (http://localhost:3000/node_modules/.vite/deps/chunk-6FRHHHSJ.js?v=d328572b:1831:43)
|
||||
at unknown (http://localhost:3000/node_modules/.vite/deps/chunk-6FRHHHSJ.js?v=d328572b:1659:17)
|
||||
at unknown (http://localhost:3000/node_modules/.vite/deps/chunk-6FRHHHSJ.js?v=d328572b:1643:19)
|
||||
at unknown (http://localhost:3000/node_modules/.vite/deps/chunk-6FRHHHSJ.js?v=d328572b:1829:33)
|
||||
at unknown (http://localhost:3000/node_modules/.vite/deps/chunk-6FRHHHSJ.js?v=d328572b:2601:23)`,
|
||||
foundInSpec:
|
||||
'e2e/playwright/testing-selections.spec.ts Solids should be select and deletable',
|
||||
project: 'webkit',
|
||||
},
|
||||
{
|
||||
name: 'Unhandled Promise Rejection',
|
||||
message: 'ReferenceError: Cannot access uninitialized variable.',
|
||||
stack: `Unhandled Promise Rejection: ReferenceError: Cannot access uninitialized variable.
|
||||
at setDiagnosticsForCurrentErrors (http://localhost:3000/src/lang/KclSingleton.ts:90:18)
|
||||
at kclErrors (http://localhost:3000/src/lang/KclSingleton.ts:82:40)
|
||||
at safeParse (http://localhost:3000/src/lang/KclSingleton.ts:150:9)
|
||||
at unknown (http://localhost:3000/src/lang/KclSingleton.ts:113:32)`,
|
||||
foundInSpec:
|
||||
'e2e/playwright/testing-segment-overlays.spec.ts angledLineToX',
|
||||
project: 'webkit',
|
||||
},
|
||||
{
|
||||
name: 'Unhandled Promise Rejection',
|
||||
message: 'sketchGroup not found',
|
||||
stack: `Unhandled Promise Rejection: sketchGroup not found
|
||||
at unknown (http://localhost:3000/src/machines/modelingMachine.ts:911:49)`,
|
||||
foundInSpec:
|
||||
'e2e/playwright/testing-selections.spec.ts Deselecting line tool should mean nothing happens on click',
|
||||
project: 'webkit',
|
||||
},
|
||||
{
|
||||
name: 'Unhandled Promise Rejection',
|
||||
message:
|
||||
'engine error: [{"error_code":"bad_request","message":"Cannot set the camera position with these values"}]',
|
||||
stack:
|
||||
'Unhandled Promise Rejection: engine error: [{"error_code":"bad_request","message":"Cannot set the camera position with these values"}]',
|
||||
foundInSpec:
|
||||
'e2e/playwright/testing-camera-movement.spec.ts Zoom should be consistent when exiting or entering sketches',
|
||||
project: 'webkit',
|
||||
},
|
||||
{
|
||||
name: 'SecurityError',
|
||||
stack: `SecurityError: Failed to read the 'localStorage' property from 'Window': Access is denied for this document.
|
||||
at <anonymous>:13:5
|
||||
at <anonymous>:18:5
|
||||
at <anonymous>:19:7`,
|
||||
message: `Failed to read the 'localStorage' property from 'Window': Access is denied for this document.`,
|
||||
project: 'Google Chrome',
|
||||
foundInSpec: 'e2e/playwright/basic-sketch.spec.ts',
|
||||
},
|
||||
{
|
||||
name: ' - internal_engine',
|
||||
stack: `
|
||||
`,
|
||||
message: `Nothing to export`,
|
||||
project: 'Google Chrome',
|
||||
foundInSpec: 'e2e/playwright/regression-tests.spec.ts',
|
||||
},
|
||||
{
|
||||
name: 'SyntaxError',
|
||||
stack: `SyntaxError: Unexpected end of JSON input
|
||||
at crossPlatformFetch (http://localhost:3000/src/lib/crossPlatformFetch.ts:34:31)
|
||||
at async sendTelemetry (http://localhost:3000/src/lib/textToCad.ts:179:3)`,
|
||||
message: `Unexpected end of JSON input`,
|
||||
project: 'Google Chrome',
|
||||
foundInSpec: 'e2e/playwright/text-to-cad-tests.spec.ts',
|
||||
},
|
||||
{
|
||||
name: '{"kind"',
|
||||
stack: ``,
|
||||
message: `engine","sourceRanges":[[0,0]],"msg":"Failed to wait for promise from engine: JsValue(\\"Force interrupt, executionIsStale, new AST requested\\")"}`,
|
||||
project: 'Google Chrome',
|
||||
foundInSpec: 'e2e/playwright/testing-settings.spec.ts',
|
||||
},
|
||||
]
|
||||
|
||||
const cleanString = (str: string) => str.replace(/[`"]/g, '')
|
||||
const foundItem = whitelist.find(
|
||||
(item) =>
|
||||
cleanString(exception.name) === cleanString(item.name) &&
|
||||
cleanString(exception.message).includes(cleanString(item.message))
|
||||
)
|
||||
|
||||
return foundItem !== undefined
|
||||
}
|
@ -38,7 +38,7 @@ test(
|
||||
await expect(page.getByText(notFoundText).first()).not.toBeVisible()
|
||||
|
||||
// Find the make button
|
||||
const makeButton = page.getByRole('button', { name: 'Make' })
|
||||
const makeButton = page.getByRole('button', { name: 'Make part' })
|
||||
// Make sure the button is visible but disabled
|
||||
await expect(makeButton).toBeVisible()
|
||||
await expect(makeButton).toBeDisabled()
|
||||
|
@ -12,7 +12,6 @@ import {
|
||||
import fsp from 'fs/promises'
|
||||
import fs from 'fs'
|
||||
import { join } from 'path'
|
||||
import { FILE_EXT } from 'lib/constants'
|
||||
|
||||
test.afterEach(async ({ page }, testInfo) => {
|
||||
await tearDown(page, testInfo)
|
||||
@ -1371,455 +1370,6 @@ test(
|
||||
}
|
||||
)
|
||||
|
||||
test.describe('Renaming in the file tree', () => {
|
||||
test(
|
||||
'A file you have open',
|
||||
{ tag: '@electron' },
|
||||
async ({ browser: _ }, testInfo) => {
|
||||
const { electronApp, page, dir } = await setupElectron({
|
||||
testInfo,
|
||||
folderSetupFn: async (dir) => {
|
||||
await fsp.mkdir(join(dir, 'Test Project'), { recursive: true })
|
||||
await fsp.copyFile(
|
||||
executorInputPath('basic_fillet_cube_end.kcl'),
|
||||
join(dir, 'Test Project', 'main.kcl')
|
||||
)
|
||||
await fsp.copyFile(
|
||||
executorInputPath('cylinder.kcl'),
|
||||
join(dir, 'Test Project', 'fileToRename.kcl')
|
||||
)
|
||||
},
|
||||
})
|
||||
const u = await getUtils(page)
|
||||
await page.setViewportSize({ width: 1200, height: 500 })
|
||||
page.on('console', console.log)
|
||||
|
||||
// Constants and locators
|
||||
const projectLink = page.getByText('Test Project')
|
||||
const projectMenuButton = page.getByTestId('project-sidebar-toggle')
|
||||
const checkUnRenamedFS = () => {
|
||||
const filePath = join(dir, 'Test Project', 'fileToRename.kcl')
|
||||
return fs.existsSync(filePath)
|
||||
}
|
||||
const newFileName = 'newFileName'
|
||||
const checkRenamedFS = () => {
|
||||
const filePath = join(dir, 'Test Project', `${newFileName}.kcl`)
|
||||
return fs.existsSync(filePath)
|
||||
}
|
||||
|
||||
const fileToRename = page
|
||||
.getByRole('listitem')
|
||||
.filter({ has: page.getByRole('button', { name: 'fileToRename.kcl' }) })
|
||||
const renamedFile = page
|
||||
.getByRole('listitem')
|
||||
.filter({ has: page.getByRole('button', { name: 'newFileName.kcl' }) })
|
||||
const renameMenuItem = page.getByRole('button', { name: 'Rename' })
|
||||
const renameInput = page.getByPlaceholder('fileToRename.kcl')
|
||||
const codeLocator = page.locator('.cm-content')
|
||||
|
||||
await test.step('Open project and file pane', async () => {
|
||||
await expect(projectLink).toBeVisible()
|
||||
await projectLink.click()
|
||||
await expect(projectMenuButton).toBeVisible()
|
||||
await expect(projectMenuButton).toContainText('main.kcl')
|
||||
|
||||
await u.openFilePanel()
|
||||
await expect(fileToRename).toBeVisible()
|
||||
expect(checkUnRenamedFS()).toBeTruthy()
|
||||
expect(checkRenamedFS()).toBeFalsy()
|
||||
await fileToRename.click()
|
||||
await expect(projectMenuButton).toContainText('fileToRename.kcl')
|
||||
await u.openKclCodePanel()
|
||||
await expect(codeLocator).toContainText('circle(')
|
||||
await u.closeKclCodePanel()
|
||||
})
|
||||
|
||||
await test.step('Rename the file', async () => {
|
||||
await fileToRename.click({ button: 'right' })
|
||||
await renameMenuItem.click()
|
||||
await expect(renameInput).toBeVisible()
|
||||
await renameInput.fill(newFileName)
|
||||
await page.keyboard.press('Enter')
|
||||
})
|
||||
|
||||
await test.step('Verify the file is renamed', async () => {
|
||||
await expect(fileToRename).not.toBeAttached()
|
||||
await expect(renamedFile).toBeVisible()
|
||||
expect(checkUnRenamedFS()).toBeFalsy()
|
||||
expect(checkRenamedFS()).toBeTruthy()
|
||||
})
|
||||
|
||||
await test.step('Verify we navigated', async () => {
|
||||
await expect(projectMenuButton).toContainText(newFileName + FILE_EXT)
|
||||
const url = page.url()
|
||||
expect(url).toContain(newFileName)
|
||||
await expect(projectMenuButton).not.toContainText('fileToRename.kcl')
|
||||
await expect(projectMenuButton).not.toContainText('main.kcl')
|
||||
expect(url).not.toContain('fileToRename.kcl')
|
||||
expect(url).not.toContain('main.kcl')
|
||||
|
||||
await u.openKclCodePanel()
|
||||
await expect(codeLocator).toContainText('circle(')
|
||||
})
|
||||
|
||||
await electronApp.close()
|
||||
}
|
||||
)
|
||||
|
||||
test(
|
||||
'A file you do not have open',
|
||||
{ tag: '@electron' },
|
||||
async ({ browser: _ }, testInfo) => {
|
||||
const { electronApp, page, dir } = await setupElectron({
|
||||
testInfo,
|
||||
folderSetupFn: async (dir) => {
|
||||
await fsp.mkdir(join(dir, 'Test Project'), { recursive: true })
|
||||
await fsp.copyFile(
|
||||
executorInputPath('basic_fillet_cube_end.kcl'),
|
||||
join(dir, 'Test Project', 'main.kcl')
|
||||
)
|
||||
await fsp.copyFile(
|
||||
executorInputPath('cylinder.kcl'),
|
||||
join(dir, 'Test Project', 'fileToRename.kcl')
|
||||
)
|
||||
},
|
||||
})
|
||||
const u = await getUtils(page)
|
||||
await page.setViewportSize({ width: 1200, height: 500 })
|
||||
page.on('console', console.log)
|
||||
|
||||
// Constants and locators
|
||||
const newFileName = 'newFileName'
|
||||
const checkUnRenamedFS = () => {
|
||||
const filePath = join(dir, 'Test Project', 'fileToRename.kcl')
|
||||
return fs.existsSync(filePath)
|
||||
}
|
||||
const checkRenamedFS = () => {
|
||||
const filePath = join(dir, 'Test Project', `${newFileName}.kcl`)
|
||||
return fs.existsSync(filePath)
|
||||
}
|
||||
const projectLink = page.getByText('Test Project')
|
||||
const projectMenuButton = page.getByTestId('project-sidebar-toggle')
|
||||
const fileToRename = page
|
||||
.getByRole('listitem')
|
||||
.filter({ has: page.getByRole('button', { name: 'fileToRename.kcl' }) })
|
||||
const renamedFile = page.getByRole('listitem').filter({
|
||||
has: page.getByRole('button', { name: newFileName + FILE_EXT }),
|
||||
})
|
||||
const renameMenuItem = page.getByRole('button', { name: 'Rename' })
|
||||
const renameInput = page.getByPlaceholder('fileToRename.kcl')
|
||||
const codeLocator = page.locator('.cm-content')
|
||||
|
||||
await test.step('Open project and file pane', async () => {
|
||||
await expect(projectLink).toBeVisible()
|
||||
await projectLink.click()
|
||||
await expect(projectMenuButton).toBeVisible()
|
||||
await expect(projectMenuButton).toContainText('main.kcl')
|
||||
|
||||
await u.openFilePanel()
|
||||
await expect(fileToRename).toBeVisible()
|
||||
expect(checkUnRenamedFS()).toBeTruthy()
|
||||
expect(checkRenamedFS()).toBeFalsy()
|
||||
})
|
||||
|
||||
await test.step('Rename the file', async () => {
|
||||
await fileToRename.click({ button: 'right' })
|
||||
await renameMenuItem.click()
|
||||
await expect(renameInput).toBeVisible()
|
||||
await renameInput.fill(newFileName)
|
||||
await page.keyboard.press('Enter')
|
||||
})
|
||||
|
||||
await test.step('Verify the file is renamed', async () => {
|
||||
await expect(fileToRename).not.toBeAttached()
|
||||
await expect(renamedFile).toBeVisible()
|
||||
expect(checkUnRenamedFS()).toBeFalsy()
|
||||
expect(checkRenamedFS()).toBeTruthy()
|
||||
})
|
||||
|
||||
await test.step('Verify we have not navigated', async () => {
|
||||
await expect(projectMenuButton).toContainText('main.kcl')
|
||||
await expect(projectMenuButton).not.toContainText(
|
||||
newFileName + FILE_EXT
|
||||
)
|
||||
await expect(projectMenuButton).not.toContainText('fileToRename.kcl')
|
||||
|
||||
const url = page.url()
|
||||
expect(url).toContain('main.kcl')
|
||||
expect(url).not.toContain(newFileName)
|
||||
expect(url).not.toContain('fileToRename.kcl')
|
||||
|
||||
await u.openKclCodePanel()
|
||||
await expect(codeLocator).toContainText('fillet(')
|
||||
})
|
||||
|
||||
await electronApp.close()
|
||||
}
|
||||
)
|
||||
|
||||
test(
|
||||
`A folder you're not inside`,
|
||||
{ tag: '@electron' },
|
||||
async ({ browser: _ }, testInfo) => {
|
||||
const { electronApp, page, dir } = await setupElectron({
|
||||
testInfo,
|
||||
folderSetupFn: async (dir) => {
|
||||
await fsp.mkdir(join(dir, 'Test Project'), { recursive: true })
|
||||
await fsp.mkdir(join(dir, 'Test Project', 'folderToRename'), {
|
||||
recursive: true,
|
||||
})
|
||||
await fsp.copyFile(
|
||||
executorInputPath('basic_fillet_cube_end.kcl'),
|
||||
join(dir, 'Test Project', 'main.kcl')
|
||||
)
|
||||
await fsp.copyFile(
|
||||
executorInputPath('cylinder.kcl'),
|
||||
join(dir, 'Test Project', 'folderToRename', 'someFileWithin.kcl')
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
const u = await getUtils(page)
|
||||
await page.setViewportSize({ width: 1200, height: 500 })
|
||||
page.on('console', console.log)
|
||||
|
||||
// Constants and locators
|
||||
const projectLink = page.getByText('Test Project')
|
||||
const projectMenuButton = page.getByTestId('project-sidebar-toggle')
|
||||
const folderToRename = page.getByRole('button', {
|
||||
name: 'folderToRename',
|
||||
})
|
||||
const renamedFolder = page.getByRole('button', { name: 'newFolderName' })
|
||||
const renameMenuItem = page.getByRole('button', { name: 'Rename' })
|
||||
const originalFolderName = 'folderToRename'
|
||||
const renameInput = page.getByPlaceholder(originalFolderName)
|
||||
const newFolderName = 'newFolderName'
|
||||
const checkUnRenamedFolderFS = () => {
|
||||
const folderPath = join(dir, 'Test Project', originalFolderName)
|
||||
return fs.existsSync(folderPath)
|
||||
}
|
||||
const checkRenamedFolderFS = () => {
|
||||
const folderPath = join(dir, 'Test Project', newFolderName)
|
||||
return fs.existsSync(folderPath)
|
||||
}
|
||||
|
||||
await test.step('Open project and file pane', async () => {
|
||||
await expect(projectLink).toBeVisible()
|
||||
await projectLink.click()
|
||||
await expect(projectMenuButton).toBeVisible()
|
||||
await expect(projectMenuButton).toContainText('main.kcl')
|
||||
|
||||
const url = page.url()
|
||||
expect(url).toContain('main.kcl')
|
||||
expect(url).not.toContain('folderToRename')
|
||||
|
||||
await u.openFilePanel()
|
||||
await expect(folderToRename).toBeVisible()
|
||||
expect(checkUnRenamedFolderFS()).toBeTruthy()
|
||||
expect(checkRenamedFolderFS()).toBeFalsy()
|
||||
})
|
||||
|
||||
await test.step('Rename the folder', async () => {
|
||||
await folderToRename.click({ button: 'right' })
|
||||
await expect(renameMenuItem).toBeVisible()
|
||||
await renameMenuItem.click()
|
||||
await expect(renameInput).toBeVisible()
|
||||
await renameInput.fill(newFolderName)
|
||||
await page.keyboard.press('Enter')
|
||||
})
|
||||
|
||||
await test.step('Verify the folder is renamed, and no navigation occurred', async () => {
|
||||
const url = page.url()
|
||||
expect(url).toContain('main.kcl')
|
||||
expect(url).not.toContain('folderToRename')
|
||||
|
||||
await expect(projectMenuButton).toContainText('main.kcl')
|
||||
await expect(renamedFolder).toBeVisible()
|
||||
await expect(folderToRename).not.toBeAttached()
|
||||
expect(checkUnRenamedFolderFS()).toBeFalsy()
|
||||
expect(checkRenamedFolderFS()).toBeTruthy()
|
||||
})
|
||||
|
||||
await electronApp.close()
|
||||
}
|
||||
)
|
||||
|
||||
test(
|
||||
`A folder you are inside`,
|
||||
{ tag: '@electron' },
|
||||
async ({ browser: _ }, testInfo) => {
|
||||
const { electronApp, page, dir } = await setupElectron({
|
||||
testInfo,
|
||||
folderSetupFn: async (dir) => {
|
||||
await fsp.mkdir(join(dir, 'Test Project'), { recursive: true })
|
||||
await fsp.mkdir(join(dir, 'Test Project', 'folderToRename'), {
|
||||
recursive: true,
|
||||
})
|
||||
await fsp.copyFile(
|
||||
executorInputPath('basic_fillet_cube_end.kcl'),
|
||||
join(dir, 'Test Project', 'main.kcl')
|
||||
)
|
||||
await fsp.copyFile(
|
||||
executorInputPath('cylinder.kcl'),
|
||||
join(dir, 'Test Project', 'folderToRename', 'someFileWithin.kcl')
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
const u = await getUtils(page)
|
||||
await page.setViewportSize({ width: 1200, height: 500 })
|
||||
page.on('console', console.log)
|
||||
|
||||
// Constants and locators
|
||||
const projectLink = page.getByText('Test Project')
|
||||
const projectMenuButton = page.getByTestId('project-sidebar-toggle')
|
||||
const folderToRename = page.getByRole('button', {
|
||||
name: 'folderToRename',
|
||||
})
|
||||
const renamedFolder = page.getByRole('button', { name: 'newFolderName' })
|
||||
const fileWithinFolder = page.getByRole('listitem').filter({
|
||||
has: page.getByRole('button', { name: 'someFileWithin.kcl' }),
|
||||
})
|
||||
const renameMenuItem = page.getByRole('button', { name: 'Rename' })
|
||||
const originalFolderName = 'folderToRename'
|
||||
const renameInput = page.getByPlaceholder(originalFolderName)
|
||||
const newFolderName = 'newFolderName'
|
||||
const checkUnRenamedFolderFS = () => {
|
||||
const folderPath = join(dir, 'Test Project', originalFolderName)
|
||||
return fs.existsSync(folderPath)
|
||||
}
|
||||
const checkRenamedFolderFS = () => {
|
||||
const folderPath = join(dir, 'Test Project', newFolderName)
|
||||
return fs.existsSync(folderPath)
|
||||
}
|
||||
|
||||
await test.step('Open project and navigate into folder', async () => {
|
||||
await expect(projectLink).toBeVisible()
|
||||
await projectLink.click()
|
||||
await expect(projectMenuButton).toBeVisible()
|
||||
await expect(projectMenuButton).toContainText('main.kcl')
|
||||
|
||||
const url = page.url()
|
||||
expect(url).toContain('main.kcl')
|
||||
expect(url).not.toContain('folderToRename')
|
||||
|
||||
await u.openFilePanel()
|
||||
await expect(folderToRename).toBeVisible()
|
||||
await folderToRename.click()
|
||||
await expect(fileWithinFolder).toBeVisible()
|
||||
await fileWithinFolder.click()
|
||||
|
||||
await expect(projectMenuButton).toContainText('someFileWithin.kcl')
|
||||
const newUrl = page.url()
|
||||
expect(newUrl).toContain('folderToRename')
|
||||
expect(newUrl).toContain('someFileWithin.kcl')
|
||||
expect(newUrl).not.toContain('main.kcl')
|
||||
expect(checkUnRenamedFolderFS()).toBeTruthy()
|
||||
expect(checkRenamedFolderFS()).toBeFalsy()
|
||||
})
|
||||
|
||||
await test.step('Rename the folder', async () => {
|
||||
await page.waitForTimeout(2000)
|
||||
await folderToRename.click({ button: 'right' })
|
||||
await expect(renameMenuItem).toBeVisible()
|
||||
await renameMenuItem.click()
|
||||
await expect(renameInput).toBeVisible()
|
||||
await renameInput.fill(newFolderName)
|
||||
await page.keyboard.press('Enter')
|
||||
})
|
||||
|
||||
await test.step('Verify the folder is renamed, and navigated to new path', async () => {
|
||||
const urlSnippet = encodeURIComponent(
|
||||
join(newFolderName, 'someFileWithin.kcl')
|
||||
)
|
||||
await page.waitForURL(new RegExp(urlSnippet))
|
||||
await expect(projectMenuButton).toContainText('someFileWithin.kcl')
|
||||
await expect(renamedFolder).toBeVisible()
|
||||
await expect(folderToRename).not.toBeAttached()
|
||||
|
||||
// URL is synchronous, so we check the other stuff first
|
||||
const url = page.url()
|
||||
expect(url).not.toContain('main.kcl')
|
||||
expect(url).toContain(newFolderName)
|
||||
expect(url).toContain('someFileWithin.kcl')
|
||||
expect(checkUnRenamedFolderFS()).toBeFalsy()
|
||||
expect(checkRenamedFolderFS()).toBeTruthy()
|
||||
})
|
||||
|
||||
await electronApp.close()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
test.describe('Deleting files from the file pane', () => {
|
||||
test(
|
||||
`when main.kcl exists, navigate to main.kcl`,
|
||||
{ tag: '@electron' },
|
||||
async ({ browserName }, testInfo) => {
|
||||
const { electronApp, page } = await setupElectron({
|
||||
testInfo,
|
||||
folderSetupFn: async (dir) => {
|
||||
const testDir = join(dir, 'testProject')
|
||||
await fsp.mkdir(testDir, { recursive: true })
|
||||
await fsp.copyFile(
|
||||
executorInputPath('cylinder.kcl'),
|
||||
join(testDir, 'main.kcl')
|
||||
)
|
||||
await fsp.copyFile(
|
||||
executorInputPath('basic_fillet_cube_end.kcl'),
|
||||
join(testDir, 'fileToDelete.kcl')
|
||||
)
|
||||
},
|
||||
})
|
||||
const u = await getUtils(page)
|
||||
await page.setViewportSize({ width: 1200, height: 500 })
|
||||
page.on('console', console.log)
|
||||
|
||||
// Constants and locators
|
||||
const projectCard = page.getByText('testProject')
|
||||
const projectMenuButton = page.getByTestId('project-sidebar-toggle')
|
||||
const fileToDelete = page
|
||||
.getByRole('listitem')
|
||||
.filter({ has: page.getByRole('button', { name: 'fileToDelete.kcl' }) })
|
||||
const deleteMenuItem = page.getByRole('button', { name: 'Delete' })
|
||||
const deleteConfirmation = page.getByTestId('delete-confirmation')
|
||||
|
||||
await test.step('Open project and navigate to fileToDelete.kcl', async () => {
|
||||
await projectCard.click()
|
||||
await u.waitForPageLoad()
|
||||
await u.openFilePanel()
|
||||
|
||||
await fileToDelete.click()
|
||||
await u.waitForPageLoad()
|
||||
await u.openKclCodePanel()
|
||||
await expect(u.codeLocator).toContainText('getOppositeEdge(thing)')
|
||||
await u.closeKclCodePanel()
|
||||
})
|
||||
|
||||
await test.step('Delete fileToDelete.kcl', async () => {
|
||||
await fileToDelete.click({ button: 'right' })
|
||||
await expect(deleteMenuItem).toBeVisible()
|
||||
await deleteMenuItem.click()
|
||||
await expect(deleteConfirmation).toBeVisible()
|
||||
await deleteConfirmation.click()
|
||||
})
|
||||
|
||||
await test.step('Check deletion and navigation', async () => {
|
||||
await u.waitForPageLoad()
|
||||
await expect(fileToDelete).not.toBeVisible()
|
||||
await u.closeFilePanel()
|
||||
await u.openKclCodePanel()
|
||||
await expect(u.codeLocator).toContainText('circle(')
|
||||
await expect(projectMenuButton).toContainText('main.kcl')
|
||||
})
|
||||
|
||||
await electronApp.close()
|
||||
}
|
||||
)
|
||||
|
||||
test.fixme('TODO - when main.kcl does not exist', async () => {})
|
||||
})
|
||||
|
||||
test(
|
||||
'Original project name persist after onboarding',
|
||||
{ tag: '@electron' },
|
||||
|
@ -11,8 +11,8 @@ import {
|
||||
import { TEST_CODE_TRIGGER_ENGINE_EXPORT_ERROR } from './storageStates'
|
||||
import { bracket } from 'lib/exampleKcl'
|
||||
|
||||
test.beforeEach(async ({ context, page }) => {
|
||||
await setup(context, page)
|
||||
test.beforeEach(async ({ context, page }, testInfo) => {
|
||||
await setup(context, page, testInfo)
|
||||
})
|
||||
|
||||
test.afterEach(async ({ page }, testInfo) => {
|
||||
@ -54,6 +54,67 @@ const sketch001 = startSketchAt([-0, -0])
|
||||
const crypticErrorText = `ApiError`
|
||||
await expect(page.getByText(crypticErrorText).first()).toBeVisible()
|
||||
})
|
||||
test('user should not have to press down twice in cmdbar', async ({
|
||||
page,
|
||||
}) => {
|
||||
// because the model has `line([0,0]..` it is valid code, but the model is invalid
|
||||
// regression test for https://github.com/KittyCAD/modeling-app/issues/3251
|
||||
// Since the bad model also found as issue with the artifact graph, which in tern blocked the editor diognostics
|
||||
const u = await getUtils(page)
|
||||
await page.addInitScript(async () => {
|
||||
localStorage.setItem(
|
||||
'persistCode',
|
||||
`const sketch2 = startSketchOn("XY")
|
||||
const sketch001 = startSketchAt([-0, -0])
|
||||
|> line([0, 0], %)
|
||||
|> line([-4.84, -5.29], %)
|
||||
|> lineTo([profileStartX(%), profileStartY(%)], %)
|
||||
|> close(%)`
|
||||
)
|
||||
})
|
||||
|
||||
await page.setViewportSize({ width: 1000, height: 500 })
|
||||
|
||||
await page.goto('/')
|
||||
await u.waitForPageLoad()
|
||||
|
||||
await test.step('Check arrow down works', async () => {
|
||||
await page.getByTestId('command-bar-open-button').click()
|
||||
|
||||
await page
|
||||
.getByRole('option', { name: 'floppy disk arrow Export' })
|
||||
.click()
|
||||
|
||||
// press arrow down key twice
|
||||
await page.keyboard.press('ArrowDown')
|
||||
await page.waitForTimeout(100)
|
||||
await page.keyboard.press('ArrowDown')
|
||||
|
||||
// STL is the third option, which makes sense for two arrow downs
|
||||
await expect(page.locator('[data-headlessui-state="active"]')).toHaveText(
|
||||
'STL'
|
||||
)
|
||||
|
||||
await page.keyboard.press('Escape')
|
||||
await page.waitForTimeout(200)
|
||||
await page.keyboard.press('Escape')
|
||||
await page.waitForTimeout(200)
|
||||
})
|
||||
|
||||
await test.step('Check arrow up works', async () => {
|
||||
// theme in test is dark, which is the second option, which means we can test arrow up
|
||||
await page.getByTestId('command-bar-open-button').click()
|
||||
|
||||
await page.getByText('The overall appearance of the').click()
|
||||
|
||||
await page.keyboard.press('ArrowUp')
|
||||
await page.waitForTimeout(100)
|
||||
|
||||
await expect(page.locator('[data-headlessui-state="active"]')).toHaveText(
|
||||
'light'
|
||||
)
|
||||
})
|
||||
})
|
||||
test('executes on load', async ({ page }) => {
|
||||
const u = await getUtils(page)
|
||||
await page.addInitScript(async () => {
|
||||
@ -285,10 +346,7 @@ const sketch001 = startSketchAt([-0, -0])
|
||||
// Find the toast.
|
||||
// Look out for the toast message
|
||||
const exportingToastMessage = page.getByText(`Exporting...`)
|
||||
await expect(exportingToastMessage).toBeVisible()
|
||||
|
||||
const errorToastMessage = page.getByText(`Error while exporting`)
|
||||
await expect(errorToastMessage).toBeVisible()
|
||||
|
||||
const engineErrorToastMessage = page.getByText(`Nothing to export`)
|
||||
await expect(engineErrorToastMessage).toBeVisible()
|
||||
|
@ -9,8 +9,8 @@ import {
|
||||
} from './test-utils'
|
||||
import { uuidv4, roundOff } from 'lib/utils'
|
||||
|
||||
test.beforeEach(async ({ context, page }) => {
|
||||
await setup(context, page)
|
||||
test.beforeEach(async ({ context, page }, testInfo) => {
|
||||
await setup(context, page, testInfo)
|
||||
})
|
||||
|
||||
test.afterEach(async ({ page }, testInfo) => {
|
||||
@ -40,7 +40,7 @@ test.describe('Sketch tests', () => {
|
||||
const screwRadius = 3
|
||||
const wireRadius = 2
|
||||
const wireOffset = 0.5
|
||||
|
||||
|
||||
const screwHole = startSketchOn('XY')
|
||||
${startProfileAt1}
|
||||
|> arc({
|
||||
@ -48,7 +48,7 @@ test.describe('Sketch tests', () => {
|
||||
angle_start: 0,
|
||||
angle_end: 360
|
||||
}, %)
|
||||
|
||||
|
||||
const part001 = startSketchOn('XY')
|
||||
${startProfileAt2}
|
||||
|> xLine(width * .5, %)
|
||||
@ -57,7 +57,7 @@ test.describe('Sketch tests', () => {
|
||||
|> close(%)
|
||||
|> hole(screwHole, %)
|
||||
|> extrude(thickness, %)
|
||||
|
||||
|
||||
const part002 = startSketchOn('-XZ')
|
||||
${startProfileAt3}
|
||||
|> xLine(width / 4, %)
|
||||
@ -618,19 +618,19 @@ test.describe('Sketch tests', () => {
|
||||
await u.closeDebugPanel()
|
||||
|
||||
await click00r(30, 0)
|
||||
codeStr += ` |> startProfileAt([1.53, 0], %)`
|
||||
codeStr += ` |> startProfileAt([2.03, 0], %)`
|
||||
await expect(u.codeLocator).toHaveText(codeStr)
|
||||
|
||||
await click00r(30, 0)
|
||||
codeStr += ` |> line([1.53, 0], %)`
|
||||
codeStr += ` |> line([2.04, 0], %)`
|
||||
await expect(u.codeLocator).toHaveText(codeStr)
|
||||
|
||||
await click00r(0, 30)
|
||||
codeStr += ` |> line([0, -1.53], %)`
|
||||
codeStr += ` |> line([0, -2.03], %)`
|
||||
await expect(u.codeLocator).toHaveText(codeStr)
|
||||
|
||||
await click00r(-30, 0)
|
||||
codeStr += ` |> line([-1.53, 0], %)`
|
||||
codeStr += ` |> line([-2.04, 0], %)`
|
||||
await expect(u.codeLocator).toHaveText(codeStr)
|
||||
|
||||
await click00r(undefined, undefined)
|
||||
@ -954,4 +954,68 @@ const sketch002 = startSketchOn(extrude001, 'END')
|
||||
await u.getGreatestPixDiff(XYPlanePoint, noPlanesColor)
|
||||
).toBeLessThan(3)
|
||||
})
|
||||
|
||||
test('Can attempt to sketch on revolved face', async ({
|
||||
page,
|
||||
browserName,
|
||||
}) => {
|
||||
test.skip(
|
||||
browserName === 'webkit',
|
||||
'Skip on Safari until `window.tearDown` is working there'
|
||||
)
|
||||
const u = await getUtils(page)
|
||||
await page.setViewportSize({ width: 1200, height: 500 })
|
||||
|
||||
await page.addInitScript(async () => {
|
||||
localStorage.setItem(
|
||||
'persistCode',
|
||||
`const lugHeadLength = 0.25
|
||||
const lugDiameter = 0.5
|
||||
const lugLength = 2
|
||||
|
||||
fn lug = (origin, length, diameter, plane) => {
|
||||
const lugSketch = startSketchOn(plane)
|
||||
|> startProfileAt([origin[0] + lugDiameter / 2, origin[1]], %)
|
||||
|> angledLineOfYLength({ angle: 60, length: lugHeadLength }, %)
|
||||
|> xLineTo(0 + .001, %)
|
||||
|> yLineTo(0, %)
|
||||
|> close(%)
|
||||
|> revolve({ axis: "Y" }, %)
|
||||
|
||||
return lugSketch
|
||||
}
|
||||
|
||||
lug([0, 0], 10, .5, "XY")`
|
||||
)
|
||||
})
|
||||
|
||||
await u.waitForAuthSkipAppStart()
|
||||
|
||||
await u.openDebugPanel()
|
||||
await u.expectCmdLog('[data-message-type="execution-done"]')
|
||||
await u.closeDebugPanel()
|
||||
|
||||
/***
|
||||
* Test Plan
|
||||
* Start the sketch mode
|
||||
* Click the middle of the screen which should click the top face that is revolved
|
||||
* Wait till you see the line tool be enabled
|
||||
* Wait till you see the exit sketch enabled
|
||||
*
|
||||
* This is supposed to test that you are allowed to go into sketch mode to sketch on a revolved face
|
||||
*/
|
||||
|
||||
await page.getByRole('button', { name: 'Start Sketch' }).click()
|
||||
|
||||
await expect(async () => {
|
||||
await page.mouse.click(600, 250)
|
||||
await page.waitForTimeout(1000)
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Exit Sketch' })
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'line Line', exact: true })
|
||||
).toHaveAttribute('aria-pressed', 'true')
|
||||
}).toPass({ timeout: 40_000, intervals: [1_000] })
|
||||
})
|
||||
})
|
||||
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 49 KiB |
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 46 KiB |
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 46 KiB |
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 43 KiB |
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 55 KiB |
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 51 KiB |
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 47 KiB |
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 43 KiB |
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 41 KiB |
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 37 KiB |
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 38 KiB |
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 35 KiB |
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 46 KiB |
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 43 KiB |
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 30 KiB |
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 30 KiB |
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 49 KiB |
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 49 KiB |
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 71 KiB |
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 62 KiB |
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 46 KiB |
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 43 KiB |
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 62 KiB |
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 58 KiB |
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 33 KiB |
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 33 KiB |
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 36 KiB |
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 36 KiB |
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 35 KiB |
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 35 KiB |
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 35 KiB |
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 35 KiB |
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 38 KiB |
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 38 KiB |
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 33 KiB |
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 32 KiB |
@ -2,8 +2,8 @@ import { test, expect } from '@playwright/test'
|
||||
|
||||
import { commonPoints, getUtils, setup, tearDown } from './test-utils'
|
||||
|
||||
test.beforeEach(async ({ context, page }) => {
|
||||
await setup(context, page)
|
||||
test.beforeEach(async ({ context, page }, testInfo) => {
|
||||
await setup(context, page, testInfo)
|
||||
})
|
||||
|
||||
test.afterEach(async ({ page }, testInfo) => {
|
||||
|
@ -26,7 +26,9 @@ import {
|
||||
import * as TOML from '@iarna/toml'
|
||||
import { SaveSettingsPayload } from 'lib/settings/settingsTypes'
|
||||
import { SETTINGS_FILE_NAME } from 'lib/constants'
|
||||
import { isErrorWhitelisted } from './lib/console-error-whitelist'
|
||||
import { isArray } from 'lib/utils'
|
||||
import { reportRejection } from 'lib/trap'
|
||||
|
||||
type TestColor = [number, number, number]
|
||||
export const TEST_COLORS = {
|
||||
@ -439,46 +441,78 @@ export async function getUtils(page: Page, test_?: typeof test) {
|
||||
}
|
||||
return maxDiff
|
||||
},
|
||||
doAndWaitForImageDiff: (fn: () => Promise<any>, diffCount = 200) =>
|
||||
new Promise(async (resolve) => {
|
||||
await page.screenshot({
|
||||
path: './e2e/playwright/temp1.png',
|
||||
fullPage: true,
|
||||
})
|
||||
await fn()
|
||||
const isImageDiff = async () => {
|
||||
getPixelRGBs: async (
|
||||
coords: { x: number; y: number },
|
||||
radius: number
|
||||
): Promise<[number, number, number][]> => {
|
||||
const buffer = await page.screenshot({
|
||||
fullPage: true,
|
||||
})
|
||||
const screenshot = await PNG.sync.read(buffer)
|
||||
const pixMultiplier: number = await page.evaluate(
|
||||
'window.devicePixelRatio'
|
||||
)
|
||||
const allCords: [number, number][] = [[coords.x, coords.y]]
|
||||
for (let i = 1; i < radius; i++) {
|
||||
allCords.push([coords.x + i, coords.y])
|
||||
allCords.push([coords.x - i, coords.y])
|
||||
allCords.push([coords.x, coords.y + i])
|
||||
allCords.push([coords.x, coords.y - i])
|
||||
}
|
||||
return allCords.map(([x, y]) => {
|
||||
const index =
|
||||
(screenshot.width * y * pixMultiplier + x * pixMultiplier) * 4 // rbga is 4 channels
|
||||
return [
|
||||
screenshot.data[index],
|
||||
screenshot.data[index + 1],
|
||||
screenshot.data[index + 2],
|
||||
]
|
||||
})
|
||||
},
|
||||
doAndWaitForImageDiff: (fn: () => Promise<unknown>, diffCount = 200) =>
|
||||
new Promise<boolean>((resolve) => {
|
||||
;(async () => {
|
||||
await page.screenshot({
|
||||
path: './e2e/playwright/temp2.png',
|
||||
path: './e2e/playwright/temp1.png',
|
||||
fullPage: true,
|
||||
})
|
||||
const screenshot1 = PNG.sync.read(
|
||||
await fsp.readFile('./e2e/playwright/temp1.png')
|
||||
)
|
||||
const screenshot2 = PNG.sync.read(
|
||||
await fsp.readFile('./e2e/playwright/temp2.png')
|
||||
)
|
||||
const actualDiffCount = pixelMatch(
|
||||
screenshot1.data,
|
||||
screenshot2.data,
|
||||
null,
|
||||
screenshot1.width,
|
||||
screenshot2.height
|
||||
)
|
||||
return actualDiffCount > diffCount
|
||||
}
|
||||
|
||||
// run isImageDiff every 50ms until it returns true or 5 seconds have passed (100 times)
|
||||
let count = 0
|
||||
const interval = setInterval(async () => {
|
||||
count++
|
||||
if (await isImageDiff()) {
|
||||
clearInterval(interval)
|
||||
resolve(true)
|
||||
} else if (count > 100) {
|
||||
clearInterval(interval)
|
||||
resolve(false)
|
||||
await fn()
|
||||
const isImageDiff = async () => {
|
||||
await page.screenshot({
|
||||
path: './e2e/playwright/temp2.png',
|
||||
fullPage: true,
|
||||
})
|
||||
const screenshot1 = PNG.sync.read(
|
||||
await fsp.readFile('./e2e/playwright/temp1.png')
|
||||
)
|
||||
const screenshot2 = PNG.sync.read(
|
||||
await fsp.readFile('./e2e/playwright/temp2.png')
|
||||
)
|
||||
const actualDiffCount = pixelMatch(
|
||||
screenshot1.data,
|
||||
screenshot2.data,
|
||||
null,
|
||||
screenshot1.width,
|
||||
screenshot2.height
|
||||
)
|
||||
return actualDiffCount > diffCount
|
||||
}
|
||||
}, 50)
|
||||
|
||||
// run isImageDiff every 50ms until it returns true or 5 seconds have passed (100 times)
|
||||
let count = 0
|
||||
const interval = setInterval(() => {
|
||||
;(async () => {
|
||||
count++
|
||||
if (await isImageDiff()) {
|
||||
clearInterval(interval)
|
||||
resolve(true)
|
||||
} else if (count > 100) {
|
||||
clearInterval(interval)
|
||||
resolve(false)
|
||||
}
|
||||
})().catch(reportRejection)
|
||||
}, 50)
|
||||
})().catch(reportRejection)
|
||||
}),
|
||||
emulateNetworkConditions: async (
|
||||
networkOptions: Protocol.Network.emulateNetworkConditionsParameters
|
||||
@ -817,7 +851,11 @@ export async function tearDown(page: Page, testInfo: TestInfo) {
|
||||
|
||||
// settingsOverrides may need to be augmented to take more generic items,
|
||||
// but we'll be strict for now
|
||||
export async function setup(context: BrowserContext, page: Page) {
|
||||
export async function setup(
|
||||
context: BrowserContext,
|
||||
page: Page,
|
||||
testInfo?: TestInfo
|
||||
) {
|
||||
await context.addInitScript(
|
||||
async ({ token, settingsKey, settings, IS_PLAYWRIGHT_KEY }) => {
|
||||
localStorage.clear()
|
||||
@ -853,6 +891,8 @@ export async function setup(context: BrowserContext, page: Page) {
|
||||
secure: true,
|
||||
},
|
||||
])
|
||||
|
||||
failOnConsoleErrors(page, testInfo)
|
||||
// kill animations, speeds up tests and reduced flakiness
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' })
|
||||
|
||||
@ -926,6 +966,48 @@ export async function setupElectron({
|
||||
return { electronApp, page, dir: projectDirName }
|
||||
}
|
||||
|
||||
function failOnConsoleErrors(page: Page, testInfo?: TestInfo) {
|
||||
// enabled for chrome for now
|
||||
if (page.context().browser()?.browserType().name() === 'chromium') {
|
||||
page.on('pageerror', (exception) => {
|
||||
if (isErrorWhitelisted(exception)) {
|
||||
return
|
||||
}
|
||||
|
||||
// only set this env var to false if you want to collect console errors
|
||||
// This can be configured in the GH workflow. This should be set to true by default (we want tests to fail when
|
||||
// unwhitelisted console errors are detected).
|
||||
if (process.env.FAIL_ON_CONSOLE_ERRORS === 'true') {
|
||||
// Fail when running on CI and FAIL_ON_CONSOLE_ERRORS is set
|
||||
// use expect to prevent page from closing and not cleaning up
|
||||
expect(`An error was detected in the console: \r\n message:${exception.message} \r\n name:${exception.name} \r\n stack:${exception.stack}
|
||||
|
||||
*Either fix the console error or add it to the whitelist defined in ./lib/console-error-whitelist.ts (if the error can be safely ignored)
|
||||
`).toEqual('Console error detected')
|
||||
} else {
|
||||
// the (test-results/exceptions.txt) file will be uploaded as part of an upload artifact in GH
|
||||
fsp
|
||||
.appendFile(
|
||||
'./test-results/exceptions.txt',
|
||||
[
|
||||
'~~~',
|
||||
`triggered_by_test:${
|
||||
testInfo?.file + ' ' + (testInfo?.title || ' ')
|
||||
}`,
|
||||
`name:${exception.name}`,
|
||||
`message:${exception.message}`,
|
||||
`stack:${exception.stack}`,
|
||||
`project:${testInfo?.project.name}`,
|
||||
'~~~',
|
||||
].join('\n')
|
||||
)
|
||||
.catch((err) => {
|
||||
console.error(err)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
export async function isOutOfViewInScrollContainer(
|
||||
element: Locator,
|
||||
container: Locator
|
||||
|
@ -3,8 +3,8 @@ import { EngineCommand } from 'lang/std/artifactGraph'
|
||||
import { uuidv4 } from 'lib/utils'
|
||||
import { getUtils, setup, tearDown } from './test-utils'
|
||||
|
||||
test.beforeEach(async ({ context, page }) => {
|
||||
await setup(context, page)
|
||||
test.beforeEach(async ({ context, page }, testInfo) => {
|
||||
await setup(context, page, testInfo)
|
||||
})
|
||||
|
||||
test.afterEach(async ({ page }, testInfo) => {
|
||||
@ -12,8 +12,8 @@ test.afterEach(async ({ page }, testInfo) => {
|
||||
})
|
||||
|
||||
test.describe('Testing Camera Movement', () => {
|
||||
test('Can moving camera', async ({ page, context }) => {
|
||||
test.skip(process.platform === 'darwin', 'Can moving camera')
|
||||
test('Can move camera reliably', async ({ page, context }) => {
|
||||
test.skip(process.platform === 'darwin', 'Can move camera reliably')
|
||||
const u = await getUtils(page)
|
||||
await page.setViewportSize({ width: 1200, height: 500 })
|
||||
|
||||
@ -102,6 +102,13 @@ test.describe('Testing Camera Movement', () => {
|
||||
await bakeInRetries(async () => {
|
||||
await page.mouse.move(700, 200)
|
||||
await page.mouse.down({ button: 'right' })
|
||||
const appLogoBBox = await page.getByTestId('app-logo').boundingBox()
|
||||
expect(appLogoBBox).not.toBeNull()
|
||||
if (!appLogoBBox) throw new Error('app logo not found')
|
||||
await page.mouse.move(
|
||||
appLogoBBox.x + appLogoBBox.width / 2,
|
||||
appLogoBBox.y + appLogoBBox.height / 2
|
||||
)
|
||||
await page.mouse.move(600, 303)
|
||||
await page.mouse.up({ button: 'right' })
|
||||
}, [4, -10.5, -120])
|
||||
@ -295,11 +302,11 @@ test.describe('Testing Camera Movement', () => {
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Edit Sketch' })
|
||||
).toBeVisible()
|
||||
await hoverOverNothing()
|
||||
await page.getByRole('button', { name: 'Edit Sketch' }).click()
|
||||
|
||||
await page.waitForTimeout(400)
|
||||
|
||||
await hoverOverNothing()
|
||||
x = 975
|
||||
y = 468
|
||||
|
||||
|
@ -3,8 +3,8 @@ import { test, expect } from '@playwright/test'
|
||||
import { getUtils, setup, tearDown, TEST_COLORS } from './test-utils'
|
||||
import { XOR } from 'lib/utils'
|
||||
|
||||
test.beforeEach(async ({ context, page }) => {
|
||||
await setup(context, page)
|
||||
test.beforeEach(async ({ context, page }, testInfo) => {
|
||||
await setup(context, page, testInfo)
|
||||
})
|
||||
|
||||
test.afterEach(async ({ page }, testInfo) => {
|
||||
|
@ -4,8 +4,8 @@ import { getUtils, setup, tearDown } from './test-utils'
|
||||
import { uuidv4 } from 'lib/utils'
|
||||
import { TEST_CODE_GIZMO } from './storageStates'
|
||||
|
||||
test.beforeEach(async ({ context, page }) => {
|
||||
await setup(context, page)
|
||||
test.beforeEach(async ({ context, page }, testInfo) => {
|
||||
await setup(context, page, testInfo)
|
||||
})
|
||||
|
||||
test.afterEach(async ({ page }, testInfo) => {
|
||||
|
@ -4,8 +4,8 @@ import { deg, getUtils, setup, tearDown, wiggleMove } from './test-utils'
|
||||
import { LineInputsType } from 'lang/std/sketchcombos'
|
||||
import { uuidv4 } from 'lib/utils'
|
||||
|
||||
test.beforeEach(async ({ context, page }) => {
|
||||
await setup(context, page)
|
||||
test.beforeEach(async ({ context, page }, testInfo) => {
|
||||
await setup(context, page, testInfo)
|
||||
})
|
||||
|
||||
test.afterEach(async ({ page }, testInfo) => {
|
||||
|
@ -5,8 +5,8 @@ import { Coords2d } from 'lang/std/sketch'
|
||||
import { KCL_DEFAULT_LENGTH } from 'lib/constants'
|
||||
import { uuidv4 } from 'lib/utils'
|
||||
|
||||
test.beforeEach(async ({ context, page }) => {
|
||||
await setup(context, page)
|
||||
test.beforeEach(async ({ context, page }, testInfo) => {
|
||||
await setup(context, page, testInfo)
|
||||
})
|
||||
|
||||
test.afterEach(async ({ page }, testInfo) => {
|
||||
@ -31,12 +31,28 @@ test.describe('Testing selections', () => {
|
||||
|
||||
const xAxisClick = () =>
|
||||
page.mouse.click(700, 253).then(() => page.waitForTimeout(100))
|
||||
const xAxisClickAfterExitingSketch = () =>
|
||||
page.mouse.click(639, 278).then(() => page.waitForTimeout(100))
|
||||
const emptySpaceHover = () =>
|
||||
test.step('Hover over empty space', async () => {
|
||||
await page.mouse.move(700, 143, { steps: 5 })
|
||||
await expect(page.locator('.hover-highlight')).not.toBeVisible()
|
||||
})
|
||||
const emptySpaceClick = () =>
|
||||
page.mouse.click(700, 343).then(() => page.waitForTimeout(100))
|
||||
test.step(`Click in empty space`, async () => {
|
||||
await page.mouse.click(700, 143)
|
||||
await expect(page.locator('.cm-line').last()).toHaveClass(
|
||||
/cm-activeLine/
|
||||
)
|
||||
})
|
||||
const topHorzSegmentClick = () =>
|
||||
page.mouse.click(709, 290).then(() => page.waitForTimeout(100))
|
||||
page.mouse
|
||||
.click(startXPx, 500 - PUR * 20)
|
||||
.then(() => page.waitForTimeout(100))
|
||||
const bottomHorzSegmentClick = () =>
|
||||
page.mouse.click(767, 396).then(() => page.waitForTimeout(100))
|
||||
page.mouse
|
||||
.click(startXPx + PUR * 10, 500 - PUR * 10)
|
||||
.then(() => page.waitForTimeout(100))
|
||||
|
||||
await u.clearCommandLogs()
|
||||
await expect(
|
||||
@ -171,7 +187,9 @@ test.describe('Testing selections', () => {
|
||||
await emptySpaceClick()
|
||||
}
|
||||
|
||||
await selectionSequence()
|
||||
await test.step(`Test hovering and selecting on fresh sketch`, async () => {
|
||||
await selectionSequence()
|
||||
})
|
||||
|
||||
// hovering in fresh sketch worked, lets try exiting and re-entering
|
||||
await u.openAndClearDebugPanel()
|
||||
@ -184,16 +202,17 @@ test.describe('Testing selections', () => {
|
||||
|
||||
// select a line, this verifies that sketches in the scene can be selected outside of sketch mode
|
||||
await topHorzSegmentClick()
|
||||
await xAxisClickAfterExitingSketch()
|
||||
await page.waitForTimeout(100)
|
||||
await emptySpaceHover()
|
||||
|
||||
// enter sketch again
|
||||
await u.doAndWaitForCmd(
|
||||
() => page.getByRole('button', { name: 'Edit Sketch' }).click(),
|
||||
'default_camera_get_settings'
|
||||
)
|
||||
await page.waitForTimeout(150)
|
||||
|
||||
await page.waitForTimeout(300) // wait for animation
|
||||
await page.waitForTimeout(450) // wait for animation
|
||||
|
||||
await u.openAndClearDebugPanel()
|
||||
await u.sendCustomCmd({
|
||||
@ -220,8 +239,9 @@ test.describe('Testing selections', () => {
|
||||
|
||||
await u.closeDebugPanel()
|
||||
|
||||
// hover again and check it works
|
||||
await selectionSequence()
|
||||
await test.step(`Test hovering and selecting on edited sketch`, async () => {
|
||||
await selectionSequence()
|
||||
})
|
||||
})
|
||||
|
||||
test('Solids should be select and deletable', async ({ page }) => {
|
||||
@ -413,7 +433,7 @@ const sketch002 = startSketchOn(launderExtrudeThroughVar, seg02)
|
||||
|> line([0, 20.03], %)
|
||||
|> line([62.61, 0], %, $seg03)
|
||||
|> lineTo([profileStartX(%), profileStartY(%)], %)
|
||||
|> close(%)
|
||||
|> close(%)
|
||||
`
|
||||
)
|
||||
}, KCL_DEFAULT_LENGTH)
|
||||
@ -516,11 +536,22 @@ const sketch002 = startSketchOn(launderExtrudeThroughVar, seg02)
|
||||
await page.waitForTimeout(100)
|
||||
await u.closeDebugPanel()
|
||||
|
||||
const extrusionTop: Coords2d = [800, 240]
|
||||
const extrusionTopCap: Coords2d = [800, 240]
|
||||
const flatExtrusionFace: Coords2d = [960, 160]
|
||||
const arc: Coords2d = [840, 160]
|
||||
const tangentialArcTo: Coords2d = [840, 160]
|
||||
const close: Coords2d = [720, 200]
|
||||
const nothing: Coords2d = [600, 200]
|
||||
const closeEdge: Coords2d = [744, 233]
|
||||
const closeAdjacentEdge: Coords2d = [688, 123]
|
||||
const closeOppositeEdge: Coords2d = [687, 169]
|
||||
|
||||
const tangentialArcEdge: Coords2d = [811, 142]
|
||||
const tangentialArcOppositeEdge: Coords2d = [820, 180]
|
||||
const tangentialArcAdjacentEdge: Coords2d = [893, 165]
|
||||
|
||||
const straightSegmentEdge: Coords2d = [819, 369]
|
||||
const straightSegmentOppositeEdge: Coords2d = [635, 394]
|
||||
const straightSegmentAdjacentEdge: Coords2d = [679, 329]
|
||||
|
||||
await page.mouse.move(nothing[0], nothing[1])
|
||||
await page.mouse.click(nothing[0], nothing[1])
|
||||
@ -528,26 +559,141 @@ const sketch002 = startSketchOn(launderExtrudeThroughVar, seg02)
|
||||
await expect(page.getByTestId('hover-highlight')).not.toBeVisible()
|
||||
await page.waitForTimeout(200)
|
||||
|
||||
await page.mouse.move(extrusionTop[0], extrusionTop[1])
|
||||
await expect(page.getByTestId('hover-highlight').first()).toBeVisible()
|
||||
await page.mouse.move(nothing[0], nothing[1])
|
||||
await expect(page.getByTestId('hover-highlight').first()).not.toBeVisible()
|
||||
const checkCodeAtHoverPosition = async (
|
||||
name = '',
|
||||
coord: Coords2d,
|
||||
highlightCode: string,
|
||||
activeLine = highlightCode
|
||||
) => {
|
||||
await test.step(`test selection for: ${name}`, async () => {
|
||||
const highlightedLocator = page.getByTestId('hover-highlight')
|
||||
const activeLineLocator = page.locator('.cm-activeLine')
|
||||
|
||||
await page.mouse.move(arc[0], arc[1])
|
||||
await expect(page.getByTestId('hover-highlight').first()).toBeVisible()
|
||||
await page.mouse.move(nothing[0], nothing[1])
|
||||
await expect(page.getByTestId('hover-highlight').first()).not.toBeVisible()
|
||||
await test.step(`hover should highlight correct code`, async () => {
|
||||
await page.mouse.move(coord[0], coord[1])
|
||||
await expect(highlightedLocator.first()).toBeVisible()
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const textContents = await highlightedLocator.allTextContents()
|
||||
return textContents.join('').replace(/\s+/g, '')
|
||||
})
|
||||
.toBe(highlightCode)
|
||||
await page.mouse.move(nothing[0], nothing[1])
|
||||
})
|
||||
await test.step(`click should put the cursor in the right place`, async () => {
|
||||
await expect(highlightedLocator.first()).not.toBeVisible()
|
||||
await page.mouse.click(coord[0], coord[1])
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const activeLines = await activeLineLocator.allInnerTexts()
|
||||
return activeLines.join('')
|
||||
})
|
||||
.toContain(activeLine)
|
||||
// check pixels near the click location are yellow
|
||||
})
|
||||
await test.step(`check the engine agrees with selections`, async () => {
|
||||
// ultimately the only way we know if the engine agrees with the selection from the FE
|
||||
// perspective is if it highlights the pixels near where we clicked yellow.
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const RGBs = await u.getPixelRGBs({ x: coord[0], y: coord[1] }, 3)
|
||||
for (const rgb of RGBs) {
|
||||
const [r, g, b] = rgb
|
||||
const RGAverage = (r + g) / 2
|
||||
const isRedGreenSameIsh = Math.abs(r - g) < 3
|
||||
const isBlueLessThanRG = RGAverage - b > 45
|
||||
const isYellowy = isRedGreenSameIsh && isBlueLessThanRG
|
||||
if (isYellowy) return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
.toBeTruthy()
|
||||
await page.mouse.click(nothing[0], nothing[1])
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
await page.mouse.move(close[0], close[1])
|
||||
await expect(page.getByTestId('hover-highlight').first()).toBeVisible()
|
||||
await page.mouse.move(nothing[0], nothing[1])
|
||||
await expect(page.getByTestId('hover-highlight').first()).not.toBeVisible()
|
||||
await checkCodeAtHoverPosition(
|
||||
'extrusionTopCap',
|
||||
extrusionTopCap,
|
||||
'startProfileAt([20,0],%)',
|
||||
'startProfileAt([20, 0], %)'
|
||||
)
|
||||
await checkCodeAtHoverPosition(
|
||||
'flatExtrusionFace',
|
||||
flatExtrusionFace,
|
||||
`angledLineThatIntersects({angle:3.14,intersectTag:a,offset:0},%)extrude(5+7,%)`,
|
||||
'}, %)'
|
||||
)
|
||||
|
||||
await page.mouse.move(flatExtrusionFace[0], flatExtrusionFace[1])
|
||||
await expect(page.getByTestId('hover-highlight')).toHaveCount(6) // multiple lines
|
||||
await page.mouse.move(nothing[0], nothing[1])
|
||||
await page.waitForTimeout(100)
|
||||
await expect(page.getByTestId('hover-highlight').first()).not.toBeVisible()
|
||||
await checkCodeAtHoverPosition(
|
||||
'tangentialArcTo',
|
||||
tangentialArcTo,
|
||||
'tangentialArcTo([13.14+0,13.14],%)extrude(5+7,%)',
|
||||
'tangentialArcTo([13.14 + 0, 13.14], %)'
|
||||
)
|
||||
await checkCodeAtHoverPosition(
|
||||
'tangentialArcEdge',
|
||||
tangentialArcEdge,
|
||||
`tangentialArcTo([13.14+0,13.14],%)`,
|
||||
'tangentialArcTo([13.14 + 0, 13.14], %)'
|
||||
)
|
||||
await checkCodeAtHoverPosition(
|
||||
'tangentialArcOppositeEdge',
|
||||
tangentialArcOppositeEdge,
|
||||
`tangentialArcTo([13.14+0,13.14],%)`,
|
||||
'tangentialArcTo([13.14 + 0, 13.14], %)'
|
||||
)
|
||||
await checkCodeAtHoverPosition(
|
||||
'tangentialArcAdjacentEdge',
|
||||
tangentialArcAdjacentEdge,
|
||||
`tangentialArcTo([13.14+0,13.14],%)`,
|
||||
'tangentialArcTo([13.14 + 0, 13.14], %)'
|
||||
)
|
||||
|
||||
await checkCodeAtHoverPosition(
|
||||
'close',
|
||||
close,
|
||||
'close(%)extrude(5+7,%)',
|
||||
'close(%)'
|
||||
)
|
||||
await checkCodeAtHoverPosition(
|
||||
'closeEdge',
|
||||
closeEdge,
|
||||
`close(%)`,
|
||||
'close(%)'
|
||||
)
|
||||
await checkCodeAtHoverPosition(
|
||||
'closeAdjacentEdge',
|
||||
closeAdjacentEdge,
|
||||
`close(%)`,
|
||||
'close(%)'
|
||||
)
|
||||
await checkCodeAtHoverPosition(
|
||||
'closeOppositeEdge',
|
||||
closeOppositeEdge,
|
||||
`close(%)`,
|
||||
'close(%)'
|
||||
)
|
||||
|
||||
await checkCodeAtHoverPosition(
|
||||
'straightSegmentEdge',
|
||||
straightSegmentEdge,
|
||||
`angledLineToY({angle:30,to:11.14},%)`,
|
||||
'angledLineToY({ angle: 30, to: 11.14 }, %)'
|
||||
)
|
||||
await checkCodeAtHoverPosition(
|
||||
'straightSegmentOppositeEdge',
|
||||
straightSegmentOppositeEdge,
|
||||
`angledLineToY({angle:30,to:11.14},%)`,
|
||||
'angledLineToY({ angle: 30, to: 11.14 }, %)'
|
||||
)
|
||||
await checkCodeAtHoverPosition(
|
||||
'straightSegmentAdjancentEdge',
|
||||
straightSegmentAdjacentEdge,
|
||||
`angledLineToY({angle:30,to:11.14},%)`,
|
||||
'angledLineToY({ angle: 30, to: 11.14 }, %)'
|
||||
)
|
||||
})
|
||||
test("Extrude button should be disabled if there's no extrudable geometry when nothing is selected", async ({
|
||||
page,
|
||||
@ -876,7 +1022,7 @@ const extrude001 = extrude(50, sketch001)
|
||||
|> line([4.95, -8], %)
|
||||
|> line([-20.38, -10.12], %)
|
||||
|> line([-15.79, 17.08], %)
|
||||
|
||||
|
||||
fn yohey = (pos) => {
|
||||
const sketch004 = startSketchOn('XZ')
|
||||
${extrudeAndEditBlockedInFunction}
|
||||
@ -886,7 +1032,7 @@ const extrude001 = extrude(50, sketch001)
|
||||
|> line([-15.79, 17.08], %)
|
||||
return ''
|
||||
}
|
||||
|
||||
|
||||
yohey([15.79, -34.6])
|
||||
`
|
||||
)
|
||||
|
@ -8,12 +8,16 @@ import {
|
||||
tearDown,
|
||||
executorInputPath,
|
||||
} from './test-utils'
|
||||
import { SaveSettingsPayload } from 'lib/settings/settingsTypes'
|
||||
import { TEST_SETTINGS_KEY, TEST_SETTINGS_CORRUPTED } from './storageStates'
|
||||
import { SaveSettingsPayload, SettingsLevel } from 'lib/settings/settingsTypes'
|
||||
import {
|
||||
TEST_SETTINGS_KEY,
|
||||
TEST_SETTINGS_CORRUPTED,
|
||||
TEST_SETTINGS,
|
||||
} from './storageStates'
|
||||
import * as TOML from '@iarna/toml'
|
||||
|
||||
test.beforeEach(async ({ context, page }) => {
|
||||
await setup(context, page)
|
||||
test.beforeEach(async ({ context, page }, testInfo) => {
|
||||
await setup(context, page, testInfo)
|
||||
})
|
||||
|
||||
test.afterEach(async ({ page }, testInfo) => {
|
||||
@ -65,12 +69,15 @@ test.describe('Testing settings', () => {
|
||||
page,
|
||||
}) => {
|
||||
const u = await getUtils(page)
|
||||
await page.setViewportSize({ width: 1200, height: 500 })
|
||||
await u.waitForAuthSkipAppStart()
|
||||
await page
|
||||
.getByRole('button', { name: 'Start Sketch' })
|
||||
.waitFor({ state: 'visible' })
|
||||
await test.step(`Setup`, async () => {
|
||||
await page.setViewportSize({ width: 1200, height: 500 })
|
||||
await u.waitForAuthSkipAppStart()
|
||||
await page
|
||||
.getByRole('button', { name: 'Start Sketch' })
|
||||
.waitFor({ state: 'visible' })
|
||||
})
|
||||
|
||||
// Selectors and constants
|
||||
const paneButtonLocator = page.getByTestId('debug-pane-button')
|
||||
const headingLocator = page.getByRole('heading', {
|
||||
name: 'Settings',
|
||||
@ -78,11 +85,23 @@ test.describe('Testing settings', () => {
|
||||
})
|
||||
const inputLocator = page.locator('input[name="modeling-showDebugPanel"]')
|
||||
|
||||
// Open the settings modal with the browser keyboard shortcut
|
||||
await page.keyboard.press('ControlOrMeta+Shift+,')
|
||||
await test.step('Open settings dialog and set "Show debug panel" to on', async () => {
|
||||
await page.keyboard.press('ControlOrMeta+Shift+,')
|
||||
await expect(headingLocator).toBeVisible()
|
||||
|
||||
await expect(headingLocator).toBeVisible()
|
||||
await page.locator('#showDebugPanel').getByText('OffOn').click()
|
||||
/** Test to close https://github.com/KittyCAD/modeling-app/issues/2713 */
|
||||
await test.step(`Confirm that this dialog has a solid background`, async () => {
|
||||
await expect
|
||||
.poll(() => u.getGreatestPixDiff({ x: 600, y: 250 }, [28, 28, 28]), {
|
||||
timeout: 1000,
|
||||
message:
|
||||
'Checking for solid background, should not see default plane colors',
|
||||
})
|
||||
.toBeLessThan(15)
|
||||
})
|
||||
|
||||
await page.locator('#showDebugPanel').getByText('OffOn').click()
|
||||
})
|
||||
|
||||
// Close it and open again with keyboard shortcut, while KCL editor is focused
|
||||
// Put the cursor in the editor
|
||||
@ -154,29 +173,33 @@ test.describe('Testing settings', () => {
|
||||
|
||||
test('Project and user settings can be reset', async ({ page }) => {
|
||||
const u = await getUtils(page)
|
||||
await page.setViewportSize({ width: 1200, height: 500 })
|
||||
await u.waitForAuthSkipAppStart()
|
||||
await page
|
||||
.getByRole('button', { name: 'Start Sketch' })
|
||||
.waitFor({ state: 'visible' })
|
||||
await test.step(`Setup`, async () => {
|
||||
await page.setViewportSize({ width: 1200, height: 500 })
|
||||
await u.waitForAuthSkipAppStart()
|
||||
})
|
||||
|
||||
// Selectors and constants
|
||||
const projectSettingsTab = page.getByRole('radio', { name: 'Project' })
|
||||
const userSettingsTab = page.getByRole('radio', { name: 'User' })
|
||||
const resetButton = page.getByRole('button', {
|
||||
name: 'Restore default settings',
|
||||
})
|
||||
const resetButton = (level: SettingsLevel) =>
|
||||
page.getByRole('button', {
|
||||
name: `Reset ${level}-level settings`,
|
||||
})
|
||||
const themeColorSetting = page.locator('#themeColor').getByRole('slider')
|
||||
const settingValues = {
|
||||
default: '259',
|
||||
user: '120',
|
||||
project: '50',
|
||||
}
|
||||
const resetToast = (level: SettingsLevel) =>
|
||||
page.getByText(`${level}-level settings were reset`)
|
||||
|
||||
// Open the settings modal with lower-right button
|
||||
await page.getByRole('link', { name: 'Settings' }).last().click()
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Settings', exact: true })
|
||||
).toBeVisible()
|
||||
await test.step(`Open the settings modal`, async () => {
|
||||
await page.getByRole('link', { name: 'Settings' }).last().click()
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Settings', exact: true })
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
await test.step('Set up theme color', async () => {
|
||||
// Verify we're looking at the project-level settings,
|
||||
@ -195,37 +218,40 @@ test.describe('Testing settings', () => {
|
||||
|
||||
await test.step('Reset project settings', async () => {
|
||||
// Click the reset settings button.
|
||||
await resetButton.click()
|
||||
await resetButton('project').click()
|
||||
|
||||
await expect(page.getByText('Settings restored to default')).toBeVisible()
|
||||
await expect(
|
||||
page.getByText('Settings restored to default')
|
||||
).not.toBeVisible()
|
||||
await expect(resetToast('project')).toBeVisible()
|
||||
await expect(resetToast('project')).not.toBeVisible()
|
||||
|
||||
// Verify it is now set to the inherited user value
|
||||
await expect(themeColorSetting).toHaveValue(settingValues.default)
|
||||
await expect(themeColorSetting).toHaveValue(settingValues.user)
|
||||
|
||||
// Check that the user setting also rolled back
|
||||
await userSettingsTab.click()
|
||||
await expect(themeColorSetting).toHaveValue(settingValues.default)
|
||||
await projectSettingsTab.click()
|
||||
await test.step(`Check that the user settings did not change`, async () => {
|
||||
await userSettingsTab.click()
|
||||
await expect(themeColorSetting).toHaveValue(settingValues.user)
|
||||
})
|
||||
|
||||
// Set project-level value to 50 again to test the user-level reset
|
||||
await themeColorSetting.fill(settingValues.project)
|
||||
await userSettingsTab.click()
|
||||
await test.step(`Set project-level again to test the user-level reset`, async () => {
|
||||
await projectSettingsTab.click()
|
||||
await themeColorSetting.fill(settingValues.project)
|
||||
await userSettingsTab.click()
|
||||
})
|
||||
})
|
||||
|
||||
await test.step('Reset user settings', async () => {
|
||||
// Change the setting and click the reset settings button.
|
||||
await themeColorSetting.fill(settingValues.user)
|
||||
await resetButton.click()
|
||||
// Click the reset settings button.
|
||||
await resetButton('user').click()
|
||||
|
||||
await expect(resetToast('user')).toBeVisible()
|
||||
await expect(resetToast('user')).not.toBeVisible()
|
||||
|
||||
// Verify it is now set to the default value
|
||||
await expect(themeColorSetting).toHaveValue(settingValues.default)
|
||||
|
||||
// Check that the project setting also changed
|
||||
await projectSettingsTab.click()
|
||||
await expect(themeColorSetting).toHaveValue(settingValues.default)
|
||||
await test.step(`Check that the project settings did not change`, async () => {
|
||||
await projectSettingsTab.click()
|
||||
await expect(themeColorSetting).toHaveValue(settingValues.project)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -251,8 +277,6 @@ test.describe('Testing settings', () => {
|
||||
|
||||
await page.setViewportSize({ width: 1200, height: 500 })
|
||||
|
||||
page.on('console', console.log)
|
||||
|
||||
// Selectors and constants
|
||||
const userThemeColor = '120'
|
||||
const projectThemeColor = '50'
|
||||
@ -266,7 +290,6 @@ test.describe('Testing settings', () => {
|
||||
const projectLink = page.getByText('bracket')
|
||||
const logoLink = page.getByTestId('app-logo')
|
||||
|
||||
// Open the app and set the user theme color
|
||||
await test.step('Set user theme color on home', async () => {
|
||||
await expect(settingsOpenButton).toBeVisible()
|
||||
await settingsOpenButton.click()
|
||||
@ -285,13 +308,15 @@ test.describe('Testing settings', () => {
|
||||
await expect(projectSettingsTab).toBeChecked()
|
||||
await themeColorSetting.fill(projectThemeColor)
|
||||
await expect(logoLink).toHaveCSS('--primary-hue', projectThemeColor)
|
||||
await settingsCloseButton.click()
|
||||
})
|
||||
|
||||
await test.step('Refresh the application and see project setting applied', async () => {
|
||||
// Make sure we're done navigating before we reload
|
||||
await expect(settingsCloseButton).not.toBeVisible()
|
||||
await page.reload({ waitUntil: 'domcontentloaded' })
|
||||
|
||||
await expect(logoLink).toHaveCSS('--primary-hue', projectThemeColor)
|
||||
await settingsCloseButton.click()
|
||||
})
|
||||
|
||||
await test.step(`Navigate back to the home view and see user setting applied`, async () => {
|
||||
@ -429,25 +454,37 @@ test.describe('Testing settings', () => {
|
||||
|
||||
test('Changing modeling default unit', async ({ page }) => {
|
||||
const u = await getUtils(page)
|
||||
await page.setViewportSize({ width: 1200, height: 500 })
|
||||
await u.waitForAuthSkipAppStart()
|
||||
await page
|
||||
.getByRole('button', { name: 'Start Sketch' })
|
||||
.waitFor({ state: 'visible' })
|
||||
|
||||
const userSettingsTab = page.getByRole('radio', { name: 'User' })
|
||||
|
||||
// Open the settings modal with lower-right button
|
||||
await page.getByRole('link', { name: 'Settings' }).last().click()
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Settings', exact: true })
|
||||
).toBeVisible()
|
||||
|
||||
const resetButton = page.getByRole('button', {
|
||||
name: 'Restore default settings',
|
||||
await test.step(`Test setup`, async () => {
|
||||
await page.setViewportSize({ width: 1200, height: 500 })
|
||||
await u.waitForAuthSkipAppStart()
|
||||
await page
|
||||
.getByRole('button', { name: 'Start Sketch' })
|
||||
.waitFor({ state: 'visible' })
|
||||
})
|
||||
|
||||
// Selectors and constants
|
||||
const userSettingsTab = page.getByRole('radio', { name: 'User' })
|
||||
const projectSettingsTab = page.getByRole('radio', { name: 'Project' })
|
||||
const defaultUnitSection = page.getByText(
|
||||
'default unitRoll back default unitRoll back to match'
|
||||
)
|
||||
const defaultUnitRollbackButton = page.getByRole('button', {
|
||||
name: 'Roll back default unit',
|
||||
})
|
||||
|
||||
await test.step(`Open the settings modal`, async () => {
|
||||
await page.getByRole('link', { name: 'Settings' }).last().click()
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Settings', exact: true })
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
await test.step(`Reset unit setting`, async () => {
|
||||
await userSettingsTab.click()
|
||||
await defaultUnitSection.hover()
|
||||
await defaultUnitRollbackButton.click()
|
||||
await projectSettingsTab.click()
|
||||
})
|
||||
// Default unit should be mm
|
||||
await resetButton.click()
|
||||
|
||||
await test.step('Change modeling default unit within project tab', async () => {
|
||||
const changeUnitOfMeasureInProjectTab = async (unitOfMeasure: string) => {
|
||||
@ -552,4 +589,148 @@ test.describe('Testing settings', () => {
|
||||
await changeUnitOfMeasureInGizmo('m', 'Meters')
|
||||
})
|
||||
})
|
||||
|
||||
test('Changing theme in sketch mode', async ({ page }) => {
|
||||
const u = await getUtils(page)
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem(
|
||||
'persistCode',
|
||||
`const sketch001 = startSketchOn('XZ')
|
||||
|> startProfileAt([0, 0], %)
|
||||
|> line([5, 0], %)
|
||||
|> line([0, 5], %)
|
||||
|> line([-5, 0], %)
|
||||
|> lineTo([profileStartX(%), profileStartY(%)], %)
|
||||
|> close(%)
|
||||
const extrude001 = extrude(5, sketch001)
|
||||
`
|
||||
)
|
||||
})
|
||||
await page.setViewportSize({ width: 1200, height: 500 })
|
||||
|
||||
// Selectors and constants
|
||||
const editSketchButton = page.getByRole('button', { name: 'Edit Sketch' })
|
||||
const lineToolButton = page.getByTestId('line')
|
||||
const segmentOverlays = page.getByTestId('segment-overlay')
|
||||
const sketchOriginLocation = { x: 600, y: 250 }
|
||||
const darkThemeSegmentColor: [number, number, number] = [215, 215, 215]
|
||||
const lightThemeSegmentColor: [number, number, number] = [90, 90, 90]
|
||||
|
||||
await test.step(`Get into sketch mode`, async () => {
|
||||
await u.waitForAuthSkipAppStart()
|
||||
await page.mouse.click(700, 200)
|
||||
await expect(editSketchButton).toBeVisible()
|
||||
await editSketchButton.click()
|
||||
|
||||
// We use the line tool as a proxy for sketch mode
|
||||
await expect(lineToolButton).toBeVisible()
|
||||
await expect(segmentOverlays).toHaveCount(4)
|
||||
// but we allow more time to pass for animating to the sketch
|
||||
await page.waitForTimeout(1000)
|
||||
})
|
||||
|
||||
await test.step(`Check the sketch line color before`, async () => {
|
||||
await expect
|
||||
.poll(() =>
|
||||
u.getGreatestPixDiff(sketchOriginLocation, darkThemeSegmentColor)
|
||||
)
|
||||
.toBeLessThan(15)
|
||||
})
|
||||
|
||||
await test.step(`Change theme to light using command palette`, async () => {
|
||||
await page.keyboard.press('ControlOrMeta+K')
|
||||
await page.getByRole('option', { name: 'theme' }).click()
|
||||
await page.getByRole('option', { name: 'light' }).click()
|
||||
await expect(page.getByText('theme to "light"')).toBeVisible()
|
||||
|
||||
// Make sure we haven't left sketch mode
|
||||
await expect(lineToolButton).toBeVisible()
|
||||
})
|
||||
|
||||
await test.step(`Check the sketch line color after`, async () => {
|
||||
await expect
|
||||
.poll(() =>
|
||||
u.getGreatestPixDiff(sketchOriginLocation, lightThemeSegmentColor)
|
||||
)
|
||||
.toBeLessThan(15)
|
||||
})
|
||||
})
|
||||
|
||||
test(`Turning off "Show debug panel" with debug panel open leaves no phantom panel`, async ({
|
||||
page,
|
||||
}) => {
|
||||
const u = await getUtils(page)
|
||||
|
||||
// Override beforeEach test setup
|
||||
// with debug panel open
|
||||
// but "show debug panel" set to false
|
||||
await page.addInitScript(
|
||||
async ({ settingsKey, settings }) => {
|
||||
localStorage.setItem(settingsKey, settings)
|
||||
localStorage.setItem(
|
||||
'persistModelingContext',
|
||||
'{"openPanes":["debug"]}'
|
||||
)
|
||||
},
|
||||
{
|
||||
settingsKey: TEST_SETTINGS_KEY,
|
||||
settings: TOML.stringify({
|
||||
settings: {
|
||||
...TEST_SETTINGS,
|
||||
modeling: { ...TEST_SETTINGS.modeling, showDebugPanel: false },
|
||||
},
|
||||
}),
|
||||
}
|
||||
)
|
||||
await page.setViewportSize({ width: 1200, height: 500 })
|
||||
|
||||
// Constants and locators
|
||||
const resizeHandle = page.locator('.sidebar-resize-handles > div.block')
|
||||
const debugPaneButton = page.getByTestId('debug-pane-button')
|
||||
const commandsButton = page.getByRole('button', { name: 'Commands' })
|
||||
const debugPaneOption = page.getByRole('option', {
|
||||
name: 'Settings · modeling · show debug panel',
|
||||
})
|
||||
|
||||
async function setShowDebugPanelTo(value: 'On' | 'Off') {
|
||||
await commandsButton.click()
|
||||
await debugPaneOption.click()
|
||||
await page.getByRole('option', { name: value }).click()
|
||||
await expect(
|
||||
page.getByText(
|
||||
`Set show debug panel to "${value === 'On'}" for this project`
|
||||
)
|
||||
).toBeVisible()
|
||||
}
|
||||
|
||||
await test.step(`Initial load with corrupted settings`, async () => {
|
||||
await u.waitForAuthSkipAppStart()
|
||||
// Check that the debug panel is not visible
|
||||
await expect(debugPaneButton).not.toBeVisible()
|
||||
// Check the pane resize handle wrapper is not visible
|
||||
await expect(resizeHandle).not.toBeVisible()
|
||||
})
|
||||
|
||||
await test.step(`Open code pane to verify we see the resize handles`, async () => {
|
||||
await u.openKclCodePanel()
|
||||
await expect(resizeHandle).toBeVisible()
|
||||
await u.closeKclCodePanel()
|
||||
})
|
||||
|
||||
await test.step(`Turn on debug panel, open it`, async () => {
|
||||
await setShowDebugPanelTo('On')
|
||||
await expect(debugPaneButton).toBeVisible()
|
||||
// We want the logic to clear the phantom panel, so we shouldn't see
|
||||
// the real panel (and therefore the resize handle) yet
|
||||
await expect(resizeHandle).not.toBeVisible()
|
||||
await u.openDebugPanel()
|
||||
await expect(resizeHandle).toBeVisible()
|
||||
})
|
||||
|
||||
await test.step(`Turn off debug panel setting with it open`, async () => {
|
||||
await setShowDebugPanelTo('Off')
|
||||
await expect(debugPaneButton).not.toBeVisible()
|
||||
await expect(resizeHandle).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
@ -2,8 +2,8 @@ import { test, expect } from '@playwright/test'
|
||||
|
||||
import { doExport, getUtils, makeTemplate, setup, tearDown } from './test-utils'
|
||||
|
||||
test.beforeEach(async ({ context, page }) => {
|
||||
await setup(context, page)
|
||||
test.beforeEach(async ({ context, page }, testInfo) => {
|
||||
await setup(context, page, testInfo)
|
||||
})
|
||||
|
||||
test.afterEach(async ({ page }, testInfo) => {
|
||||
|
@ -1,12 +1,9 @@
|
||||
appId: dev.zoo.modeling-app
|
||||
|
||||
directories:
|
||||
output: out
|
||||
buildResources: assets
|
||||
|
||||
files:
|
||||
- .vite/**
|
||||
|
||||
mac:
|
||||
category: public.app-category.developer-tools
|
||||
artifactName: "${productName}-${version}-${arch}-${os}.${ext}"
|
||||
@ -28,7 +25,6 @@ mac:
|
||||
description: Zoo KCL File
|
||||
role: Editor
|
||||
rank: Owner
|
||||
|
||||
win:
|
||||
artifactName: "${productName}-${version}-${arch}-${os}.${ext}"
|
||||
target:
|
||||
@ -43,7 +39,7 @@ win:
|
||||
signingHashAlgorithms:
|
||||
- sha256
|
||||
sign: "./sign-win.js"
|
||||
publisherName: "KittyCAD Inc" # needs to be exactly like on Digicert
|
||||
publisherName: "KittyCAD Inc" # needs to be exactly like on Digicert
|
||||
icon: "assets/icon.ico"
|
||||
fileAssociations:
|
||||
- ext: kcl
|
||||
@ -51,18 +47,15 @@ win:
|
||||
mimeType: text/vnd.zoo.kcl
|
||||
description: Zoo KCL File
|
||||
role: Editor
|
||||
|
||||
msi:
|
||||
oneClick: false
|
||||
perMachine: true
|
||||
|
||||
nsis:
|
||||
oneClick: false
|
||||
perMachine: true
|
||||
allowElevation: true
|
||||
installerIcon: "assets/icon.ico"
|
||||
include: "./installer.nsh"
|
||||
|
||||
linux:
|
||||
artifactName: "${productName}-${version}-${arch}-${os}.${ext}"
|
||||
target:
|
||||
@ -76,7 +69,6 @@ linux:
|
||||
mimeType: text/vnd.zoo.kcl
|
||||
description: Zoo KCL File
|
||||
role: Editor
|
||||
|
||||
publish:
|
||||
- provider: generic
|
||||
url: https://dl.zoo.dev/releases/modeling-app
|
||||
|
@ -15,6 +15,7 @@
|
||||
/>
|
||||
<link rel="apple-touch-icon" href="/logo192.png" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<link rel="stylesheet" href="./inter/inter.css" />
|
||||
<link rel="stylesheet" href="https://use.typekit.net/zzv8rvm.css" />
|
||||
<script
|
||||
defer
|
||||
|
10
package.json
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "zoo-modeling-app",
|
||||
"version": "0.25.1",
|
||||
"version": "0.25.2",
|
||||
"private": true,
|
||||
"productName": "Zoo Modeling App",
|
||||
"author": {
|
||||
@ -34,7 +34,7 @@
|
||||
"@ts-stack/markdown": "^1.5.0",
|
||||
"@tweenjs/tween.js": "^23.1.1",
|
||||
"@xstate/inspect": "^0.8.0",
|
||||
"@xstate/react": "^3.2.2",
|
||||
"@xstate/react": "^4.1.1",
|
||||
"bonjour-service": "^1.2.1",
|
||||
"codemirror": "^6.0.1",
|
||||
"decamelize": "^6.0.0",
|
||||
@ -64,7 +64,7 @@
|
||||
"vscode-languageserver-protocol": "^3.17.5",
|
||||
"vscode-uri": "^3.0.8",
|
||||
"web-vitals": "^3.5.2",
|
||||
"xstate": "^4.38.2"
|
||||
"xstate": "^5.17.4"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "vite",
|
||||
@ -88,7 +88,7 @@
|
||||
"build:wasm": "yarn wasm-prep && cd src/wasm-lib && wasm-pack build --release --target web --out-dir pkg && cargo test -p kcl-lib export_bindings && cd ../.. && yarn isomorphic-copy-wasm && yarn fmt",
|
||||
"remove-importmeta": "sed -i 's/import.meta.url/window.location.origin/g' \"./src/wasm-lib/pkg/wasm_lib.js\"; sed -i '' 's/import.meta.url/window.location.origin/g' \"./src/wasm-lib/pkg/wasm_lib.js\" || echo \"sed for both mac and linux\"",
|
||||
"wasm-prep": "rimraf src/wasm-lib/pkg && mkdirp src/wasm-lib/pkg && rimraf src/wasm-lib/kcl/bindings",
|
||||
"lint": "eslint --fix src e2e",
|
||||
"lint": "eslint --fix src e2e packages/codemirror-lsp-client",
|
||||
"bump-jsons": "echo \"$(jq --arg v \"$VERSION\" '.version=$v' package.json --indent 2)\" > package.json",
|
||||
"postinstall": "yarn xstate:typegen && ./node_modules/.bin/electron-rebuild",
|
||||
"xstate:typegen": "yarn xstate typegen \"src/**/*.ts?(x)\"",
|
||||
@ -183,7 +183,7 @@
|
||||
"tailwindcss": "^3.4.1",
|
||||
"ts-node": "^10.0.0",
|
||||
"typescript": "^5.0.0",
|
||||
"vite": "^5.4.2",
|
||||
"vite": "^5.4.3",
|
||||
"vite-plugin-eslint": "^1.8.1",
|
||||
"vite-plugin-package-version": "^1.1.0",
|
||||
"vite-tsconfig-paths": "^4.3.2",
|
||||
|
@ -72,6 +72,7 @@ export class LanguageServerClient {
|
||||
async initialize() {
|
||||
// Start the client in the background.
|
||||
this.client.setNotifyFn(this.processNotifications.bind(this))
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
this.client.start()
|
||||
|
||||
this.ready = true
|
||||
@ -195,6 +196,9 @@ export class LanguageServerClient {
|
||||
}
|
||||
|
||||
private processNotifications(notification: LSP.NotificationMessage) {
|
||||
for (const plugin of this.plugins) plugin.processNotification(notification)
|
||||
for (const plugin of this.plugins) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
plugin.processNotification(notification)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -12,6 +12,7 @@ export default function lspFormatExt(
|
||||
run: (view: EditorView) => {
|
||||
let value = view.plugin(plugin)
|
||||
if (!value) return false
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
value.requestFormatting()
|
||||
return true
|
||||
},
|
||||
|
@ -117,6 +117,7 @@ export class LanguageServerPlugin implements PluginValue {
|
||||
|
||||
this.processLspNotification = options.processLspNotification
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
this.initialize({
|
||||
documentText: this.getDocText(),
|
||||
})
|
||||
@ -149,6 +150,7 @@ export class LanguageServerPlugin implements PluginValue {
|
||||
}
|
||||
|
||||
async initialize({ documentText }: { documentText: string }) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises
|
||||
if (this.client.initializePromise) {
|
||||
await this.client.initializePromise
|
||||
}
|
||||
@ -162,7 +164,9 @@ export class LanguageServerPlugin implements PluginValue {
|
||||
},
|
||||
})
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
this.requestSemanticTokens()
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
this.updateFoldingRanges()
|
||||
}
|
||||
|
||||
@ -225,7 +229,9 @@ export class LanguageServerPlugin implements PluginValue {
|
||||
contentChanges: [{ text: this.view.state.doc.toString() }],
|
||||
})
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
this.requestSemanticTokens()
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
this.updateFoldingRanges()
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
@ -526,7 +532,9 @@ export class LanguageServerPlugin implements PluginValue {
|
||||
processDiagnostics(params: PublishDiagnosticsParams) {
|
||||
if (params.uri !== this.getDocUri()) return
|
||||
|
||||
const diagnostics = params.diagnostics
|
||||
// Commented to avoid the lint. See TODO below.
|
||||
// const diagnostics =
|
||||
params.diagnostics
|
||||
.map(({ range, message, severity }) => ({
|
||||
from: posToOffset(this.view.state.doc, range.start)!,
|
||||
to: posToOffset(this.view.state.doc, range.end)!,
|
||||
|
@ -57,10 +57,10 @@ export default defineConfig({
|
||||
},
|
||||
}, // or 'chrome-beta'
|
||||
},
|
||||
{
|
||||
name: 'webkit',
|
||||
use: { ...devices['Desktop Safari'] },
|
||||
},
|
||||
// {
|
||||
// name: 'webkit',
|
||||
// use: { ...devices['Desktop Safari'] },
|
||||
// },
|
||||
// {
|
||||
// name: 'firefox',
|
||||
// use: { ...devices['Desktop Firefox'] },
|
||||
|
BIN
public/inter/InterVariable-Italic.woff2
Normal file
BIN
public/inter/InterVariable.woff2
Normal file
14
public/inter/inter.css
Normal file
@ -0,0 +1,14 @@
|
||||
@font-face {
|
||||
font-family: Inter;
|
||||
font-style: normal;
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
src: url("InterVariable.woff2") format("woff2");
|
||||
}
|
||||
@font-face {
|
||||
font-family: Inter;
|
||||
font-style: italic;
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
src: url("InterVariable-Italic.woff2") format("woff2");
|
||||
}
|
59
src/App.tsx
@ -1,12 +1,8 @@
|
||||
import { MouseEventHandler, useEffect, useMemo, useRef } from 'react'
|
||||
import { uuidv4 } from 'lib/utils'
|
||||
import { useEffect, useMemo, useRef } from 'react'
|
||||
import { useHotKeyListener } from './hooks/useHotKeyListener'
|
||||
import { Stream } from './components/Stream'
|
||||
import { EngineCommand } from 'lang/std/artifactGraph'
|
||||
import { throttle } from './lib/utils'
|
||||
import { AppHeader } from './components/AppHeader'
|
||||
import { useHotkeys } from 'react-hotkeys-hook'
|
||||
import { getNormalisedCoordinates } from './lib/utils'
|
||||
import { useLoaderData, useNavigate } from 'react-router-dom'
|
||||
import { type IndexLoaderData } from 'lib/types'
|
||||
import { PATHS } from 'lib/paths'
|
||||
@ -14,7 +10,6 @@ import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
|
||||
import { onboardingPaths } from 'routes/Onboarding/paths'
|
||||
import { useEngineConnectionSubscriptions } from 'hooks/useEngineConnectionSubscriptions'
|
||||
import { codeManager, engineCommandManager } from 'lib/singletons'
|
||||
import { useModelingContext } from 'hooks/useModelingContext'
|
||||
import { useAbsoluteFilePath } from 'hooks/useAbsoluteFilePath'
|
||||
import { isDesktop } from 'lib/isDesktop'
|
||||
import { useLspContext } from 'components/LspProvider'
|
||||
@ -44,7 +39,6 @@ export function App() {
|
||||
}, [projectName, projectPath])
|
||||
|
||||
useHotKeyListener()
|
||||
const { context, state } = useModelingContext()
|
||||
|
||||
const { auth, settings } = useSettingsAuthContext()
|
||||
const token = auth?.context?.token
|
||||
@ -73,61 +67,14 @@ export function App() {
|
||||
(p) => p === onboardingStatus.current
|
||||
)
|
||||
? 'opacity-20'
|
||||
: context.store?.didDragInStream
|
||||
? 'opacity-40'
|
||||
: ''
|
||||
|
||||
useEngineConnectionSubscriptions()
|
||||
|
||||
const debounceSocketSend = throttle<EngineCommand>((message) => {
|
||||
engineCommandManager.sendSceneCommand(message)
|
||||
}, 1000 / 15)
|
||||
const handleMouseMove: MouseEventHandler<HTMLDivElement> = (e) => {
|
||||
if (state.matches('Sketch')) {
|
||||
return
|
||||
}
|
||||
|
||||
const { x, y } = getNormalisedCoordinates({
|
||||
clientX: e.clientX,
|
||||
clientY: e.clientY,
|
||||
el: e.currentTarget,
|
||||
...context.store?.streamDimensions,
|
||||
})
|
||||
|
||||
const newCmdId = uuidv4()
|
||||
if (state.matches('idle.showPlanes')) return
|
||||
if (context.store?.buttonDownInStream !== undefined) return
|
||||
debounceSocketSend({
|
||||
type: 'modeling_cmd_req',
|
||||
cmd: {
|
||||
type: 'highlight_set_entity',
|
||||
selected_at_window: { x, y },
|
||||
},
|
||||
cmd_id: newCmdId,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative h-full flex flex-col"
|
||||
onMouseMove={handleMouseMove}
|
||||
ref={ref}
|
||||
>
|
||||
<div className="relative h-full flex flex-col" ref={ref}>
|
||||
<AppHeader
|
||||
className={
|
||||
'transition-opacity transition-duration-75 ' +
|
||||
paneOpacity +
|
||||
(context.store?.buttonDownInStream ? ' pointer-events-none' : '')
|
||||
}
|
||||
// Override the electron window draggable region behavior as well
|
||||
// when the button is down in the stream
|
||||
style={
|
||||
isDesktop() && context.store?.buttonDownInStream
|
||||
? ({
|
||||
'-webkit-app-region': 'no-drag',
|
||||
} as React.CSSProperties)
|
||||
: {}
|
||||
}
|
||||
className={'transition-opacity transition-duration-75 ' + paneOpacity}
|
||||
project={{ project, file }}
|
||||
enableMenu={true}
|
||||
/>
|
||||
|
@ -41,6 +41,7 @@ import toast from 'react-hot-toast'
|
||||
import { coreDump } from 'lang/wasm'
|
||||
import { useMemo } from 'react'
|
||||
import { AppStateProvider } from 'AppState'
|
||||
import { reportRejection } from 'lib/trap'
|
||||
|
||||
const createRouter = isDesktop() ? createHashRouter : createBrowserRouter
|
||||
|
||||
@ -173,21 +174,23 @@ function CoreDump() {
|
||||
[]
|
||||
)
|
||||
useHotkeyWrapper(['mod + shift + .'], () => {
|
||||
toast.promise(
|
||||
coreDump(coreDumpManager, true),
|
||||
{
|
||||
loading: 'Starting core dump...',
|
||||
success: 'Core dump completed successfully',
|
||||
error: 'Error while exporting core dump',
|
||||
},
|
||||
{
|
||||
success: {
|
||||
// Note: this extended duration is especially important for Playwright e2e testing
|
||||
// default duration is 2000 - https://react-hot-toast.com/docs/toast#default-durations
|
||||
duration: 6000,
|
||||
toast
|
||||
.promise(
|
||||
coreDump(coreDumpManager, true),
|
||||
{
|
||||
loading: 'Starting core dump...',
|
||||
success: 'Core dump completed successfully',
|
||||
error: 'Error while exporting core dump',
|
||||
},
|
||||
}
|
||||
)
|
||||
{
|
||||
success: {
|
||||
// Note: this extended duration is especially important for Playwright e2e testing
|
||||
// default duration is 2000 - https://react-hot-toast.com/docs/toast#default-durations
|
||||
duration: 6000,
|
||||
},
|
||||
}
|
||||
)
|
||||
.catch(reportRejection)
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
@ -70,12 +70,12 @@ export function Toolbar({
|
||||
*/
|
||||
const configCallbackProps: ToolbarItemCallbackProps = useMemo(
|
||||
() => ({
|
||||
modelingStateMatches: state.matches,
|
||||
modelingState: state,
|
||||
modelingSend: send,
|
||||
commandBarSend,
|
||||
sketchPathId,
|
||||
}),
|
||||
[state.matches, send, commandBarSend, sketchPathId]
|
||||
[state, send, commandBarSend, sketchPathId]
|
||||
)
|
||||
|
||||
/**
|
||||
@ -124,7 +124,7 @@ export function Toolbar({
|
||||
}, [currentMode, disableAllButtons, configCallbackProps])
|
||||
|
||||
return (
|
||||
<menu className="max-w-full whitespace-nowrap rounded-b px-2 py-1 bg-chalkboard-10 dark:bg-chalkboard-90 relative border border-chalkboard-20 dark:border-chalkboard-80 border-t-0 shadow-sm">
|
||||
<menu className="max-w-full whitespace-nowrap rounded-b px-2 py-1 bg-chalkboard-10 dark:bg-chalkboard-90 relative border border-chalkboard-30 dark:border-chalkboard-80 border-t-0 shadow-sm">
|
||||
<ul
|
||||
{...props}
|
||||
ref={toolbarButtonsRef}
|
||||
|
@ -22,14 +22,16 @@ import {
|
||||
UnreliableSubscription,
|
||||
} from 'lang/std/engineConnection'
|
||||
import { EngineCommand } from 'lang/std/artifactGraph'
|
||||
import { uuidv4 } from 'lib/utils'
|
||||
import { toSync, uuidv4 } from 'lib/utils'
|
||||
import { deg2Rad } from 'lib/utils2d'
|
||||
import { isReducedMotion, roundOff, throttle } from 'lib/utils'
|
||||
import * as TWEEN from '@tweenjs/tween.js'
|
||||
import { isQuaternionVertical } from './helpers'
|
||||
import { reportRejection } from 'lib/trap'
|
||||
|
||||
const ORTHOGRAPHIC_CAMERA_SIZE = 20
|
||||
const FRAMES_TO_ANIMATE_IN = 30
|
||||
const ORTHOGRAPHIC_MAGIC_FOV = 4
|
||||
|
||||
const tempQuaternion = new Quaternion() // just used for maths
|
||||
|
||||
@ -83,7 +85,7 @@ export class CameraControls {
|
||||
pendingPan: Vector2 | null = null
|
||||
interactionGuards: MouseGuard = cameraMouseDragGuards.KittyCAD
|
||||
isFovAnimationInProgress = false
|
||||
fovBeforeOrtho = 45
|
||||
perspectiveFovBeforeOrtho = 45
|
||||
get isPerspective() {
|
||||
return this.camera instanceof PerspectiveCamera
|
||||
}
|
||||
@ -100,6 +102,7 @@ export class CameraControls {
|
||||
camProps.type === 'perspective' &&
|
||||
this.camera instanceof OrthographicCamera
|
||||
) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
this.usePerspectiveCamera()
|
||||
} else if (
|
||||
camProps.type === 'orthographic' &&
|
||||
@ -127,6 +130,7 @@ export class CameraControls {
|
||||
}
|
||||
|
||||
throttledEngCmd = throttle((cmd: EngineCommand) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
this.engineCommandManager.sendSceneCommand(cmd)
|
||||
}, 1000 / 30)
|
||||
|
||||
@ -139,6 +143,7 @@ export class CameraControls {
|
||||
...convertThreeCamValuesToEngineCam(threeValues),
|
||||
},
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
this.engineCommandManager.sendSceneCommand(cmd)
|
||||
}, 1000 / 15)
|
||||
|
||||
@ -151,6 +156,7 @@ export class CameraControls {
|
||||
this.lastPerspectiveCmd &&
|
||||
Date.now() - this.lastPerspectiveCmdTime >= lastCmdDelay
|
||||
) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
this.engineCommandManager.sendSceneCommand(this.lastPerspectiveCmd, true)
|
||||
this.lastPerspectiveCmdTime = Date.now()
|
||||
}
|
||||
@ -218,6 +224,7 @@ export class CameraControls {
|
||||
this.useOrthographicCamera()
|
||||
}
|
||||
if (this.camera instanceof OrthographicCamera && !camSettings.ortho) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
this.usePerspectiveCamera()
|
||||
}
|
||||
if (this.camera instanceof PerspectiveCamera && camSettings.fov_y) {
|
||||
@ -249,6 +256,7 @@ export class CameraControls {
|
||||
const doZoom = () => {
|
||||
if (this.zoomDataFromLastFrame !== undefined) {
|
||||
this.handleStart()
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
this.engineCommandManager.sendSceneCommand({
|
||||
type: 'modeling_cmd_req',
|
||||
cmd: {
|
||||
@ -266,6 +274,7 @@ export class CameraControls {
|
||||
|
||||
const doMove = () => {
|
||||
if (this.moveDataFromLastFrame !== undefined) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
this.engineCommandManager.sendSceneCommand({
|
||||
type: 'modeling_cmd_req',
|
||||
cmd: {
|
||||
@ -335,7 +344,8 @@ export class CameraControls {
|
||||
this.camera.updateProjectionMatrix()
|
||||
}
|
||||
|
||||
onMouseDown = (event: MouseEvent) => {
|
||||
onMouseDown = (event: PointerEvent) => {
|
||||
this.domElement.setPointerCapture(event.pointerId)
|
||||
this.isDragging = true
|
||||
this.mouseDownPosition.set(event.clientX, event.clientY)
|
||||
let interaction = this.getInteractionType(event)
|
||||
@ -355,7 +365,7 @@ export class CameraControls {
|
||||
}
|
||||
}
|
||||
|
||||
onMouseMove = (event: MouseEvent) => {
|
||||
onMouseMove = (event: PointerEvent) => {
|
||||
if (this.isDragging) {
|
||||
this.mouseNewPosition.set(event.clientX, event.clientY)
|
||||
const deltaMove = this.mouseNewPosition
|
||||
@ -389,14 +399,33 @@ export class CameraControls {
|
||||
const zoomFudgeFactor = 2280
|
||||
distance = zoomFudgeFactor / (this.camera.zoom * 45)
|
||||
}
|
||||
const panSpeed = (distance / 1000 / 45) * this.fovBeforeOrtho
|
||||
const panSpeed = (distance / 1000 / 45) * this.perspectiveFovBeforeOrtho
|
||||
this.pendingPan.x += -deltaMove.x * panSpeed
|
||||
this.pendingPan.y += deltaMove.y * panSpeed
|
||||
}
|
||||
} else {
|
||||
/**
|
||||
* If we're not in sketch mode and not dragging, we can highlight entities
|
||||
* under the cursor. This recently moved from being handled in App.tsx.
|
||||
* This might not be the right spot, but it is more consolidated.
|
||||
*/
|
||||
if (this.syncDirection === 'engineToClient') {
|
||||
const newCmdId = uuidv4()
|
||||
|
||||
this.throttledEngCmd({
|
||||
type: 'modeling_cmd_req',
|
||||
cmd: {
|
||||
type: 'highlight_set_entity',
|
||||
selected_at_window: { x: event.clientX, y: event.clientY },
|
||||
},
|
||||
cmd_id: newCmdId,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMouseUp = (event: MouseEvent) => {
|
||||
onMouseUp = (event: PointerEvent) => {
|
||||
this.domElement.releasePointerCapture(event.pointerId)
|
||||
this.isDragging = false
|
||||
this.handleEnd()
|
||||
if (this.syncDirection === 'engineToClient') {
|
||||
@ -415,8 +444,19 @@ export class CameraControls {
|
||||
}
|
||||
|
||||
onMouseWheel = (event: WheelEvent) => {
|
||||
const interaction = this.getInteractionType(event)
|
||||
if (interaction === 'none') return
|
||||
event.preventDefault()
|
||||
|
||||
if (this.syncDirection === 'engineToClient') {
|
||||
this.zoomDataFromLastFrame = event.deltaY
|
||||
if (interaction === 'zoom') {
|
||||
this.zoomDataFromLastFrame = event.deltaY
|
||||
} else {
|
||||
// This case will get handled when we add pan and rotate using Apple trackpad.
|
||||
console.error(
|
||||
`Unexpected interaction type for engineToClient wheel event: ${interaction}`
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@ -426,8 +466,16 @@ export class CameraControls {
|
||||
// zoom commands to engine. This means dropping some zoom
|
||||
// commands too.
|
||||
// From onMouseMove zoom handling which seems to be really smooth
|
||||
|
||||
this.handleStart()
|
||||
this.pendingZoom = 1 + (event.deltaY / window.devicePixelRatio) * 0.001
|
||||
if (interaction === 'zoom') {
|
||||
this.pendingZoom = 1 + (event.deltaY / window.devicePixelRatio) * 0.001
|
||||
} else {
|
||||
// This case will get handled when we add pan and rotate using Apple trackpad.
|
||||
console.error(
|
||||
`Unexpected interaction type for wheel event: ${interaction}`
|
||||
)
|
||||
}
|
||||
this.handleEnd()
|
||||
}
|
||||
|
||||
@ -459,6 +507,7 @@ export class CameraControls {
|
||||
|
||||
this.camera.quaternion.set(qx, qy, qz, qw)
|
||||
this.camera.updateProjectionMatrix()
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
this.engineCommandManager.sendSceneCommand({
|
||||
type: 'modeling_cmd_req',
|
||||
cmd_id: uuidv4(),
|
||||
@ -487,19 +536,15 @@ export class CameraControls {
|
||||
_usePerspectiveCamera = () => {
|
||||
const { x: px, y: py, z: pz } = this.camera.position
|
||||
const { x: qx, y: qy, z: qz, w: qw } = this.camera.quaternion
|
||||
const zoom = this.camera.zoom
|
||||
this.camera = this.createPerspectiveCamera()
|
||||
|
||||
this.camera.position.set(px, py, pz)
|
||||
this.camera.quaternion.set(qx, qy, qz, qw)
|
||||
const zoomFudgeFactor = 2280
|
||||
const distance = zoomFudgeFactor / (zoom * this.lastPerspectiveFov)
|
||||
const direction = new Vector3().subVectors(
|
||||
this.camera.position,
|
||||
this.target
|
||||
)
|
||||
direction.normalize()
|
||||
this.camera.position.copy(this.target).addScaledVector(direction, distance)
|
||||
}
|
||||
usePerspectiveCamera = async (forceSend = false) => {
|
||||
this._usePerspectiveCamera()
|
||||
@ -541,7 +586,7 @@ export class CameraControls {
|
||||
const oldFov = this.camera.fov
|
||||
|
||||
const viewHeightFactor = (fov: number) => {
|
||||
/* *
|
||||
/* *
|
||||
/|
|
||||
/ |
|
||||
/ |
|
||||
@ -929,6 +974,7 @@ export class CameraControls {
|
||||
}
|
||||
|
||||
if (isReducedMotion()) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
onComplete()
|
||||
return
|
||||
}
|
||||
@ -937,7 +983,7 @@ export class CameraControls {
|
||||
.to({ t: tweenEnd }, duration)
|
||||
.easing(TWEEN.Easing.Quadratic.InOut)
|
||||
.onUpdate(({ t }) => cameraAtTime(t))
|
||||
.onComplete(onComplete)
|
||||
.onComplete(toSync(onComplete, reportRejection))
|
||||
.start()
|
||||
})
|
||||
}
|
||||
@ -950,9 +996,9 @@ export class CameraControls {
|
||||
)
|
||||
this.isFovAnimationInProgress = true
|
||||
let currentFov = this.lastPerspectiveFov
|
||||
this.fovBeforeOrtho = currentFov
|
||||
this.perspectiveFovBeforeOrtho = currentFov
|
||||
|
||||
const targetFov = 4
|
||||
const targetFov = ORTHOGRAPHIC_MAGIC_FOV
|
||||
const fovAnimationStep = (currentFov - targetFov) / FRAMES_TO_ANIMATE_IN
|
||||
let frameWaitOnFinish = 10
|
||||
|
||||
@ -962,6 +1008,7 @@ export class CameraControls {
|
||||
// Decrease the FOV
|
||||
currentFov = Math.max(currentFov - fovAnimationStep, targetFov)
|
||||
this.camera.updateProjectionMatrix()
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
this.dollyZoom(currentFov)
|
||||
requestAnimationFrame(animateFovChange) // Continue the animation
|
||||
} else if (frameWaitOnFinish > 0) {
|
||||
@ -987,10 +1034,11 @@ export class CameraControls {
|
||||
)
|
||||
}
|
||||
this.isFovAnimationInProgress = true
|
||||
const targetFov = this.fovBeforeOrtho // Target FOV for perspective
|
||||
this.lastPerspectiveFov = 4
|
||||
let currentFov = 4
|
||||
const targetFov = this.perspectiveFovBeforeOrtho // Target FOV for perspective
|
||||
this.lastPerspectiveFov = ORTHOGRAPHIC_MAGIC_FOV
|
||||
let currentFov = ORTHOGRAPHIC_MAGIC_FOV
|
||||
const initialCameraUp = this.camera.up.clone()
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
this.usePerspectiveCamera()
|
||||
const tempVec = new Vector3()
|
||||
|
||||
@ -999,6 +1047,7 @@ export class CameraControls {
|
||||
this.lastPerspectiveFov + (targetFov - this.lastPerspectiveFov) * t
|
||||
const currentUp = tempVec.lerpVectors(initialCameraUp, targetCamUp, t)
|
||||
this.camera.up.copy(currentUp)
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
this.dollyZoom(currentFov)
|
||||
}
|
||||
|
||||
@ -1023,10 +1072,10 @@ export class CameraControls {
|
||||
)
|
||||
}
|
||||
this.isFovAnimationInProgress = true
|
||||
const targetFov = this.fovBeforeOrtho // Target FOV for perspective
|
||||
this.lastPerspectiveFov = 4
|
||||
let currentFov = 4
|
||||
const targetFov = this.perspectiveFovBeforeOrtho // Target FOV for perspective
|
||||
let currentFov = ORTHOGRAPHIC_MAGIC_FOV
|
||||
const initialCameraUp = this.camera.up.clone()
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
this.usePerspectiveCamera()
|
||||
const tempVec = new Vector3()
|
||||
|
||||
@ -1093,7 +1142,7 @@ export class CameraControls {
|
||||
this.deferReactUpdate(this.reactCameraProperties)
|
||||
Object.values(this._camChangeCallbacks).forEach((cb) => cb())
|
||||
}
|
||||
getInteractionType = (event: any) =>
|
||||
getInteractionType = (event: MouseEvent) =>
|
||||
_getInteractionType(
|
||||
this.interactionGuards,
|
||||
event,
|
||||
@ -1175,7 +1224,7 @@ function convertThreeCamValuesToEngineCam({
|
||||
const lookAt = buildLookAt(64 / zoom, target, position)
|
||||
return {
|
||||
center: new Vector3(lookAt.center.x, lookAt.center.y, lookAt.center.z),
|
||||
up: new Vector3(0, 0, 1),
|
||||
up: new Vector3(upVector.x, upVector.y, upVector.z),
|
||||
vantage: new Vector3(lookAt.eye.x, lookAt.eye.y, lookAt.eye.z),
|
||||
}
|
||||
}
|
||||
@ -1201,16 +1250,21 @@ function _lookAt(position: Vector3, target: Vector3, up: Vector3): Quaternion {
|
||||
|
||||
function _getInteractionType(
|
||||
interactionGuards: MouseGuard,
|
||||
event: any,
|
||||
event: MouseEvent | WheelEvent,
|
||||
enablePan: boolean,
|
||||
enableRotate: boolean,
|
||||
enableZoom: boolean
|
||||
): interactionType | 'none' {
|
||||
let state: interactionType | 'none' = 'none'
|
||||
if (enablePan && interactionGuards.pan.callback(event)) return 'pan'
|
||||
if (enableRotate && interactionGuards.rotate.callback(event)) return 'rotate'
|
||||
if (enableZoom && interactionGuards.zoom.dragCallback(event)) return 'zoom'
|
||||
return state
|
||||
if (event instanceof WheelEvent) {
|
||||
if (enableZoom && interactionGuards.zoom.scrollCallback(event))
|
||||
return 'zoom'
|
||||
} else {
|
||||
if (enablePan && interactionGuards.pan.callback(event)) return 'pan'
|
||||
if (enableRotate && interactionGuards.rotate.callback(event))
|
||||
return 'rotate'
|
||||
if (enableZoom && interactionGuards.zoom.dragCallback(event)) return 'zoom'
|
||||
}
|
||||
return 'none'
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -5,7 +5,7 @@ import { cameraMouseDragGuards } from 'lib/cameraControls'
|
||||
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
|
||||
import { ARROWHEAD, DEBUG_SHOW_BOTH_SCENES } from './sceneInfra'
|
||||
import { ReactCameraProperties } from './CameraControls'
|
||||
import { throttle } from 'lib/utils'
|
||||
import { throttle, toSync } from 'lib/utils'
|
||||
import {
|
||||
sceneInfra,
|
||||
kclManager,
|
||||
@ -34,17 +34,15 @@ import { CustomIcon, CustomIconName } from 'components/CustomIcon'
|
||||
import { ConstrainInfo } from 'lang/std/stdTypes'
|
||||
import { getConstraintInfo } from 'lang/std/sketch'
|
||||
import { Dialog, Popover, Transition } from '@headlessui/react'
|
||||
import { LineInputsType } from 'lang/std/sketchcombos'
|
||||
import toast from 'react-hot-toast'
|
||||
import { InstanceProps, create } from 'react-modal-promise'
|
||||
import { executeAst } from 'lang/langHelpers'
|
||||
import {
|
||||
deleteSegmentFromPipeExpression,
|
||||
makeRemoveSingleConstraintInput,
|
||||
removeSingleConstraintInfo,
|
||||
} from 'lang/modifyAst'
|
||||
import { ActionButton } from 'components/ActionButton'
|
||||
import { err, trap } from 'lib/trap'
|
||||
import { err, reportRejection, trap } from 'lib/trap'
|
||||
|
||||
function useShouldHideScene(): { hideClient: boolean; hideServer: boolean } {
|
||||
const [isCamMoving, setIsCamMoving] = useState(false)
|
||||
@ -124,9 +122,9 @@ export const ClientSideScene = ({
|
||||
} else if (context.mouseState.type === 'isDragging') {
|
||||
cursor = 'grabbing'
|
||||
} else if (
|
||||
state.matches('Sketch.Line tool') ||
|
||||
state.matches('Sketch.Tangential arc to') ||
|
||||
state.matches('Sketch.Rectangle tool')
|
||||
state.matches({ Sketch: 'Line tool' }) ||
|
||||
state.matches({ Sketch: 'Tangential arc to' }) ||
|
||||
state.matches({ Sketch: 'Rectangle tool' })
|
||||
) {
|
||||
cursor = 'crosshair'
|
||||
} else {
|
||||
@ -214,9 +212,9 @@ const Overlay = ({
|
||||
overlay.visible &&
|
||||
typeof context?.segmentHoverMap?.[pathToNodeString] === 'number' &&
|
||||
!(
|
||||
state.matches('Sketch.Line tool') ||
|
||||
state.matches('Sketch.Tangential arc to') ||
|
||||
state.matches('Sketch.Rectangle tool')
|
||||
state.matches({ Sketch: 'Line tool' }) ||
|
||||
state.matches({ Sketch: 'Tangential arc to' }) ||
|
||||
state.matches({ Sketch: 'Rectangle tool' })
|
||||
)
|
||||
|
||||
return (
|
||||
@ -542,12 +540,10 @@ const ConstraintSymbol = ({
|
||||
iconName: 'dimension',
|
||||
},
|
||||
}
|
||||
const varName =
|
||||
_type in varNameMap ? varNameMap[_type as LineInputsType].varName : 'var'
|
||||
const name: CustomIconName = varNameMap[_type as LineInputsType].iconName
|
||||
const displayName = varNameMap[_type as LineInputsType]?.displayName
|
||||
const implicitDesc =
|
||||
varNameMap[_type as LineInputsType]?.implicitConstraintDesc
|
||||
const varName = varNameMap?.[_type]?.varName || 'var'
|
||||
const name: CustomIconName = varNameMap[_type].iconName
|
||||
const displayName = varNameMap[_type]?.displayName
|
||||
const implicitDesc = varNameMap[_type]?.implicitConstraintDesc
|
||||
|
||||
const _node = useMemo(
|
||||
() => getNodeFromPath<Expr>(kclManager.ast, pathToNode),
|
||||
@ -582,7 +578,7 @@ const ConstraintSymbol = ({
|
||||
}}
|
||||
// disabled={isConstrained || !convertToVarEnabled}
|
||||
// disabled={implicitDesc} TODO why does this change styles that are hard to override?
|
||||
onClick={async () => {
|
||||
onClick={toSync(async () => {
|
||||
if (!isConstrained) {
|
||||
send({
|
||||
type: 'Convert to variable',
|
||||
@ -604,25 +600,23 @@ const ConstraintSymbol = ({
|
||||
if (trap(_node1)) return Promise.reject(_node1)
|
||||
const shallowPath = _node1.shallowPath
|
||||
|
||||
const input = makeRemoveSingleConstraintInput(
|
||||
argPosition,
|
||||
shallowPath
|
||||
)
|
||||
if (!input || !context.sketchDetails) return
|
||||
if (!context.sketchDetails || !argPosition) return
|
||||
const transform = removeSingleConstraintInfo(
|
||||
input,
|
||||
shallowPath,
|
||||
argPosition,
|
||||
kclManager.ast,
|
||||
kclManager.programMemory
|
||||
)
|
||||
if (!transform) return
|
||||
const { modifiedAst } = transform
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
kclManager.updateAst(modifiedAst, true)
|
||||
} catch (e) {
|
||||
console.log('error', e)
|
||||
}
|
||||
toast.success('Constraint removed')
|
||||
}
|
||||
}}
|
||||
}, reportRejection)}
|
||||
>
|
||||
<CustomIcon name={name} />
|
||||
</button>
|
||||
@ -688,7 +682,7 @@ const ConstraintSymbol = ({
|
||||
|
||||
const throttled = throttle((a: ReactCameraProperties) => {
|
||||
if (a.type === 'perspective' && a.fov) {
|
||||
sceneInfra.camControls.dollyZoom(a.fov)
|
||||
sceneInfra.camControls.dollyZoom(a.fov).catch(reportRejection)
|
||||
}
|
||||
}, 1000 / 15)
|
||||
|
||||
@ -718,6 +712,7 @@ export const CamDebugSettings = () => {
|
||||
if (camSettings.type === 'perspective') {
|
||||
sceneInfra.camControls.useOrthographicCamera()
|
||||
} else {
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
sceneInfra.camControls.usePerspectiveCamera(true)
|
||||
}
|
||||
}}
|
||||
@ -725,7 +720,7 @@ export const CamDebugSettings = () => {
|
||||
<div>
|
||||
<button
|
||||
onClick={() => {
|
||||
sceneInfra.camControls.resetCameraPosition()
|
||||
sceneInfra.camControls.resetCameraPosition().catch(reportRejection)
|
||||
}}
|
||||
>
|
||||
Reset Camera Position
|
||||
|
@ -1,10 +1,8 @@
|
||||
import {
|
||||
BoxGeometry,
|
||||
DoubleSide,
|
||||
ExtrudeGeometry,
|
||||
Group,
|
||||
Intersection,
|
||||
LineCurve3,
|
||||
Mesh,
|
||||
MeshBasicMaterial,
|
||||
Object3D,
|
||||
@ -15,7 +13,6 @@ import {
|
||||
Points,
|
||||
Quaternion,
|
||||
Scene,
|
||||
Shape,
|
||||
Vector2,
|
||||
Vector3,
|
||||
} from 'three'
|
||||
@ -27,9 +24,6 @@ import {
|
||||
OnClickCallbackArgs,
|
||||
OnMouseEnterLeaveArgs,
|
||||
RAYCASTABLE_PLANE,
|
||||
SEGMENT_LENGTH_LABEL,
|
||||
SEGMENT_LENGTH_LABEL_OFFSET_PX,
|
||||
SEGMENT_LENGTH_LABEL_TEXT,
|
||||
SKETCH_GROUP_SEGMENTS,
|
||||
SKETCH_LAYER,
|
||||
X_AXIS,
|
||||
@ -38,7 +32,6 @@ import {
|
||||
import { isQuaternionVertical, quaternionFromUpNForward } from './helpers'
|
||||
import {
|
||||
CallExpression,
|
||||
getTangentialArcToInfo,
|
||||
parse,
|
||||
Path,
|
||||
PathToNode,
|
||||
@ -62,11 +55,9 @@ import {
|
||||
import { getNodeFromPath, getNodePathFromSourceRange } from 'lang/queryAst'
|
||||
import { executeAst } from 'lang/langHelpers'
|
||||
import {
|
||||
createArcGeometry,
|
||||
dashedStraight,
|
||||
profileStart,
|
||||
straightSegment,
|
||||
tangentialArcToSegment,
|
||||
createProfileStartHandle,
|
||||
SegmentUtils,
|
||||
segmentUtils,
|
||||
} from './segments'
|
||||
import {
|
||||
addCallExpressionsToPipe,
|
||||
@ -75,13 +66,7 @@ import {
|
||||
changeSketchArguments,
|
||||
updateStartProfileAtArgs,
|
||||
} from 'lang/std/sketch'
|
||||
import {
|
||||
isArray,
|
||||
isOverlap,
|
||||
normaliseAngle,
|
||||
roundOff,
|
||||
throttle,
|
||||
} from 'lib/utils'
|
||||
import { isArray, isOverlap, roundOff } from 'lib/utils'
|
||||
import {
|
||||
addStartProfileAt,
|
||||
createArrayExpression,
|
||||
@ -92,7 +77,6 @@ import {
|
||||
findUniqueName,
|
||||
} from 'lang/modifyAst'
|
||||
import { Selections, getEventForSegmentSelection } from 'lib/selections'
|
||||
import { getTangentPointFromPreviousArc } from 'lib/utils2d'
|
||||
import { createGridHelper, orthoScale, perspScale } from './helpers'
|
||||
import { Models } from '@kittycad/lib'
|
||||
import { uuidv4 } from 'lib/utils'
|
||||
@ -102,8 +86,8 @@ import {
|
||||
getRectangleCallExpressions,
|
||||
updateRectangleSketch,
|
||||
} from 'lib/rectangleTool'
|
||||
import { getThemeColorForThreeJs } from 'lib/theme'
|
||||
import { err, trap } from 'lib/trap'
|
||||
import { getThemeColorForThreeJs, Themes } from 'lib/theme'
|
||||
import { err, reportRejection, trap } from 'lib/trap'
|
||||
import { CSS2DObject } from 'three/examples/jsm/renderers/CSS2DRenderer'
|
||||
import { Point3d } from 'wasm-lib/kcl/bindings/Point3d'
|
||||
|
||||
@ -122,6 +106,11 @@ export const TANGENTIAL_ARC_TO_SEGMENT_BODY = 'tangential-arc-to-segment-body'
|
||||
export const SEGMENT_WIDTH_PX = 1.6
|
||||
export const HIDE_SEGMENT_LENGTH = 75 // in pixels
|
||||
export const HIDE_HOVER_SEGMENT_LENGTH = 60 // in pixels
|
||||
export const SEGMENT_BODIES = [STRAIGHT_SEGMENT, TANGENTIAL_ARC_TO_SEGMENT]
|
||||
export const SEGMENT_BODIES_PLUS_PROFILE_START = [
|
||||
...SEGMENT_BODIES,
|
||||
PROFILE_START,
|
||||
]
|
||||
|
||||
type Vec3Array = [number, number, number]
|
||||
|
||||
@ -155,37 +144,35 @@ export class SceneEntities {
|
||||
? orthoFactor
|
||||
: perspScale(sceneInfra.camControls.camera, segment)) /
|
||||
sceneInfra._baseUnitMultiplier
|
||||
const input = {
|
||||
type: 'straight-segment',
|
||||
from: segment.userData.from,
|
||||
to: segment.userData.to,
|
||||
} as const
|
||||
let update: SegmentUtils['update'] | null = null
|
||||
if (
|
||||
segment.userData.from &&
|
||||
segment.userData.to &&
|
||||
segment.userData.type === STRAIGHT_SEGMENT
|
||||
) {
|
||||
callbacks.push(
|
||||
this.updateStraightSegment({
|
||||
from: segment.userData.from,
|
||||
to: segment.userData.to,
|
||||
group: segment,
|
||||
scale: factor,
|
||||
})
|
||||
)
|
||||
update = segmentUtils.straight.update
|
||||
}
|
||||
|
||||
if (
|
||||
segment.userData.from &&
|
||||
segment.userData.to &&
|
||||
segment.userData.prevSegment &&
|
||||
segment.userData.type === TANGENTIAL_ARC_TO_SEGMENT
|
||||
) {
|
||||
callbacks.push(
|
||||
this.updateTangentialArcToSegment({
|
||||
prevSegment: segment.userData.prevSegment,
|
||||
from: segment.userData.from,
|
||||
to: segment.userData.to,
|
||||
group: segment,
|
||||
scale: factor,
|
||||
})
|
||||
)
|
||||
update = segmentUtils.tangentialArcTo.update
|
||||
}
|
||||
const callBack = update?.({
|
||||
prevSegment: segment.userData.prevSegment,
|
||||
input,
|
||||
group: segment,
|
||||
scale: factor,
|
||||
sceneInfra,
|
||||
})
|
||||
callBack && !err(callBack) && callbacks.push(callBack)
|
||||
if (segment.name === PROFILE_START) {
|
||||
segment.scale.set(factor, factor, factor)
|
||||
}
|
||||
@ -324,6 +311,7 @@ export class SceneEntities {
|
||||
)
|
||||
}
|
||||
sceneInfra.setCallbacks({
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises
|
||||
onClick: async (args) => {
|
||||
if (!args) return
|
||||
if (args.mouseEvent.which !== 1) return
|
||||
@ -421,7 +409,7 @@ export class SceneEntities {
|
||||
maybeModdedAst,
|
||||
sketchGroup.start.__geoMeta.sourceRange
|
||||
)
|
||||
const _profileStart = profileStart({
|
||||
const _profileStart = createProfileStartHandle({
|
||||
from: sketchGroup.start.from,
|
||||
id: sketchGroup.start.__geoMeta.id,
|
||||
pathToNode: segPathToNode,
|
||||
@ -476,50 +464,31 @@ export class SceneEntities {
|
||||
if (err(_node1)) return
|
||||
const callExpName = _node1.node?.callee?.name
|
||||
|
||||
if (segment.type === 'TangentialArcTo') {
|
||||
seg = tangentialArcToSegment({
|
||||
prevSegment: sketchGroup.value[index - 1],
|
||||
const initSegment =
|
||||
segment.type === 'TangentialArcTo'
|
||||
? segmentUtils.tangentialArcTo.init
|
||||
: segmentUtils.straight.init
|
||||
const result = initSegment({
|
||||
prevSegment: sketchGroup.value[index - 1],
|
||||
callExpName,
|
||||
input: {
|
||||
type: 'straight-segment',
|
||||
from: segment.from,
|
||||
to: segment.to,
|
||||
id: segment.__geoMeta.id,
|
||||
pathToNode: segPathToNode,
|
||||
isDraftSegment,
|
||||
scale: factor,
|
||||
texture: sceneInfra.extraSegmentTexture,
|
||||
theme: sceneInfra._theme,
|
||||
isSelected,
|
||||
})
|
||||
callbacks.push(
|
||||
this.updateTangentialArcToSegment({
|
||||
prevSegment: sketchGroup.value[index - 1],
|
||||
from: segment.from,
|
||||
to: segment.to,
|
||||
group: seg,
|
||||
scale: factor,
|
||||
})
|
||||
)
|
||||
} else {
|
||||
seg = straightSegment({
|
||||
from: segment.from,
|
||||
to: segment.to,
|
||||
id: segment.__geoMeta.id,
|
||||
pathToNode: segPathToNode,
|
||||
isDraftSegment,
|
||||
scale: factor,
|
||||
callExpName,
|
||||
texture: sceneInfra.extraSegmentTexture,
|
||||
theme: sceneInfra._theme,
|
||||
isSelected,
|
||||
})
|
||||
callbacks.push(
|
||||
this.updateStraightSegment({
|
||||
from: segment.from,
|
||||
to: segment.to,
|
||||
group: seg,
|
||||
scale: factor,
|
||||
})
|
||||
)
|
||||
}
|
||||
},
|
||||
id: segment.__geoMeta.id,
|
||||
pathToNode: segPathToNode,
|
||||
isDraftSegment,
|
||||
scale: factor,
|
||||
texture: sceneInfra.extraSegmentTexture,
|
||||
theme: sceneInfra._theme,
|
||||
isSelected,
|
||||
sceneInfra,
|
||||
})
|
||||
if (err(result)) return
|
||||
const { group: _group, updateOverlaysCallback } = result
|
||||
seg = _group
|
||||
callbacks.push(updateOverlaysCallback)
|
||||
seg.layers.set(SKETCH_LAYER)
|
||||
seg.traverse((child) => {
|
||||
child.layers.set(SKETCH_LAYER)
|
||||
@ -602,16 +571,19 @@ export class SceneEntities {
|
||||
kclManager.programMemory.get(variableDeclarationName),
|
||||
variableDeclarationName
|
||||
)
|
||||
if (err(sg)) return sg
|
||||
const lastSeg = sg.value?.slice(-1)[0] || sg.start
|
||||
if (err(sg)) return Promise.reject(sg)
|
||||
const lastSeg = sg?.value?.slice(-1)[0] || sg.start
|
||||
|
||||
const index = sg.value.length // because we've added a new segment that's not in the memory yet, no need for `-1`
|
||||
|
||||
const mod = addNewSketchLn({
|
||||
node: _ast,
|
||||
programMemory: kclManager.programMemory,
|
||||
to: [lastSeg.to[0], lastSeg.to[1]],
|
||||
from: [lastSeg.to[0], lastSeg.to[1]],
|
||||
input: {
|
||||
type: 'straight-segment',
|
||||
to: lastSeg.to,
|
||||
from: lastSeg.to,
|
||||
},
|
||||
fnName: segmentName,
|
||||
pathToNode: sketchPathToNode,
|
||||
})
|
||||
@ -634,6 +606,7 @@ export class SceneEntities {
|
||||
draftExpressionsIndices,
|
||||
})
|
||||
sceneInfra.setCallbacks({
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises
|
||||
onClick: async (args) => {
|
||||
if (!args) return
|
||||
if (args.mouseEvent.which !== 1) return
|
||||
@ -681,8 +654,11 @@ export class SceneEntities {
|
||||
const tmp = addNewSketchLn({
|
||||
node: kclManager.ast,
|
||||
programMemory: kclManager.programMemory,
|
||||
to: [intersection2d.x, intersection2d.y],
|
||||
from: [lastSegment.to[0], lastSegment.to[1]],
|
||||
input: {
|
||||
type: 'straight-segment',
|
||||
from: [lastSegment.to[0], lastSegment.to[1]],
|
||||
to: [intersection2d.x, intersection2d.y],
|
||||
},
|
||||
fnName:
|
||||
lastSegment.type === 'TangentialArcTo'
|
||||
? 'tangentialArcTo'
|
||||
@ -701,7 +677,7 @@ export class SceneEntities {
|
||||
if (profileStart) {
|
||||
sceneInfra.modelingSend({ type: 'CancelSketch' })
|
||||
} else {
|
||||
this.setUpDraftSegment(
|
||||
await this.setUpDraftSegment(
|
||||
sketchPathToNode,
|
||||
forward,
|
||||
up,
|
||||
@ -771,6 +747,7 @@ export class SceneEntities {
|
||||
})
|
||||
|
||||
sceneInfra.setCallbacks({
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises
|
||||
onMove: async (args) => {
|
||||
// Update the width and height of the draft rectangle
|
||||
const pathToNodeTwo = structuredClone(sketchPathToNode)
|
||||
@ -818,6 +795,7 @@ export class SceneEntities {
|
||||
this.updateSegment(seg, index, 0, _ast, orthoFactor, sketchGroup)
|
||||
)
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises
|
||||
onClick: async (args) => {
|
||||
// Commit the rectangle to the full AST/code and return to sketch.idle
|
||||
const cornerPoint = args.intersectionPoint?.twoD
|
||||
@ -831,14 +809,14 @@ export class SceneEntities {
|
||||
sketchPathToNode || [],
|
||||
'VariableDeclaration'
|
||||
)
|
||||
if (trap(_node)) return Promise.reject(_node)
|
||||
if (trap(_node)) return
|
||||
const sketchInit = _node.node?.declarations?.[0]?.init
|
||||
|
||||
if (sketchInit.type === 'PipeExpression') {
|
||||
updateRectangleSketch(sketchInit, x, y, tags[0])
|
||||
|
||||
let _recastAst = parse(recast(_ast))
|
||||
if (trap(_recastAst)) return Promise.reject(_recastAst)
|
||||
if (trap(_recastAst)) return
|
||||
_ast = _recastAst
|
||||
|
||||
// Update the primary AST and unequip the rectangle tool
|
||||
@ -858,7 +836,7 @@ export class SceneEntities {
|
||||
programMemory.get(variableDeclarationName),
|
||||
variableDeclarationName
|
||||
)
|
||||
if (err(sketchGroup)) return sketchGroup
|
||||
if (err(sketchGroup)) return
|
||||
const sgPaths = sketchGroup.value
|
||||
const orthoFactor = orthoScale(sceneInfra.camControls.camera)
|
||||
|
||||
@ -892,9 +870,11 @@ export class SceneEntities {
|
||||
}) => {
|
||||
let addingNewSegmentStatus: 'nothing' | 'pending' | 'added' = 'nothing'
|
||||
sceneInfra.setCallbacks({
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises
|
||||
onDragEnd: async () => {
|
||||
if (addingNewSegmentStatus !== 'nothing') {
|
||||
await this.tearDownSketch({ removeAxis: false })
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
this.setupSketch({
|
||||
sketchPathToNode: pathToNode,
|
||||
maybeModdedAst: kclManager.ast,
|
||||
@ -911,6 +891,7 @@ export class SceneEntities {
|
||||
})
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises
|
||||
onDrag: async ({
|
||||
selected,
|
||||
intersectionPoint,
|
||||
@ -944,8 +925,11 @@ export class SceneEntities {
|
||||
const mod = addNewSketchLn({
|
||||
node: kclManager.ast,
|
||||
programMemory: kclManager.programMemory,
|
||||
to: [intersectionPoint.twoD.x, intersectionPoint.twoD.y],
|
||||
from: [prevSegment.from[0], prevSegment.from[1]],
|
||||
input: {
|
||||
type: 'straight-segment',
|
||||
to: [intersectionPoint.twoD.x, intersectionPoint.twoD.y],
|
||||
from: prevSegment.from,
|
||||
},
|
||||
// TODO assuming it's always a straight segments being added
|
||||
// as this is easiest, and we'll need to add "tabbing" behavior
|
||||
// to support other segment types
|
||||
@ -958,6 +942,7 @@ export class SceneEntities {
|
||||
|
||||
await kclManager.executeAstMock(mod.modifiedAst)
|
||||
await this.tearDownSketch({ removeAxis: false })
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
this.setupSketch({
|
||||
sketchPathToNode: pathToNode,
|
||||
maybeModdedAst: kclManager.ast,
|
||||
@ -1065,7 +1050,7 @@ export class SceneEntities {
|
||||
group.userData.from[0],
|
||||
group.userData.from[1],
|
||||
]
|
||||
const to: [number, number] = [intersection2d.x, intersection2d.y]
|
||||
const dragTo: [number, number] = [intersection2d.x, intersection2d.y]
|
||||
let modifiedAst = draftInfo ? draftInfo.truncatedAst : { ...kclManager.ast }
|
||||
|
||||
const _node = getNodeFromPath<CallExpression>(
|
||||
@ -1088,8 +1073,11 @@ export class SceneEntities {
|
||||
modded = updateStartProfileAtArgs({
|
||||
node: modifiedAst,
|
||||
pathToNode,
|
||||
to,
|
||||
from,
|
||||
input: {
|
||||
type: 'straight-segment',
|
||||
to: dragTo,
|
||||
from,
|
||||
},
|
||||
previousProgramMemory: kclManager.programMemory,
|
||||
})
|
||||
} else {
|
||||
@ -1097,8 +1085,11 @@ export class SceneEntities {
|
||||
modifiedAst,
|
||||
kclManager.programMemory,
|
||||
[node.start, node.end],
|
||||
to,
|
||||
from
|
||||
{
|
||||
type: 'straight-segment',
|
||||
from,
|
||||
to: dragTo,
|
||||
}
|
||||
)
|
||||
}
|
||||
if (trap(modded)) return
|
||||
@ -1161,7 +1152,7 @@ export class SceneEntities {
|
||||
)
|
||||
)
|
||||
sceneInfra.overlayCallbacks(callBacks)
|
||||
})()
|
||||
})().catch(reportRejection)
|
||||
}
|
||||
|
||||
/**
|
||||
@ -1201,269 +1192,54 @@ export class SceneEntities {
|
||||
? orthoFactor
|
||||
: perspScale(sceneInfra.camControls.camera, group)) /
|
||||
sceneInfra._baseUnitMultiplier
|
||||
const input = {
|
||||
type: 'straight-segment',
|
||||
from: segment.from,
|
||||
to: segment.to,
|
||||
} as const
|
||||
let update: SegmentUtils['update'] | null = null
|
||||
if (type === TANGENTIAL_ARC_TO_SEGMENT) {
|
||||
return this.updateTangentialArcToSegment({
|
||||
prevSegment: sgPaths[index - 1],
|
||||
from: segment.from,
|
||||
to: segment.to,
|
||||
group: group,
|
||||
scale: factor,
|
||||
})
|
||||
update = segmentUtils.tangentialArcTo.update
|
||||
} else if (type === STRAIGHT_SEGMENT) {
|
||||
return this.updateStraightSegment({
|
||||
from: segment.from,
|
||||
to: segment.to,
|
||||
update = segmentUtils.straight.update
|
||||
}
|
||||
const callBack =
|
||||
update &&
|
||||
!err(update) &&
|
||||
update({
|
||||
input,
|
||||
group,
|
||||
scale: factor,
|
||||
prevSegment: sgPaths[index - 1],
|
||||
sceneInfra,
|
||||
})
|
||||
} else if (type === PROFILE_START) {
|
||||
if (callBack && !err(callBack)) return callBack
|
||||
|
||||
if (type === PROFILE_START) {
|
||||
group.position.set(segment.from[0], segment.from[1], 0)
|
||||
group.scale.set(factor, factor, factor)
|
||||
}
|
||||
return () => null
|
||||
}
|
||||
|
||||
updateTangentialArcToSegment({
|
||||
prevSegment,
|
||||
from,
|
||||
to,
|
||||
group,
|
||||
scale = 1,
|
||||
}: {
|
||||
prevSegment: SketchGroup['value'][number]
|
||||
from: [number, number]
|
||||
to: [number, number]
|
||||
group: Group
|
||||
scale?: number
|
||||
}): () => SegmentOverlayPayload | null {
|
||||
group.userData.from = from
|
||||
group.userData.to = to
|
||||
group.userData.prevSegment = prevSegment
|
||||
const arrowGroup = group.getObjectByName(ARROWHEAD) as Group
|
||||
const extraSegmentGroup = group.getObjectByName(EXTRA_SEGMENT_HANDLE)
|
||||
|
||||
const previousPoint =
|
||||
prevSegment?.type === 'TangentialArcTo'
|
||||
? getTangentPointFromPreviousArc(
|
||||
prevSegment.center,
|
||||
prevSegment.ccw,
|
||||
prevSegment.to
|
||||
)
|
||||
: prevSegment.from
|
||||
|
||||
const arcInfo = getTangentialArcToInfo({
|
||||
arcStartPoint: from,
|
||||
arcEndPoint: to,
|
||||
tanPreviousPoint: previousPoint,
|
||||
obtuse: true,
|
||||
/**
|
||||
* Update the base color of each of the THREEjs meshes
|
||||
* that represent each of the sketch segments, to get the
|
||||
* latest value from `sceneInfra._theme`
|
||||
*/
|
||||
updateSegmentBaseColor(newColor: Themes.Light | Themes.Dark) {
|
||||
const newColorThreeJs = getThemeColorForThreeJs(newColor)
|
||||
Object.values(this.activeSegments).forEach((group) => {
|
||||
group.userData.baseColor = newColorThreeJs
|
||||
group.traverse((child) => {
|
||||
if (
|
||||
child instanceof Mesh &&
|
||||
child.material instanceof MeshBasicMaterial
|
||||
) {
|
||||
child.material.color.set(newColorThreeJs)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const pxLength = arcInfo.arcLength / scale
|
||||
const shouldHideIdle = pxLength < HIDE_SEGMENT_LENGTH
|
||||
const shouldHideHover = pxLength < HIDE_HOVER_SEGMENT_LENGTH
|
||||
|
||||
const hoveredParent =
|
||||
sceneInfra.hoveredObject &&
|
||||
getParentGroup(sceneInfra.hoveredObject, [TANGENTIAL_ARC_TO_SEGMENT])
|
||||
let isHandlesVisible = !shouldHideIdle
|
||||
if (hoveredParent && hoveredParent?.uuid === group?.uuid) {
|
||||
isHandlesVisible = !shouldHideHover
|
||||
}
|
||||
|
||||
if (arrowGroup) {
|
||||
arrowGroup.position.set(to[0], to[1], 0)
|
||||
|
||||
const arrowheadAngle =
|
||||
arcInfo.endAngle + (Math.PI / 2) * (arcInfo.ccw ? 1 : -1)
|
||||
arrowGroup.quaternion.setFromUnitVectors(
|
||||
new Vector3(0, 1, 0),
|
||||
new Vector3(Math.cos(arrowheadAngle), Math.sin(arrowheadAngle), 0)
|
||||
)
|
||||
arrowGroup.scale.set(scale, scale, scale)
|
||||
arrowGroup.visible = isHandlesVisible
|
||||
}
|
||||
|
||||
if (extraSegmentGroup) {
|
||||
const circumferenceInPx = (2 * Math.PI * arcInfo.radius) / scale
|
||||
const extraSegmentAngleDelta =
|
||||
(EXTRA_SEGMENT_OFFSET_PX / circumferenceInPx) * Math.PI * 2
|
||||
const extraSegmentAngle =
|
||||
arcInfo.startAngle + (arcInfo.ccw ? 1 : -1) * extraSegmentAngleDelta
|
||||
const extraSegmentOffset = new Vector2(
|
||||
Math.cos(extraSegmentAngle) * arcInfo.radius,
|
||||
Math.sin(extraSegmentAngle) * arcInfo.radius
|
||||
)
|
||||
extraSegmentGroup.position.set(
|
||||
arcInfo.center[0] + extraSegmentOffset.x,
|
||||
arcInfo.center[1] + extraSegmentOffset.y,
|
||||
0
|
||||
)
|
||||
extraSegmentGroup.scale.set(scale, scale, scale)
|
||||
extraSegmentGroup.visible = isHandlesVisible
|
||||
}
|
||||
|
||||
const tangentialArcToSegmentBody = group.children.find(
|
||||
(child) => child.userData.type === TANGENTIAL_ARC_TO_SEGMENT_BODY
|
||||
) as Mesh
|
||||
|
||||
if (tangentialArcToSegmentBody) {
|
||||
const newGeo = createArcGeometry({ ...arcInfo, scale })
|
||||
tangentialArcToSegmentBody.geometry = newGeo
|
||||
}
|
||||
const tangentialArcToSegmentBodyDashed = group.children.find(
|
||||
(child) => child.userData.type === TANGENTIAL_ARC_TO__SEGMENT_DASH
|
||||
) as Mesh
|
||||
if (tangentialArcToSegmentBodyDashed) {
|
||||
// consider throttling the whole updateTangentialArcToSegment
|
||||
// if there are more perf considerations going forward
|
||||
this.throttledUpdateDashedArcGeo({
|
||||
...arcInfo,
|
||||
mesh: tangentialArcToSegmentBodyDashed,
|
||||
isDashed: true,
|
||||
scale,
|
||||
})
|
||||
}
|
||||
const angle = normaliseAngle(
|
||||
(arcInfo.endAngle * 180) / Math.PI + (arcInfo.ccw ? 90 : -90)
|
||||
)
|
||||
return () =>
|
||||
sceneInfra.updateOverlayDetails({
|
||||
arrowGroup,
|
||||
group,
|
||||
isHandlesVisible,
|
||||
from,
|
||||
to,
|
||||
angle,
|
||||
})
|
||||
}
|
||||
throttledUpdateDashedArcGeo = throttle(
|
||||
(
|
||||
args: Parameters<typeof createArcGeometry>[0] & {
|
||||
mesh: Mesh
|
||||
scale: number
|
||||
}
|
||||
) => (args.mesh.geometry = createArcGeometry(args)),
|
||||
1000 / 30
|
||||
)
|
||||
updateStraightSegment({
|
||||
from,
|
||||
to,
|
||||
group,
|
||||
scale = 1,
|
||||
}: {
|
||||
from: [number, number]
|
||||
to: [number, number]
|
||||
group: Group
|
||||
scale?: number
|
||||
}): () => SegmentOverlayPayload | null {
|
||||
group.userData.from = from
|
||||
group.userData.to = to
|
||||
const shape = new Shape()
|
||||
shape.moveTo(0, (-SEGMENT_WIDTH_PX / 2) * scale) // The width of the line in px (2.4px in this case)
|
||||
shape.lineTo(0, (SEGMENT_WIDTH_PX / 2) * scale)
|
||||
const arrowGroup = group.getObjectByName(ARROWHEAD) as Group
|
||||
const labelGroup = group.getObjectByName(SEGMENT_LENGTH_LABEL) as Group
|
||||
|
||||
const length = Math.sqrt(
|
||||
Math.pow(to[0] - from[0], 2) + Math.pow(to[1] - from[1], 2)
|
||||
)
|
||||
|
||||
const pxLength = length / scale
|
||||
const shouldHideIdle = pxLength < HIDE_SEGMENT_LENGTH
|
||||
const shouldHideHover = pxLength < HIDE_HOVER_SEGMENT_LENGTH
|
||||
|
||||
const hoveredParent =
|
||||
sceneInfra.hoveredObject &&
|
||||
getParentGroup(sceneInfra.hoveredObject, [STRAIGHT_SEGMENT])
|
||||
let isHandlesVisible = !shouldHideIdle
|
||||
if (hoveredParent && hoveredParent?.uuid === group?.uuid) {
|
||||
isHandlesVisible = !shouldHideHover
|
||||
}
|
||||
|
||||
if (arrowGroup) {
|
||||
arrowGroup.position.set(to[0], to[1], 0)
|
||||
|
||||
const dir = new Vector3()
|
||||
.subVectors(
|
||||
new Vector3(to[0], to[1], 0),
|
||||
new Vector3(from[0], from[1], 0)
|
||||
)
|
||||
.normalize()
|
||||
arrowGroup.quaternion.setFromUnitVectors(new Vector3(0, 1, 0), dir)
|
||||
arrowGroup.scale.set(scale, scale, scale)
|
||||
arrowGroup.visible = isHandlesVisible
|
||||
}
|
||||
|
||||
const extraSegmentGroup = group.getObjectByName(EXTRA_SEGMENT_HANDLE)
|
||||
if (extraSegmentGroup) {
|
||||
const offsetFromBase = new Vector2(to[0] - from[0], to[1] - from[1])
|
||||
.normalize()
|
||||
.multiplyScalar(EXTRA_SEGMENT_OFFSET_PX * scale)
|
||||
extraSegmentGroup.position.set(
|
||||
from[0] + offsetFromBase.x,
|
||||
from[1] + offsetFromBase.y,
|
||||
0
|
||||
)
|
||||
extraSegmentGroup.scale.set(scale, scale, scale)
|
||||
extraSegmentGroup.visible = isHandlesVisible
|
||||
}
|
||||
|
||||
if (labelGroup) {
|
||||
const labelWrapper = labelGroup.getObjectByName(
|
||||
SEGMENT_LENGTH_LABEL_TEXT
|
||||
) as CSS2DObject
|
||||
const labelWrapperElem = labelWrapper.element as HTMLDivElement
|
||||
const label = labelWrapperElem.children[0] as HTMLParagraphElement
|
||||
label.innerText = `${roundOff(length)}${sceneInfra._baseUnit}`
|
||||
label.classList.add(SEGMENT_LENGTH_LABEL_TEXT)
|
||||
const offsetFromMidpoint = new Vector2(to[0] - from[0], to[1] - from[1])
|
||||
.normalize()
|
||||
.rotateAround(new Vector2(0, 0), Math.PI / 2)
|
||||
.multiplyScalar(SEGMENT_LENGTH_LABEL_OFFSET_PX * scale)
|
||||
label.style.setProperty('--x', `${offsetFromMidpoint.x}px`)
|
||||
label.style.setProperty('--y', `${offsetFromMidpoint.y}px`)
|
||||
labelWrapper.position.set(
|
||||
(from[0] + to[0]) / 2 + offsetFromMidpoint.x,
|
||||
(from[1] + to[1]) / 2 + offsetFromMidpoint.y,
|
||||
0
|
||||
)
|
||||
|
||||
labelGroup.visible = isHandlesVisible
|
||||
}
|
||||
|
||||
const straightSegmentBody = group.children.find(
|
||||
(child) => child.userData.type === STRAIGHT_SEGMENT_BODY
|
||||
) as Mesh
|
||||
if (straightSegmentBody) {
|
||||
const line = new LineCurve3(
|
||||
new Vector3(from[0], from[1], 0),
|
||||
new Vector3(to[0], to[1], 0)
|
||||
)
|
||||
straightSegmentBody.geometry = new ExtrudeGeometry(shape, {
|
||||
steps: 2,
|
||||
bevelEnabled: false,
|
||||
extrudePath: line,
|
||||
})
|
||||
}
|
||||
const straightSegmentBodyDashed = group.children.find(
|
||||
(child) => child.userData.type === STRAIGHT_SEGMENT_DASH
|
||||
) as Mesh
|
||||
if (straightSegmentBodyDashed) {
|
||||
straightSegmentBodyDashed.geometry = dashedStraight(
|
||||
from,
|
||||
to,
|
||||
shape,
|
||||
scale
|
||||
)
|
||||
}
|
||||
return () =>
|
||||
sceneInfra.updateOverlayDetails({
|
||||
arrowGroup,
|
||||
group,
|
||||
isHandlesVisible,
|
||||
from,
|
||||
to,
|
||||
})
|
||||
}
|
||||
removeSketchGrid() {
|
||||
if (this.axisGroup) this.scene.remove(this.axisGroup)
|
||||
@ -1558,27 +1334,30 @@ export class SceneEntities {
|
||||
}
|
||||
const orthoFactor = orthoScale(sceneInfra.camControls.camera)
|
||||
|
||||
const input = {
|
||||
type: 'straight-segment',
|
||||
from: parent.userData.from,
|
||||
to: parent.userData.to,
|
||||
} as const
|
||||
const factor =
|
||||
(sceneInfra.camControls.camera instanceof OrthographicCamera
|
||||
? orthoFactor
|
||||
: perspScale(sceneInfra.camControls.camera, parent)) /
|
||||
sceneInfra._baseUnitMultiplier
|
||||
let update: SegmentUtils['update'] | null = null
|
||||
if (parent.name === STRAIGHT_SEGMENT) {
|
||||
this.updateStraightSegment({
|
||||
from: parent.userData.from,
|
||||
to: parent.userData.to,
|
||||
group: parent,
|
||||
scale: factor,
|
||||
})
|
||||
update = segmentUtils.straight.update
|
||||
} else if (parent.name === TANGENTIAL_ARC_TO_SEGMENT) {
|
||||
this.updateTangentialArcToSegment({
|
||||
update = segmentUtils.tangentialArcTo.update
|
||||
}
|
||||
update &&
|
||||
update({
|
||||
prevSegment: parent.userData.prevSegment,
|
||||
from: parent.userData.from,
|
||||
to: parent.userData.to,
|
||||
input,
|
||||
group: parent,
|
||||
scale: factor,
|
||||
sceneInfra,
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
editorManager.setHighlightRange([[0, 0]])
|
||||
@ -1593,27 +1372,30 @@ export class SceneEntities {
|
||||
if (parent) {
|
||||
const orthoFactor = orthoScale(sceneInfra.camControls.camera)
|
||||
|
||||
const input = {
|
||||
type: 'straight-segment',
|
||||
from: parent.userData.from,
|
||||
to: parent.userData.to,
|
||||
} as const
|
||||
const factor =
|
||||
(sceneInfra.camControls.camera instanceof OrthographicCamera
|
||||
? orthoFactor
|
||||
: perspScale(sceneInfra.camControls.camera, parent)) /
|
||||
sceneInfra._baseUnitMultiplier
|
||||
let update: SegmentUtils['update'] | null = null
|
||||
if (parent.name === STRAIGHT_SEGMENT) {
|
||||
this.updateStraightSegment({
|
||||
from: parent.userData.from,
|
||||
to: parent.userData.to,
|
||||
group: parent,
|
||||
scale: factor,
|
||||
})
|
||||
update = segmentUtils.straight.update
|
||||
} else if (parent.name === TANGENTIAL_ARC_TO_SEGMENT) {
|
||||
this.updateTangentialArcToSegment({
|
||||
update = segmentUtils.tangentialArcTo.update
|
||||
}
|
||||
update &&
|
||||
update({
|
||||
prevSegment: parent.userData.prevSegment,
|
||||
from: parent.userData.from,
|
||||
to: parent.userData.to,
|
||||
input,
|
||||
group: parent,
|
||||
scale: factor,
|
||||
sceneInfra,
|
||||
})
|
||||
}
|
||||
}
|
||||
const isSelected = parent?.userData?.isSelected
|
||||
colorSegment(
|
||||
|
@ -105,10 +105,6 @@ export class SceneInfra {
|
||||
_baseUnit: BaseUnit = 'mm'
|
||||
_baseUnitMultiplier = 1
|
||||
_theme: Themes = Themes.System
|
||||
_streamDimensions: { streamWidth: number; streamHeight: number } = {
|
||||
streamWidth: 1280,
|
||||
streamHeight: 720,
|
||||
}
|
||||
extraSegmentTexture: Texture
|
||||
lastMouseState: MouseState = { type: 'idle' }
|
||||
onDragStartCallback: (arg: OnDragCallbackArgs) => void = () => {}
|
||||
|
@ -26,6 +26,7 @@ import { PathToNode, SketchGroup, getTangentialArcToInfo } from 'lang/wasm'
|
||||
import {
|
||||
EXTRA_SEGMENT_HANDLE,
|
||||
EXTRA_SEGMENT_OFFSET_PX,
|
||||
HIDE_HOVER_SEGMENT_LENGTH,
|
||||
HIDE_SEGMENT_LENGTH,
|
||||
PROFILE_START,
|
||||
SEGMENT_WIDTH_PX,
|
||||
@ -35,18 +36,448 @@ import {
|
||||
TANGENTIAL_ARC_TO_SEGMENT,
|
||||
TANGENTIAL_ARC_TO_SEGMENT_BODY,
|
||||
TANGENTIAL_ARC_TO__SEGMENT_DASH,
|
||||
getParentGroup,
|
||||
} from './sceneEntities'
|
||||
import { getTangentPointFromPreviousArc } from 'lib/utils2d'
|
||||
import {
|
||||
ARROWHEAD,
|
||||
SceneInfra,
|
||||
SEGMENT_LENGTH_LABEL,
|
||||
SEGMENT_LENGTH_LABEL_OFFSET_PX,
|
||||
SEGMENT_LENGTH_LABEL_TEXT,
|
||||
} from './sceneInfra'
|
||||
import { Themes, getThemeColorForThreeJs } from 'lib/theme'
|
||||
import { roundOff } from 'lib/utils'
|
||||
import { normaliseAngle, roundOff } from 'lib/utils'
|
||||
import { SegmentOverlayPayload } from 'machines/modelingMachine'
|
||||
import { SegmentInputs } from 'lang/std/stdTypes'
|
||||
import { err } from 'lib/trap'
|
||||
|
||||
export function profileStart({
|
||||
interface CreateSegmentArgs {
|
||||
input: SegmentInputs
|
||||
prevSegment: SketchGroup['value'][number]
|
||||
id: string
|
||||
pathToNode: PathToNode
|
||||
isDraftSegment?: boolean
|
||||
scale?: number
|
||||
callExpName: string
|
||||
texture: Texture
|
||||
theme: Themes
|
||||
isSelected?: boolean
|
||||
sceneInfra: SceneInfra
|
||||
}
|
||||
|
||||
interface UpdateSegmentArgs {
|
||||
input: SegmentInputs
|
||||
prevSegment: SketchGroup['value'][number]
|
||||
group: Group
|
||||
sceneInfra: SceneInfra
|
||||
scale?: number
|
||||
}
|
||||
|
||||
interface CreateSegmentResult {
|
||||
group: Group
|
||||
updateOverlaysCallback: () => SegmentOverlayPayload | null
|
||||
}
|
||||
|
||||
export interface SegmentUtils {
|
||||
/**
|
||||
* the init is responsible for adding all of the correct entities to the group with important details like `mesh.name = ...`
|
||||
* as these act like handles later
|
||||
*
|
||||
* It's **Not** responsible for doing all calculations to size and position the entities as this would be duplicated in the update function
|
||||
* Which should instead be called at the end of the init function
|
||||
*/
|
||||
init: (args: CreateSegmentArgs) => CreateSegmentResult | Error
|
||||
/**
|
||||
* The update function is responsible for updating the group with the correct size and position of the entities
|
||||
* It should be called at the end of the init function and return a callback that can be used to update the overlay
|
||||
*
|
||||
* It returns a callback for updating the overlays, this is so the overlays do not have to update at the same pace threeJs does
|
||||
* This is useful for performance reasons
|
||||
*/
|
||||
update: (
|
||||
args: UpdateSegmentArgs
|
||||
) => CreateSegmentResult['updateOverlaysCallback'] | Error
|
||||
}
|
||||
|
||||
class StraightSegment implements SegmentUtils {
|
||||
init: SegmentUtils['init'] = ({
|
||||
input,
|
||||
id,
|
||||
pathToNode,
|
||||
isDraftSegment,
|
||||
scale = 1,
|
||||
callExpName,
|
||||
texture,
|
||||
theme,
|
||||
isSelected = false,
|
||||
sceneInfra,
|
||||
prevSegment,
|
||||
}) => {
|
||||
if (input.type !== 'straight-segment')
|
||||
return new Error('Invalid segment type')
|
||||
const { from, to } = input
|
||||
const baseColor =
|
||||
callExpName === 'close' ? 0x444444 : getThemeColorForThreeJs(theme)
|
||||
const color = isSelected ? 0x0000ff : baseColor
|
||||
const meshType = isDraftSegment
|
||||
? STRAIGHT_SEGMENT_DASH
|
||||
: STRAIGHT_SEGMENT_BODY
|
||||
|
||||
const segmentGroup = new Group()
|
||||
const shape = new Shape()
|
||||
const line = new LineCurve3(
|
||||
new Vector3(from[0], from[1], 0),
|
||||
new Vector3(to[0], to[1], 0)
|
||||
)
|
||||
const geometry = new ExtrudeGeometry(shape, {
|
||||
steps: 2,
|
||||
bevelEnabled: false,
|
||||
extrudePath: line,
|
||||
})
|
||||
const body = new MeshBasicMaterial({ color })
|
||||
const mesh = new Mesh(geometry, body)
|
||||
|
||||
mesh.userData.type = meshType
|
||||
mesh.name = meshType
|
||||
segmentGroup.name = STRAIGHT_SEGMENT
|
||||
segmentGroup.userData = {
|
||||
type: STRAIGHT_SEGMENT,
|
||||
id,
|
||||
from,
|
||||
to,
|
||||
pathToNode,
|
||||
isSelected,
|
||||
callExpName,
|
||||
baseColor,
|
||||
}
|
||||
|
||||
// All segment types get an extra segment handle,
|
||||
// Which is a little plus sign that appears at the origin of the segment
|
||||
// and can be dragged to insert a new segment
|
||||
const extraSegmentGroup = createExtraSegmentHandle(scale, texture, theme)
|
||||
|
||||
// Segment decorators that only apply to non-close segments
|
||||
if (callExpName !== 'close') {
|
||||
// an arrowhead that appears at the end of the segment
|
||||
const arrowGroup = createArrowhead(scale, theme, color)
|
||||
// A length indicator that appears at the midpoint of the segment
|
||||
const lengthIndicatorGroup = createLengthIndicator({
|
||||
from,
|
||||
to,
|
||||
scale,
|
||||
})
|
||||
segmentGroup.add(arrowGroup)
|
||||
segmentGroup.add(lengthIndicatorGroup)
|
||||
}
|
||||
|
||||
segmentGroup.add(mesh, extraSegmentGroup)
|
||||
let updateOverlaysCallback = this.update({
|
||||
prevSegment,
|
||||
input,
|
||||
group: segmentGroup,
|
||||
scale,
|
||||
sceneInfra,
|
||||
})
|
||||
if (err(updateOverlaysCallback)) return updateOverlaysCallback
|
||||
|
||||
return {
|
||||
group: segmentGroup,
|
||||
updateOverlaysCallback,
|
||||
}
|
||||
}
|
||||
|
||||
update: SegmentUtils['update'] = ({
|
||||
input,
|
||||
group,
|
||||
scale = 1,
|
||||
sceneInfra,
|
||||
}) => {
|
||||
if (input.type !== 'straight-segment')
|
||||
return new Error('Invalid segment type')
|
||||
const { from, to } = input
|
||||
group.userData.from = from
|
||||
group.userData.to = to
|
||||
const shape = new Shape()
|
||||
shape.moveTo(0, (-SEGMENT_WIDTH_PX / 2) * scale) // The width of the line in px (2.4px in this case)
|
||||
shape.lineTo(0, (SEGMENT_WIDTH_PX / 2) * scale)
|
||||
const arrowGroup = group.getObjectByName(ARROWHEAD) as Group
|
||||
const labelGroup = group.getObjectByName(SEGMENT_LENGTH_LABEL) as Group
|
||||
|
||||
const length = Math.sqrt(
|
||||
Math.pow(to[0] - from[0], 2) + Math.pow(to[1] - from[1], 2)
|
||||
)
|
||||
|
||||
const pxLength = length / scale
|
||||
const shouldHideIdle = pxLength < HIDE_SEGMENT_LENGTH
|
||||
const shouldHideHover = pxLength < HIDE_HOVER_SEGMENT_LENGTH
|
||||
|
||||
const hoveredParent =
|
||||
sceneInfra.hoveredObject &&
|
||||
getParentGroup(sceneInfra.hoveredObject, [STRAIGHT_SEGMENT])
|
||||
let isHandlesVisible = !shouldHideIdle
|
||||
if (hoveredParent && hoveredParent?.uuid === group?.uuid) {
|
||||
isHandlesVisible = !shouldHideHover
|
||||
}
|
||||
|
||||
if (arrowGroup) {
|
||||
arrowGroup.position.set(to[0], to[1], 0)
|
||||
|
||||
const dir = new Vector3()
|
||||
.subVectors(
|
||||
new Vector3(to[0], to[1], 0),
|
||||
new Vector3(from[0], from[1], 0)
|
||||
)
|
||||
.normalize()
|
||||
arrowGroup.quaternion.setFromUnitVectors(new Vector3(0, 1, 0), dir)
|
||||
arrowGroup.scale.set(scale, scale, scale)
|
||||
arrowGroup.visible = isHandlesVisible
|
||||
}
|
||||
|
||||
const extraSegmentGroup = group.getObjectByName(EXTRA_SEGMENT_HANDLE)
|
||||
if (extraSegmentGroup) {
|
||||
const offsetFromBase = new Vector2(to[0] - from[0], to[1] - from[1])
|
||||
.normalize()
|
||||
.multiplyScalar(EXTRA_SEGMENT_OFFSET_PX * scale)
|
||||
extraSegmentGroup.position.set(
|
||||
from[0] + offsetFromBase.x,
|
||||
from[1] + offsetFromBase.y,
|
||||
0
|
||||
)
|
||||
extraSegmentGroup.scale.set(scale, scale, scale)
|
||||
extraSegmentGroup.visible = isHandlesVisible
|
||||
}
|
||||
|
||||
if (labelGroup) {
|
||||
const labelWrapper = labelGroup.getObjectByName(
|
||||
SEGMENT_LENGTH_LABEL_TEXT
|
||||
) as CSS2DObject
|
||||
const labelWrapperElem = labelWrapper.element as HTMLDivElement
|
||||
const label = labelWrapperElem.children[0] as HTMLParagraphElement
|
||||
label.innerText = `${roundOff(length)}`
|
||||
label.classList.add(SEGMENT_LENGTH_LABEL_TEXT)
|
||||
const slope = (to[1] - from[1]) / (to[0] - from[0])
|
||||
let slopeAngle = ((Math.atan(slope) * 180) / Math.PI) * -1
|
||||
label.style.setProperty('--degree', `${slopeAngle}deg`)
|
||||
label.style.setProperty('--x', `0px`)
|
||||
label.style.setProperty('--y', `0px`)
|
||||
labelWrapper.position.set((from[0] + to[0]) / 2, (from[1] + to[1]) / 2, 0)
|
||||
labelGroup.visible = isHandlesVisible
|
||||
}
|
||||
|
||||
const straightSegmentBody = group.children.find(
|
||||
(child) => child.userData.type === STRAIGHT_SEGMENT_BODY
|
||||
) as Mesh
|
||||
if (straightSegmentBody) {
|
||||
const line = new LineCurve3(
|
||||
new Vector3(from[0], from[1], 0),
|
||||
new Vector3(to[0], to[1], 0)
|
||||
)
|
||||
straightSegmentBody.geometry = new ExtrudeGeometry(shape, {
|
||||
steps: 2,
|
||||
bevelEnabled: false,
|
||||
extrudePath: line,
|
||||
})
|
||||
}
|
||||
const straightSegmentBodyDashed = group.children.find(
|
||||
(child) => child.userData.type === STRAIGHT_SEGMENT_DASH
|
||||
) as Mesh
|
||||
if (straightSegmentBodyDashed) {
|
||||
straightSegmentBodyDashed.geometry = dashedStraight(
|
||||
from,
|
||||
to,
|
||||
shape,
|
||||
scale
|
||||
)
|
||||
}
|
||||
return () =>
|
||||
sceneInfra.updateOverlayDetails({
|
||||
arrowGroup,
|
||||
group,
|
||||
isHandlesVisible,
|
||||
from,
|
||||
to,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
class TangentialArcToSegment implements SegmentUtils {
|
||||
init: SegmentUtils['init'] = ({
|
||||
prevSegment,
|
||||
input,
|
||||
id,
|
||||
pathToNode,
|
||||
isDraftSegment,
|
||||
scale = 1,
|
||||
texture,
|
||||
theme,
|
||||
isSelected,
|
||||
sceneInfra,
|
||||
}) => {
|
||||
if (input.type !== 'straight-segment')
|
||||
return new Error('Invalid segment type')
|
||||
const { from, to } = input
|
||||
const meshName = isDraftSegment
|
||||
? TANGENTIAL_ARC_TO__SEGMENT_DASH
|
||||
: TANGENTIAL_ARC_TO_SEGMENT_BODY
|
||||
|
||||
const group = new Group()
|
||||
const geometry = createArcGeometry({
|
||||
center: [0, 0],
|
||||
radius: 1,
|
||||
startAngle: 0,
|
||||
endAngle: 1,
|
||||
ccw: true,
|
||||
isDashed: isDraftSegment,
|
||||
scale,
|
||||
})
|
||||
const baseColor = getThemeColorForThreeJs(theme)
|
||||
const color = isSelected ? 0x0000ff : baseColor
|
||||
const body = new MeshBasicMaterial({ color })
|
||||
const mesh = new Mesh(geometry, body)
|
||||
const arrowGroup = createArrowhead(scale, theme, color)
|
||||
const extraSegmentGroup = createExtraSegmentHandle(scale, texture, theme)
|
||||
|
||||
group.name = TANGENTIAL_ARC_TO_SEGMENT
|
||||
mesh.userData.type = meshName
|
||||
mesh.name = meshName
|
||||
group.userData = {
|
||||
type: TANGENTIAL_ARC_TO_SEGMENT,
|
||||
id,
|
||||
from,
|
||||
to,
|
||||
prevSegment,
|
||||
pathToNode,
|
||||
isSelected,
|
||||
baseColor,
|
||||
}
|
||||
|
||||
group.add(mesh, arrowGroup, extraSegmentGroup)
|
||||
const updateOverlaysCallback = this.update({
|
||||
prevSegment,
|
||||
input,
|
||||
group,
|
||||
scale,
|
||||
sceneInfra,
|
||||
})
|
||||
if (err(updateOverlaysCallback)) return updateOverlaysCallback
|
||||
|
||||
return {
|
||||
group,
|
||||
updateOverlaysCallback,
|
||||
}
|
||||
}
|
||||
|
||||
update: SegmentUtils['update'] = ({
|
||||
prevSegment,
|
||||
input,
|
||||
group,
|
||||
scale = 1,
|
||||
sceneInfra,
|
||||
}) => {
|
||||
if (input.type !== 'straight-segment')
|
||||
return new Error('Invalid segment type')
|
||||
const { from, to } = input
|
||||
group.userData.from = from
|
||||
group.userData.to = to
|
||||
group.userData.prevSegment = prevSegment
|
||||
const arrowGroup = group.getObjectByName(ARROWHEAD) as Group
|
||||
const extraSegmentGroup = group.getObjectByName(EXTRA_SEGMENT_HANDLE)
|
||||
|
||||
const previousPoint =
|
||||
prevSegment?.type === 'TangentialArcTo'
|
||||
? getTangentPointFromPreviousArc(
|
||||
prevSegment.center,
|
||||
prevSegment.ccw,
|
||||
prevSegment.to
|
||||
)
|
||||
: prevSegment.from
|
||||
|
||||
const arcInfo = getTangentialArcToInfo({
|
||||
arcStartPoint: from,
|
||||
arcEndPoint: to,
|
||||
tanPreviousPoint: previousPoint,
|
||||
obtuse: true,
|
||||
})
|
||||
|
||||
const pxLength = arcInfo.arcLength / scale
|
||||
const shouldHideIdle = pxLength < HIDE_SEGMENT_LENGTH
|
||||
const shouldHideHover = pxLength < HIDE_HOVER_SEGMENT_LENGTH
|
||||
|
||||
const hoveredParent =
|
||||
sceneInfra?.hoveredObject &&
|
||||
getParentGroup(sceneInfra.hoveredObject, [TANGENTIAL_ARC_TO_SEGMENT])
|
||||
let isHandlesVisible = !shouldHideIdle
|
||||
if (hoveredParent && hoveredParent?.uuid === group?.uuid) {
|
||||
isHandlesVisible = !shouldHideHover
|
||||
}
|
||||
|
||||
if (arrowGroup) {
|
||||
arrowGroup.position.set(to[0], to[1], 0)
|
||||
|
||||
const arrowheadAngle =
|
||||
arcInfo.endAngle + (Math.PI / 2) * (arcInfo.ccw ? 1 : -1)
|
||||
arrowGroup.quaternion.setFromUnitVectors(
|
||||
new Vector3(0, 1, 0),
|
||||
new Vector3(Math.cos(arrowheadAngle), Math.sin(arrowheadAngle), 0)
|
||||
)
|
||||
arrowGroup.scale.set(scale, scale, scale)
|
||||
arrowGroup.visible = isHandlesVisible
|
||||
}
|
||||
|
||||
if (extraSegmentGroup) {
|
||||
const circumferenceInPx = (2 * Math.PI * arcInfo.radius) / scale
|
||||
const extraSegmentAngleDelta =
|
||||
(EXTRA_SEGMENT_OFFSET_PX / circumferenceInPx) * Math.PI * 2
|
||||
const extraSegmentAngle =
|
||||
arcInfo.startAngle + (arcInfo.ccw ? 1 : -1) * extraSegmentAngleDelta
|
||||
const extraSegmentOffset = new Vector2(
|
||||
Math.cos(extraSegmentAngle) * arcInfo.radius,
|
||||
Math.sin(extraSegmentAngle) * arcInfo.radius
|
||||
)
|
||||
extraSegmentGroup.position.set(
|
||||
arcInfo.center[0] + extraSegmentOffset.x,
|
||||
arcInfo.center[1] + extraSegmentOffset.y,
|
||||
0
|
||||
)
|
||||
extraSegmentGroup.scale.set(scale, scale, scale)
|
||||
extraSegmentGroup.visible = isHandlesVisible
|
||||
}
|
||||
|
||||
const tangentialArcToSegmentBody = group.children.find(
|
||||
(child) => child.userData.type === TANGENTIAL_ARC_TO_SEGMENT_BODY
|
||||
) as Mesh
|
||||
|
||||
if (tangentialArcToSegmentBody) {
|
||||
const newGeo = createArcGeometry({ ...arcInfo, scale })
|
||||
tangentialArcToSegmentBody.geometry = newGeo
|
||||
}
|
||||
const tangentialArcToSegmentBodyDashed = group.getObjectByName(
|
||||
TANGENTIAL_ARC_TO__SEGMENT_DASH
|
||||
)
|
||||
if (tangentialArcToSegmentBodyDashed instanceof Mesh) {
|
||||
tangentialArcToSegmentBodyDashed.geometry = createArcGeometry({
|
||||
...arcInfo,
|
||||
isDashed: true,
|
||||
scale,
|
||||
})
|
||||
}
|
||||
const angle = normaliseAngle(
|
||||
(arcInfo.endAngle * 180) / Math.PI + (arcInfo.ccw ? 90 : -90)
|
||||
)
|
||||
return () =>
|
||||
sceneInfra.updateOverlayDetails({
|
||||
arrowGroup,
|
||||
group,
|
||||
isHandlesVisible,
|
||||
from,
|
||||
to,
|
||||
angle,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export function createProfileStartHandle({
|
||||
from,
|
||||
id,
|
||||
pathToNode,
|
||||
@ -85,127 +516,6 @@ export function profileStart({
|
||||
return group
|
||||
}
|
||||
|
||||
export function straightSegment({
|
||||
from,
|
||||
to,
|
||||
id,
|
||||
pathToNode,
|
||||
isDraftSegment,
|
||||
scale = 1,
|
||||
callExpName,
|
||||
texture,
|
||||
theme,
|
||||
isSelected = false,
|
||||
}: {
|
||||
from: Coords2d
|
||||
to: Coords2d
|
||||
id: string
|
||||
pathToNode: PathToNode
|
||||
isDraftSegment?: boolean
|
||||
scale?: number
|
||||
callExpName: string
|
||||
texture: Texture
|
||||
theme: Themes
|
||||
isSelected?: boolean
|
||||
}): Group {
|
||||
const segmentGroup = new Group()
|
||||
|
||||
const shape = new Shape()
|
||||
shape.moveTo(0, (-SEGMENT_WIDTH_PX / 2) * scale)
|
||||
shape.lineTo(0, (SEGMENT_WIDTH_PX / 2) * scale)
|
||||
|
||||
let geometry
|
||||
if (isDraftSegment) {
|
||||
geometry = dashedStraight(from, to, shape, scale)
|
||||
} else {
|
||||
const line = new LineCurve3(
|
||||
new Vector3(from[0], from[1], 0),
|
||||
new Vector3(to[0], to[1], 0)
|
||||
)
|
||||
|
||||
geometry = new ExtrudeGeometry(shape, {
|
||||
steps: 2,
|
||||
bevelEnabled: false,
|
||||
extrudePath: line,
|
||||
})
|
||||
}
|
||||
|
||||
const baseColor =
|
||||
callExpName === 'close' ? 0x444444 : getThemeColorForThreeJs(theme)
|
||||
const color = isSelected ? 0x0000ff : baseColor
|
||||
const body = new MeshBasicMaterial({ color })
|
||||
const mesh = new Mesh(geometry, body)
|
||||
mesh.userData.type = isDraftSegment
|
||||
? STRAIGHT_SEGMENT_DASH
|
||||
: STRAIGHT_SEGMENT_BODY
|
||||
mesh.name = STRAIGHT_SEGMENT_BODY
|
||||
|
||||
segmentGroup.userData = {
|
||||
type: STRAIGHT_SEGMENT,
|
||||
id,
|
||||
from,
|
||||
to,
|
||||
pathToNode,
|
||||
isSelected,
|
||||
callExpName,
|
||||
baseColor,
|
||||
}
|
||||
segmentGroup.name = STRAIGHT_SEGMENT
|
||||
segmentGroup.add(mesh)
|
||||
|
||||
const length = Math.sqrt(
|
||||
Math.pow(to[0] - from[0], 2) + Math.pow(to[1] - from[1], 2)
|
||||
)
|
||||
const pxLength = length / scale
|
||||
const shouldHide = pxLength < HIDE_SEGMENT_LENGTH
|
||||
|
||||
// All segment types get an extra segment handle,
|
||||
// Which is a little plus sign that appears at the origin of the segment
|
||||
// and can be dragged to insert a new segment
|
||||
const extraSegmentGroup = createExtraSegmentHandle(scale, texture, theme)
|
||||
const directionVector = new Vector2(
|
||||
to[0] - from[0],
|
||||
to[1] - from[1]
|
||||
).normalize()
|
||||
const offsetFromBase = directionVector.multiplyScalar(
|
||||
EXTRA_SEGMENT_OFFSET_PX * scale
|
||||
)
|
||||
extraSegmentGroup.position.set(
|
||||
from[0] + offsetFromBase.x,
|
||||
from[1] + offsetFromBase.y,
|
||||
0
|
||||
)
|
||||
extraSegmentGroup.visible = !shouldHide
|
||||
segmentGroup.add(extraSegmentGroup)
|
||||
|
||||
// Segment decorators that only apply to non-close segments
|
||||
if (callExpName !== 'close') {
|
||||
// an arrowhead that appears at the end of the segment
|
||||
const arrowGroup = createArrowhead(scale, theme, color)
|
||||
arrowGroup.position.set(to[0], to[1], 0)
|
||||
const dir = new Vector3()
|
||||
.subVectors(
|
||||
new Vector3(to[0], to[1], 0),
|
||||
new Vector3(from[0], from[1], 0)
|
||||
)
|
||||
.normalize()
|
||||
arrowGroup.quaternion.setFromUnitVectors(new Vector3(0, 1, 0), dir)
|
||||
arrowGroup.visible = !shouldHide
|
||||
segmentGroup.add(arrowGroup)
|
||||
|
||||
// A length indicator that appears at the midpoint of the segment
|
||||
const lengthIndicatorGroup = createLengthIndicator({
|
||||
from,
|
||||
to,
|
||||
scale,
|
||||
length,
|
||||
})
|
||||
segmentGroup.add(lengthIndicatorGroup)
|
||||
}
|
||||
|
||||
return segmentGroup
|
||||
}
|
||||
|
||||
function createArrowhead(scale = 1, theme: Themes, color?: number): Group {
|
||||
const baseColor = getThemeColorForThreeJs(theme)
|
||||
const arrowMaterial = new MeshBasicMaterial({
|
||||
@ -267,12 +577,12 @@ function createLengthIndicator({
|
||||
from,
|
||||
to,
|
||||
scale,
|
||||
length,
|
||||
length = 0.1,
|
||||
}: {
|
||||
from: Coords2d
|
||||
to: Coords2d
|
||||
scale: number
|
||||
length: number
|
||||
length?: number
|
||||
}) {
|
||||
const lengthIndicatorGroup = new Group()
|
||||
lengthIndicatorGroup.name = SEGMENT_LENGTH_LABEL
|
||||
@ -300,111 +610,6 @@ function createLengthIndicator({
|
||||
return lengthIndicatorGroup
|
||||
}
|
||||
|
||||
export function tangentialArcToSegment({
|
||||
prevSegment,
|
||||
from,
|
||||
to,
|
||||
id,
|
||||
pathToNode,
|
||||
isDraftSegment,
|
||||
scale = 1,
|
||||
texture,
|
||||
theme,
|
||||
isSelected,
|
||||
}: {
|
||||
prevSegment: SketchGroup['value'][number]
|
||||
from: Coords2d
|
||||
to: Coords2d
|
||||
id: string
|
||||
pathToNode: PathToNode
|
||||
isDraftSegment?: boolean
|
||||
scale?: number
|
||||
texture: Texture
|
||||
theme: Themes
|
||||
isSelected?: boolean
|
||||
}): Group {
|
||||
const group = new Group()
|
||||
|
||||
const previousPoint =
|
||||
prevSegment?.type === 'TangentialArcTo'
|
||||
? getTangentPointFromPreviousArc(
|
||||
prevSegment.center,
|
||||
prevSegment.ccw,
|
||||
prevSegment.to
|
||||
)
|
||||
: prevSegment.from
|
||||
|
||||
const { center, radius, startAngle, endAngle, ccw, arcLength } =
|
||||
getTangentialArcToInfo({
|
||||
arcStartPoint: from,
|
||||
arcEndPoint: to,
|
||||
tanPreviousPoint: previousPoint,
|
||||
obtuse: true,
|
||||
})
|
||||
|
||||
const geometry = createArcGeometry({
|
||||
center,
|
||||
radius,
|
||||
startAngle,
|
||||
endAngle,
|
||||
ccw,
|
||||
isDashed: isDraftSegment,
|
||||
scale,
|
||||
})
|
||||
|
||||
const baseColor = getThemeColorForThreeJs(theme)
|
||||
const color = isSelected ? 0x0000ff : baseColor
|
||||
const body = new MeshBasicMaterial({ color })
|
||||
const mesh = new Mesh(geometry, body)
|
||||
mesh.userData.type = isDraftSegment
|
||||
? TANGENTIAL_ARC_TO__SEGMENT_DASH
|
||||
: TANGENTIAL_ARC_TO_SEGMENT_BODY
|
||||
|
||||
group.userData = {
|
||||
type: TANGENTIAL_ARC_TO_SEGMENT,
|
||||
id,
|
||||
from,
|
||||
to,
|
||||
prevSegment,
|
||||
pathToNode,
|
||||
isSelected,
|
||||
baseColor,
|
||||
}
|
||||
group.name = TANGENTIAL_ARC_TO_SEGMENT
|
||||
|
||||
const arrowGroup = createArrowhead(scale, theme, color)
|
||||
arrowGroup.position.set(to[0], to[1], 0)
|
||||
const arrowheadAngle = endAngle + (Math.PI / 2) * (ccw ? 1 : -1)
|
||||
arrowGroup.quaternion.setFromUnitVectors(
|
||||
new Vector3(0, 1, 0),
|
||||
new Vector3(Math.cos(arrowheadAngle), Math.sin(arrowheadAngle), 0)
|
||||
)
|
||||
const pxLength = arcLength / scale
|
||||
const shouldHide = pxLength < HIDE_SEGMENT_LENGTH
|
||||
arrowGroup.visible = !shouldHide
|
||||
|
||||
const extraSegmentGroup = createExtraSegmentHandle(scale, texture, theme)
|
||||
const circumferenceInPx = (2 * Math.PI * radius) / scale
|
||||
const extraSegmentAngleDelta =
|
||||
(EXTRA_SEGMENT_OFFSET_PX / circumferenceInPx) * Math.PI * 2
|
||||
const extraSegmentAngle = startAngle + (ccw ? 1 : -1) * extraSegmentAngleDelta
|
||||
const extraSegmentOffset = new Vector2(
|
||||
Math.cos(extraSegmentAngle) * radius,
|
||||
Math.sin(extraSegmentAngle) * radius
|
||||
)
|
||||
extraSegmentGroup.position.set(
|
||||
center[0] + extraSegmentOffset.x,
|
||||
center[1] + extraSegmentOffset.y,
|
||||
0
|
||||
)
|
||||
|
||||
extraSegmentGroup.visible = !shouldHide
|
||||
|
||||
group.add(mesh, arrowGroup, extraSegmentGroup)
|
||||
|
||||
return group
|
||||
}
|
||||
|
||||
export function createArcGeometry({
|
||||
center,
|
||||
radius,
|
||||
@ -579,3 +784,8 @@ export function dashedStraight(
|
||||
geo.userData.type = 'dashed'
|
||||
return geo
|
||||
}
|
||||
|
||||
export const segmentUtils = {
|
||||
straight: new StraightSegment(),
|
||||
tangentialArcTo: new TangentialArcToSegment(),
|
||||
} as const
|
||||
|
@ -29,8 +29,8 @@ export const ActionIcon = ({
|
||||
size = 'md',
|
||||
children,
|
||||
}: ActionIconProps) => {
|
||||
const computedIconClassName = `h-auto text-inherit dark:text-current !group-disabled:text-chalkboard-60 !group-disabled:text-chalkboard-60 ${iconClassName}`
|
||||
const computedBgClassName = `bg-chalkboard-20 dark:bg-chalkboard-80 !group-disabled:bg-chalkboard-30 !dark:group-disabled:bg-chalkboard-80 ${bgClassName}`
|
||||
const computedIconClassName = `h-auto text-inherit dark:text-current group-disabled:text-chalkboard-60 group-disabled:text-chalkboard-60 ${iconClassName}`
|
||||
const computedBgClassName = `bg-chalkboard-20 dark:bg-chalkboard-80 group-disabled:bg-chalkboard-30 dark:group-disabled:bg-chalkboard-80 ${bgClassName}`
|
||||
|
||||
return (
|
||||
<div
|
||||
|
@ -151,6 +151,7 @@ export function useCalc({
|
||||
})
|
||||
if (trap(error)) return
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
executeAst({
|
||||
ast,
|
||||
engineCommandManager,
|
||||
|
@ -2,6 +2,7 @@ import { useState, useEffect } from 'react'
|
||||
import { EngineCommandManagerEvents } from 'lang/std/engineConnection'
|
||||
import { engineCommandManager, sceneInfra } from 'lib/singletons'
|
||||
import { throttle, isReducedMotion } from 'lib/utils'
|
||||
import { reportRejection } from 'lib/trap'
|
||||
|
||||
const updateDollyZoom = throttle(
|
||||
(newFov: number) => sceneInfra.camControls.dollyZoom(newFov),
|
||||
@ -16,8 +17,8 @@ export const CamToggle = () => {
|
||||
useEffect(() => {
|
||||
engineCommandManager.addEventListener(
|
||||
EngineCommandManagerEvents.SceneReady,
|
||||
async () => {
|
||||
sceneInfra.camControls.dollyZoom(fov)
|
||||
() => {
|
||||
sceneInfra.camControls.dollyZoom(fov).catch(reportRejection)
|
||||
}
|
||||
)
|
||||
}, [])
|
||||
@ -26,11 +27,11 @@ export const CamToggle = () => {
|
||||
if (isPerspective) {
|
||||
isReducedMotion()
|
||||
? sceneInfra.camControls.useOrthographicCamera()
|
||||
: sceneInfra.camControls.animateToOrthographic()
|
||||
: sceneInfra.camControls.animateToOrthographic().catch(reportRejection)
|
||||
} else {
|
||||
isReducedMotion()
|
||||
? sceneInfra.camControls.usePerspectiveCamera()
|
||||
: sceneInfra.camControls.animateToPerspective()
|
||||
? sceneInfra.camControls.usePerspectiveCamera().catch(reportRejection)
|
||||
: sceneInfra.camControls.animateToPerspective().catch(reportRejection)
|
||||
}
|
||||
setIsPerspective(!isPerspective)
|
||||
}
|
||||
|
@ -71,6 +71,17 @@ function CommandArgOptionInput({
|
||||
inputRef.current?.focus()
|
||||
inputRef.current?.select()
|
||||
}, [inputRef])
|
||||
useEffect(() => {
|
||||
// work around to make sure the user doesn't have to press the down arrow key to focus the first option
|
||||
// instead this makes it move from the first hit
|
||||
const downArrowEvent = new KeyboardEvent('keydown', {
|
||||
key: 'ArrowDown',
|
||||
keyCode: 40,
|
||||
which: 40,
|
||||
bubbles: true,
|
||||
})
|
||||
inputRef?.current?.dispatchEvent(downArrowEvent)
|
||||
}, [])
|
||||
|
||||
// Filter the options based on the query,
|
||||
// resetting the query when the options change
|
||||
|
@ -1,53 +1,43 @@
|
||||
import { useMachine } from '@xstate/react'
|
||||
import { createActorContext } from '@xstate/react'
|
||||
import { editorManager } from 'lib/singletons'
|
||||
import { commandBarMachine } from 'machines/commandBarMachine'
|
||||
import { createContext, useEffect } from 'react'
|
||||
import { EventFrom, StateFrom } from 'xstate'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
type CommandsContextType = {
|
||||
commandBarState: StateFrom<typeof commandBarMachine>
|
||||
commandBarSend: (event: EventFrom<typeof commandBarMachine>) => void
|
||||
}
|
||||
|
||||
export const CommandsContext = createContext<CommandsContextType>({
|
||||
commandBarState: commandBarMachine.initialState,
|
||||
commandBarSend: () => {},
|
||||
})
|
||||
|
||||
export const CommandBarProvider = ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) => {
|
||||
const [commandBarState, commandBarSend] = useMachine(commandBarMachine, {
|
||||
devTools: true,
|
||||
export const CommandsContext = createActorContext(
|
||||
commandBarMachine.provide({
|
||||
guards: {
|
||||
'Command has no arguments': (context, _event) => {
|
||||
'Command has no arguments': ({ context }) => {
|
||||
return (
|
||||
!context.selectedCommand?.args ||
|
||||
Object.keys(context.selectedCommand?.args).length === 0
|
||||
)
|
||||
},
|
||||
'All arguments are skippable': (context, _event) => {
|
||||
'All arguments are skippable': ({ context }) => {
|
||||
return Object.values(context.selectedCommand!.args!).every(
|
||||
(argConfig) => argConfig.skip
|
||||
)
|
||||
},
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
editorManager.setCommandBarSend(commandBarSend)
|
||||
})
|
||||
|
||||
export const CommandBarProvider = ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) => {
|
||||
return (
|
||||
<CommandsContext.Provider
|
||||
value={{
|
||||
commandBarState,
|
||||
commandBarSend,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
<CommandsContext.Provider>
|
||||
<CommandBarProviderInner>{children}</CommandBarProviderInner>
|
||||
</CommandsContext.Provider>
|
||||
)
|
||||
}
|
||||
function CommandBarProviderInner({ children }: { children: React.ReactNode }) {
|
||||
const commandBarActor = CommandsContext.useActorRef()
|
||||
|
||||
useEffect(() => {
|
||||
editorManager.setCommandBarSend(commandBarActor.send)
|
||||
})
|
||||
|
||||
return children
|
||||
}
|
||||
|
@ -52,7 +52,7 @@ function CommandBarReview({ stepBack }: { stepBack: () => void }) {
|
||||
e.preventDefault()
|
||||
commandBarSend({
|
||||
type: 'Submit command',
|
||||
data: argumentsToSubmit,
|
||||
output: argumentsToSubmit,
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -9,7 +9,7 @@ import {
|
||||
getSelectionTypeDisplayText,
|
||||
} from 'lib/selections'
|
||||
import { modelingMachine } from 'machines/modelingMachine'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { StateFrom } from 'xstate'
|
||||
|
||||
const semanticEntityNames: { [key: string]: Array<Selection['type']> } = {
|
||||
@ -48,15 +48,15 @@ function CommandBarSelectionInput({
|
||||
const { commandBarState, commandBarSend } = useCommandsContext()
|
||||
const [hasSubmitted, setHasSubmitted] = useState(false)
|
||||
const selection = useSelector(arg.machineActor, selectionSelector)
|
||||
const initSelectionsByType = useCallback(() => {
|
||||
const selectionsByType = useMemo(() => {
|
||||
const selectionRangeEnd = selection.codeBasedSelections[0]?.range[1]
|
||||
return !selectionRangeEnd || selectionRangeEnd === code.length
|
||||
? 'none'
|
||||
: getSelectionType(selection)
|
||||
}, [selection, code])
|
||||
const selectionsByType = initSelectionsByType()
|
||||
const [canSubmitSelection, setCanSubmitSelection] = useState<boolean>(
|
||||
canSubmitSelectionArg(selectionsByType, arg)
|
||||
const canSubmitSelection = useMemo<boolean>(
|
||||
() => canSubmitSelectionArg(selectionsByType, arg),
|
||||
[selectionsByType]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
@ -66,26 +66,18 @@ function CommandBarSelectionInput({
|
||||
// Fast-forward through this arg if it's marked as skippable
|
||||
// and we have a valid selection already
|
||||
useEffect(() => {
|
||||
console.log('selection input effect', {
|
||||
selectionsByType,
|
||||
canSubmitSelection,
|
||||
arg,
|
||||
})
|
||||
setCanSubmitSelection(canSubmitSelectionArg(selectionsByType, arg))
|
||||
const argValue = commandBarState.context.argumentsToSubmit[arg.name]
|
||||
if (canSubmitSelection && arg.skip && argValue === undefined) {
|
||||
handleSubmit({
|
||||
preventDefault: () => {},
|
||||
} as React.FormEvent<HTMLFormElement>)
|
||||
handleSubmit()
|
||||
}
|
||||
}, [selectionsByType, arg])
|
||||
}, [canSubmitSelection])
|
||||
|
||||
function handleChange() {
|
||||
inputRef.current?.focus()
|
||||
}
|
||||
|
||||
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault()
|
||||
function handleSubmit(e?: React.FormEvent<HTMLFormElement>) {
|
||||
e?.preventDefault()
|
||||
|
||||
if (!canSubmitSelection) {
|
||||
setHasSubmitted(true)
|
||||
|
@ -11,6 +11,7 @@ export function CommandBarOpenButton() {
|
||||
<button
|
||||
className="group rounded-full flex items-center justify-center gap-2 px-2 py-1 bg-primary/10 dark:bg-chalkboard-90 dark:backdrop-blur-sm border-primary hover:border-primary dark:border-chalkboard-50 dark:hover:border-inherit text-primary dark:text-inherit"
|
||||
onClick={() => commandBarSend({ type: 'Open' })}
|
||||
data-testid="command-bar-open-button"
|
||||
>
|
||||
<span>Commands</span>
|
||||
<kbd className="bg-primary/10 dark:bg-chalkboard-80 dark:group-hover:bg-primary font-mono rounded-sm dark:text-inherit inline-block px-1 border-primary dark:border-chalkboard-90">
|
||||
|
@ -681,6 +681,21 @@ const CustomIconMap = {
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
logs: (
|
||||
<svg
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-label="logs"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M6.5 15C6.5 14.1716 5.82843 13.5 5 13.5C4.17157 13.5 3.5 14.1716 3.5 15C3.5 15.8284 4.17157 16.5 5 16.5C5.82843 16.5 6.5 15.8284 6.5 15ZM6.5 10C6.5 9.17157 5.82843 8.5 5 8.5C4.17157 8.5 3.5 9.17157 3.5 10C3.5 10.8284 4.17157 11.5 5 11.5C5.82843 11.5 6.5 10.8284 6.5 10ZM5 3.5C5.82843 3.5 6.5 4.17157 6.5 5C6.5 5.82843 5.82843 6.5 5 6.5C4.17157 6.5 3.5 5.82843 3.5 5C3.5 4.17157 4.17157 3.5 5 3.5ZM8.5 5.5H16.5V4.5H8.5V5.5ZM8.5 10.5H16.5V9.5H8.5V10.5ZM16.5 15.5H8.5V14.5H16.5V15.5Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
'make-variable': (
|
||||
<svg
|
||||
viewBox="0 0 20 20"
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { CommandLog } from 'lang/std/engineConnection'
|
||||
import { engineCommandManager } from 'lib/singletons'
|
||||
import { reportRejection } from 'lib/trap'
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
export function useEngineCommands(): [CommandLog[], () => void] {
|
||||
@ -77,9 +78,11 @@ export const EngineCommands = () => {
|
||||
/>
|
||||
<button
|
||||
data-testid="custom-cmd-send-button"
|
||||
onClick={() =>
|
||||
engineCommandManager.sendSceneCommand(JSON.parse(customCmd))
|
||||
}
|
||||
onClick={() => {
|
||||
engineCommandManager
|
||||
.sendSceneCommand(JSON.parse(customCmd))
|
||||
.catch(reportRejection)
|
||||
}}
|
||||
>
|
||||
Send custom command
|
||||
</button>
|
||||
|
@ -5,13 +5,12 @@ import { PATHS } from 'lib/paths'
|
||||
import React, { createContext } from 'react'
|
||||
import { toast } from 'react-hot-toast'
|
||||
import {
|
||||
Actor,
|
||||
AnyStateMachine,
|
||||
ContextFrom,
|
||||
EventFrom,
|
||||
InterpreterFrom,
|
||||
Prop,
|
||||
StateFrom,
|
||||
assign,
|
||||
fromPromise,
|
||||
} from 'xstate'
|
||||
import { useCommandsContext } from 'hooks/useCommandsContext'
|
||||
import { fileMachine } from 'machines/fileMachine'
|
||||
@ -27,7 +26,7 @@ import { getNextDirName, getNextFileName } from 'lib/desktopFS'
|
||||
type MachineContext<T extends AnyStateMachine> = {
|
||||
state: StateFrom<T>
|
||||
context: ContextFrom<T>
|
||||
send: Prop<InterpreterFrom<T>, 'send'>
|
||||
send: Prop<Actor<T>, 'send'>
|
||||
}
|
||||
|
||||
export const FileContext = createContext(
|
||||
@ -43,239 +42,234 @@ export const FileMachineProvider = ({
|
||||
const { commandBarSend } = useCommandsContext()
|
||||
const { project, file } = useRouteLoaderData(PATHS.FILE) as IndexLoaderData
|
||||
|
||||
const [state, send] = useMachine(fileMachine, {
|
||||
context: {
|
||||
project,
|
||||
selectedDirectory: project,
|
||||
},
|
||||
actions: {
|
||||
navigateToFile: (context, event) => {
|
||||
if (event.data && 'name' in event.data) {
|
||||
commandBarSend({ type: 'Close' })
|
||||
navigate(
|
||||
`..${PATHS.FILE}/${encodeURIComponent(
|
||||
context.selectedDirectory +
|
||||
window.electron.path.sep +
|
||||
event.data.name
|
||||
)}`
|
||||
const [state, send] = useMachine(
|
||||
fileMachine.provide({
|
||||
actions: {
|
||||
renameToastSuccess: ({ event }) => {
|
||||
if (event.type !== 'xstate.done.actor.rename-file') return
|
||||
toast.success(event.output.message)
|
||||
},
|
||||
createToastSuccess: ({ event }) => {
|
||||
if (event.type !== 'xstate.done.actor.create-and-open-file') return
|
||||
toast.success(event.output.message)
|
||||
},
|
||||
toastSuccess: ({ event }) => {
|
||||
if (
|
||||
event.type !== 'xstate.done.actor.rename-file' &&
|
||||
event.type !== 'xstate.done.actor.delete-file'
|
||||
)
|
||||
} else if (
|
||||
event.data &&
|
||||
'path' in event.data &&
|
||||
event.data.path.endsWith(FILE_EXT)
|
||||
) {
|
||||
// Don't navigate to newly created directories
|
||||
navigate(`..${PATHS.FILE}/${encodeURIComponent(event.data.path)}`)
|
||||
}
|
||||
return
|
||||
toast.success(event.output.message)
|
||||
},
|
||||
toastError: ({ event }) => {
|
||||
if (event.type !== 'xstate.done.actor.rename-file') return
|
||||
toast.error(event.output.message)
|
||||
},
|
||||
navigateToFile: ({ context, event }) => {
|
||||
if (event.type !== 'xstate.done.actor.create-and-open-file') return
|
||||
if (event.output && 'name' in event.output) {
|
||||
commandBarSend({ type: 'Close' })
|
||||
navigate(
|
||||
`..${PATHS.FILE}/${encodeURIComponent(
|
||||
context.selectedDirectory +
|
||||
window.electron.path.sep +
|
||||
event.output.name
|
||||
)}`
|
||||
)
|
||||
} else if (
|
||||
event.output &&
|
||||
'path' in event.output &&
|
||||
event.output.path.endsWith(FILE_EXT)
|
||||
) {
|
||||
// Don't navigate to newly created directories
|
||||
navigate(`..${PATHS.FILE}/${encodeURIComponent(event.output.path)}`)
|
||||
}
|
||||
},
|
||||
},
|
||||
addFileToRenamingQueue: assign({
|
||||
itemsBeingRenamed: (context, event) => [
|
||||
...context.itemsBeingRenamed,
|
||||
event.data.path,
|
||||
],
|
||||
}),
|
||||
removeFileFromRenamingQueue: assign({
|
||||
itemsBeingRenamed: (
|
||||
context,
|
||||
event: EventFrom<typeof fileMachine, 'done.invoke.rename-file'>
|
||||
) =>
|
||||
context.itemsBeingRenamed.filter(
|
||||
(path) => path !== event.data.oldPath
|
||||
),
|
||||
}),
|
||||
renameToastSuccess: (_, event) => toast.success(event.data.message),
|
||||
createToastSuccess: (_, event) => toast.success(event.data.message),
|
||||
toastSuccess: (_, event) =>
|
||||
event.data && toast.success((event.data || '') + ''),
|
||||
toastError: (_, event) => toast.error((event.data || '') + ''),
|
||||
},
|
||||
services: {
|
||||
readFiles: async (context: ContextFrom<typeof fileMachine>) => {
|
||||
const newFiles = isDesktop()
|
||||
? (await getProjectInfo(context.project.path)).children
|
||||
: []
|
||||
return {
|
||||
...context.project,
|
||||
children: newFiles,
|
||||
}
|
||||
},
|
||||
createAndOpenFile: async (context, event) => {
|
||||
let createdName = event.data.name.trim() || DEFAULT_FILE_NAME
|
||||
let createdPath: string
|
||||
|
||||
if (event.data.makeDir) {
|
||||
let { name, path } = getNextDirName({
|
||||
entryName: createdName,
|
||||
baseDir: context.selectedDirectory.path,
|
||||
})
|
||||
createdName = name
|
||||
createdPath = path
|
||||
await window.electron.mkdir(createdPath)
|
||||
} else {
|
||||
const { name, path } = getNextFileName({
|
||||
entryName: createdName,
|
||||
baseDir: context.selectedDirectory.path,
|
||||
})
|
||||
createdName = name
|
||||
createdPath = path
|
||||
await window.electron.writeFile(createdPath, event.data.content ?? '')
|
||||
}
|
||||
|
||||
return {
|
||||
message: `Successfully created "${createdName}"`,
|
||||
path: createdPath,
|
||||
}
|
||||
},
|
||||
createFile: async (context, event) => {
|
||||
let createdName = event.data.name.trim() || DEFAULT_FILE_NAME
|
||||
let createdPath: string
|
||||
|
||||
if (event.data.makeDir) {
|
||||
let { name, path } = getNextDirName({
|
||||
entryName: createdName,
|
||||
baseDir: context.selectedDirectory.path,
|
||||
})
|
||||
createdName = name
|
||||
createdPath = path
|
||||
await window.electron.mkdir(createdPath)
|
||||
} else {
|
||||
const { name, path } = getNextFileName({
|
||||
entryName: createdName,
|
||||
baseDir: context.selectedDirectory.path,
|
||||
})
|
||||
createdName = name
|
||||
createdPath = path
|
||||
await window.electron.writeFile(createdPath, event.data.content ?? '')
|
||||
}
|
||||
|
||||
return {
|
||||
path: createdPath,
|
||||
}
|
||||
},
|
||||
renameFile: async (
|
||||
context: ContextFrom<typeof fileMachine>,
|
||||
event: EventFrom<typeof fileMachine, 'Rename file'>
|
||||
) => {
|
||||
const { oldName, newName, isDir } = event.data
|
||||
const name = newName
|
||||
? newName.endsWith(FILE_EXT) || isDir
|
||||
? newName
|
||||
: newName + FILE_EXT
|
||||
: DEFAULT_FILE_NAME
|
||||
const oldPath = window.electron.path.join(
|
||||
context.selectedDirectory.path,
|
||||
oldName
|
||||
)
|
||||
const newPath = window.electron.path.join(
|
||||
context.selectedDirectory.path,
|
||||
name
|
||||
)
|
||||
|
||||
// no-op
|
||||
if (oldPath === newPath) {
|
||||
actors: {
|
||||
readFiles: fromPromise(async ({ input }) => {
|
||||
const newFiles =
|
||||
(isDesktop() ? (await getProjectInfo(input.path)).children : []) ??
|
||||
[]
|
||||
return {
|
||||
message: `Old is the same as new.`,
|
||||
...input,
|
||||
children: newFiles,
|
||||
}
|
||||
}),
|
||||
createAndOpenFile: fromPromise(async ({ input }) => {
|
||||
let createdName = input.name.trim() || DEFAULT_FILE_NAME
|
||||
let createdPath: string
|
||||
|
||||
if (input.makeDir) {
|
||||
let { name, path } = getNextDirName({
|
||||
entryName: createdName,
|
||||
baseDir: input.selectedDirectory.path,
|
||||
})
|
||||
createdName = name
|
||||
createdPath = path
|
||||
await window.electron.mkdir(createdPath)
|
||||
} else {
|
||||
const { name, path } = getNextFileName({
|
||||
entryName: createdName,
|
||||
baseDir: input.selectedDirectory.path,
|
||||
})
|
||||
createdName = name
|
||||
createdPath = path
|
||||
await window.electron.writeFile(createdPath, input.content ?? '')
|
||||
}
|
||||
|
||||
return {
|
||||
message: `Successfully created "${createdName}"`,
|
||||
path: createdPath,
|
||||
}
|
||||
}),
|
||||
createFile: fromPromise(async ({ input }) => {
|
||||
let createdName = input.name.trim() || DEFAULT_FILE_NAME
|
||||
let createdPath: string
|
||||
|
||||
if (input.makeDir) {
|
||||
let { name, path } = getNextDirName({
|
||||
entryName: createdName,
|
||||
baseDir: input.selectedDirectory.path,
|
||||
})
|
||||
createdName = name
|
||||
createdPath = path
|
||||
await window.electron.mkdir(createdPath)
|
||||
} else {
|
||||
const { name, path } = getNextFileName({
|
||||
entryName: createdName,
|
||||
baseDir: input.selectedDirectory.path,
|
||||
})
|
||||
createdName = name
|
||||
createdPath = path
|
||||
await window.electron.writeFile(createdPath, input.content ?? '')
|
||||
}
|
||||
|
||||
return {
|
||||
path: createdPath,
|
||||
}
|
||||
}),
|
||||
renameFile: fromPromise(async ({ input }) => {
|
||||
const { oldName, newName, isDir } = input
|
||||
const name = newName
|
||||
? newName.endsWith(FILE_EXT) || isDir
|
||||
? newName
|
||||
: newName + FILE_EXT
|
||||
: DEFAULT_FILE_NAME
|
||||
const oldPath = window.electron.path.join(
|
||||
input.selectedDirectory.path,
|
||||
oldName
|
||||
)
|
||||
const newPath = window.electron.path.join(
|
||||
input.selectedDirectory.path,
|
||||
name
|
||||
)
|
||||
|
||||
// no-op
|
||||
if (oldPath === newPath) {
|
||||
return {
|
||||
message: `Old is the same as new.`,
|
||||
newPath,
|
||||
oldPath,
|
||||
}
|
||||
}
|
||||
|
||||
// if there are any siblings with the same name, report error.
|
||||
const entries = await window.electron.readdir(
|
||||
window.electron.path.dirname(newPath)
|
||||
)
|
||||
for (let entry of entries) {
|
||||
if (entry === newName) {
|
||||
return Promise.reject(new Error('Filename already exists.'))
|
||||
}
|
||||
}
|
||||
|
||||
window.electron.rename(oldPath, newPath)
|
||||
|
||||
if (!file) {
|
||||
return Promise.reject(new Error('file is not defined'))
|
||||
}
|
||||
|
||||
if (oldPath === file.path && project?.path) {
|
||||
// If we just renamed the current file, navigate to the new path
|
||||
navigate(`..${PATHS.FILE}/${encodeURIComponent(newPath)}`)
|
||||
} else if (file?.path.includes(oldPath)) {
|
||||
// If we just renamed a directory that the current file is in, navigate to the new path
|
||||
navigate(
|
||||
`..${PATHS.FILE}/${encodeURIComponent(
|
||||
file.path.replace(oldPath, newPath)
|
||||
)}`
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
message: `Successfully renamed "${oldName}" to "${name}"`,
|
||||
newPath,
|
||||
oldPath,
|
||||
}
|
||||
}
|
||||
}),
|
||||
deleteFile: fromPromise(async ({ input }) => {
|
||||
const isDir = !!input.children
|
||||
|
||||
// if there are any siblings with the same name, report error.
|
||||
const entries = await window.electron.readdir(
|
||||
window.electron.path.dirname(newPath)
|
||||
)
|
||||
for (let entry of entries) {
|
||||
if (entry === newName) {
|
||||
return Promise.reject(new Error('Filename already exists.'))
|
||||
if (isDir) {
|
||||
await window.electron
|
||||
.rm(input.path, {
|
||||
recursive: true,
|
||||
})
|
||||
.catch((e) => console.error('Error deleting directory', e))
|
||||
} else {
|
||||
await window.electron
|
||||
.rm(input.path)
|
||||
.catch((e) => console.error('Error deleting file', e))
|
||||
}
|
||||
}
|
||||
|
||||
window.electron.rename(oldPath, newPath)
|
||||
// If there are no more files at all in the project, create a main.kcl
|
||||
// for when we navigate to the root.
|
||||
if (!project?.path) {
|
||||
return Promise.reject(new Error('Project path not set.'))
|
||||
}
|
||||
|
||||
if (!file) {
|
||||
return Promise.reject(new Error('file is not defined'))
|
||||
}
|
||||
const entries = await window.electron.readdir(project.path)
|
||||
const hasKclEntries =
|
||||
entries.filter((e: string) => e.endsWith('.kcl')).length !== 0
|
||||
if (!hasKclEntries) {
|
||||
await window.electron.writeFile(
|
||||
window.electron.path.join(project.path, DEFAULT_PROJECT_KCL_FILE),
|
||||
''
|
||||
)
|
||||
// Refresh the route selected above because it's possible we're on
|
||||
// the same path on the navigate, which doesn't cause anything to
|
||||
// refresh, leaving a stale execution state.
|
||||
navigate(0)
|
||||
return {
|
||||
message: 'No more files in project, created main.kcl',
|
||||
}
|
||||
}
|
||||
|
||||
if (oldPath === file.path && project?.path) {
|
||||
// If we just renamed the current file, navigate to the new path
|
||||
navigate(`..${PATHS.FILE}/${encodeURIComponent(newPath)}`)
|
||||
} else if (file?.path.includes(oldPath)) {
|
||||
// If we just renamed a directory that the current file is in, navigate to the new path
|
||||
navigate(
|
||||
`..${PATHS.FILE}/${encodeURIComponent(
|
||||
file.path.replace(oldPath, newPath)
|
||||
)}`
|
||||
)
|
||||
}
|
||||
// If we just deleted the current file or one of its parent directories,
|
||||
// navigate to the project root
|
||||
if (
|
||||
(input.path === file?.path || file?.path.includes(input.path)) &&
|
||||
project?.path
|
||||
) {
|
||||
navigate(`../${PATHS.FILE}/${encodeURIComponent(project.path)}`)
|
||||
}
|
||||
|
||||
return {
|
||||
message: `Successfully renamed "${oldName}" to "${name}"`,
|
||||
newPath,
|
||||
oldPath,
|
||||
}
|
||||
return {
|
||||
message: `Successfully deleted ${isDir ? 'folder' : 'file'} "${
|
||||
input.name
|
||||
}"`,
|
||||
}
|
||||
}),
|
||||
},
|
||||
deleteFile: async (
|
||||
context: ContextFrom<typeof fileMachine>,
|
||||
event: EventFrom<typeof fileMachine, 'Delete file'>
|
||||
) => {
|
||||
const isDir = !!event.data.children
|
||||
|
||||
if (isDir) {
|
||||
await window.electron
|
||||
.rm(event.data.path, {
|
||||
recursive: true,
|
||||
})
|
||||
.catch((e) => console.error('Error deleting directory', e))
|
||||
} else {
|
||||
await window.electron
|
||||
.rm(event.data.path)
|
||||
.catch((e) => console.error('Error deleting file', e))
|
||||
}
|
||||
|
||||
// If there are no more files at all in the project, create a main.kcl
|
||||
// for when we navigate to the root.
|
||||
if (!project?.path) {
|
||||
return Promise.reject(new Error('Project path not set.'))
|
||||
}
|
||||
|
||||
const entries = await window.electron.readdir(project.path)
|
||||
const hasKclEntries =
|
||||
entries.filter((e: string) => e.endsWith('.kcl')).length !== 0
|
||||
if (!hasKclEntries) {
|
||||
await window.electron.writeFile(
|
||||
window.electron.path.join(project.path, DEFAULT_PROJECT_KCL_FILE),
|
||||
''
|
||||
)
|
||||
// Refresh the route selected above because it's possible we're on
|
||||
// the same path on the navigate, which doesn't cause anything to
|
||||
// refresh, leaving a stale execution state.
|
||||
navigate(0)
|
||||
return
|
||||
}
|
||||
|
||||
// If we just deleted the current file or one of its parent directories,
|
||||
// navigate to the project root
|
||||
if (
|
||||
(event.data.path === file?.path ||
|
||||
file?.path.includes(event.data.path)) &&
|
||||
project?.path
|
||||
) {
|
||||
navigate(`../${PATHS.FILE}/${encodeURIComponent(project.path)}`)
|
||||
}
|
||||
|
||||
return `Successfully deleted ${isDir ? 'folder' : 'file'} "${
|
||||
event.data.name
|
||||
}"`
|
||||
}),
|
||||
{
|
||||
input: {
|
||||
project,
|
||||
selectedDirectory: project,
|
||||
},
|
||||
},
|
||||
guards: {
|
||||
'Has at least 1 file': (_, event: EventFrom<typeof fileMachine>) => {
|
||||
if (event.type !== 'done.invoke.read-files') return false
|
||||
return !!event?.data?.children && event.data.children.length > 0
|
||||
},
|
||||
'Is not silent': (_, event) => !event.data?.silent,
|
||||
},
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
return (
|
||||
<FileContext.Provider
|
||||
|
@ -176,9 +176,11 @@ const FileTreeItem = ({
|
||||
`import("${fileOrDir.path.replace(project.path, '.')}")\n` +
|
||||
codeManager.code
|
||||
)
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
codeManager.writeToFile()
|
||||
|
||||
// Prevent seeing the model built one piece at a time when changing files
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
kclManager.executeCode(true)
|
||||
} else {
|
||||
// Let the lsp servers know we closed a file.
|
||||
@ -243,13 +245,13 @@ const FileTreeItem = ({
|
||||
onClickCapture={(e) =>
|
||||
fileSend({
|
||||
type: 'Set selected directory',
|
||||
data: fileOrDir,
|
||||
directory: fileOrDir,
|
||||
})
|
||||
}
|
||||
onFocusCapture={(e) =>
|
||||
fileSend({
|
||||
type: 'Set selected directory',
|
||||
data: fileOrDir,
|
||||
directory: fileOrDir,
|
||||
})
|
||||
}
|
||||
onKeyDown={(e) => e.key === 'Enter' && e.preventDefault()}
|
||||
@ -296,13 +298,13 @@ const FileTreeItem = ({
|
||||
onClickCapture={(e) => {
|
||||
fileSend({
|
||||
type: 'Set selected directory',
|
||||
data: fileOrDir,
|
||||
directory: fileOrDir,
|
||||
})
|
||||
}}
|
||||
onFocusCapture={(e) =>
|
||||
fileSend({
|
||||
type: 'Set selected directory',
|
||||
data: fileOrDir,
|
||||
directory: fileOrDir,
|
||||
})
|
||||
}
|
||||
>
|
||||
@ -388,14 +390,14 @@ interface FileTreeProps {
|
||||
export const FileTreeMenu = () => {
|
||||
const { send } = useFileContext()
|
||||
|
||||
async function createFile() {
|
||||
function createFile() {
|
||||
send({
|
||||
type: 'Create file',
|
||||
data: { name: '', makeDir: false },
|
||||
})
|
||||
}
|
||||
|
||||
async function createFolder() {
|
||||
function createFolder() {
|
||||
send({
|
||||
type: 'Create file',
|
||||
data: { name: '', makeDir: true },
|
||||
@ -482,7 +484,7 @@ export const FileTreeInner = ({
|
||||
onClickCapture={(e) => {
|
||||
fileSend({
|
||||
type: 'Set selected directory',
|
||||
data: fileContext.project,
|
||||
directory: fileContext.project,
|
||||
})
|
||||
}}
|
||||
>
|
||||
|