Compare commits

..

1 Commits

Author SHA1 Message Date
49c8fc4c97 Update bracket example code and some test colors that broke 2024-09-03 18:35:09 -04:00
317 changed files with 14387 additions and 36025 deletions

View File

@ -2,9 +2,7 @@ NODE_ENV=development
DEV=true
VITE_KC_API_WS_MODELING_URL=wss://api.dev.zoo.dev/ws/modeling/commands
VITE_KC_API_BASE_URL=https://api.dev.zoo.dev
BASE_URL=https://api.dev.zoo.dev
VITE_KC_SITE_BASE_URL=https://dev.zoo.dev
VITE_KC_SKIP_AUTH=false
VITE_KC_CONNECTION_TIMEOUT_MS=5000
# ONLY add your token in .env.development.local if you want to skip auth, otherwise this token takes precedence!
#VITE_KC_DEV_TOKEN="your token from dev.zoo.dev should go in .env.development.local"
VITE_KC_DEV_TOKEN="your token from dev.zoo.dev should go in .env.development.local"

View File

@ -13,8 +13,6 @@
"plugin:css-modules/recommended"
],
"rules": {
"@typescript-eslint/no-floating-promises": "error",
"@typescript-eslint/no-misused-promises": "error",
"semi": [
"error",
"never"
@ -26,6 +24,7 @@
{
"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"

View File

@ -44,7 +44,7 @@ jobs:
# TODO: see if we can fetch from main instead if no diff at src/wasm-lib
- name: Run build:wasm
run: "yarn build:wasm"
run: "yarn build:wasm${{ env.BUILD_RELEASE == 'true' && '-dev' || ''}}"
- name: Set nightly version
if: github.event_name == 'schedule'
@ -52,6 +52,7 @@ 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:
@ -63,17 +64,6 @@ 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]
@ -91,6 +81,8 @@ jobs:
CSC_KEY_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
CSC_KEYCHAIN: ${{ secrets.APPLE_SIGNING_IDENTITY }}
CSC_FOR_PULL_REQUEST: true
TAURI_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
VERSION: ${{ github.event_name == 'schedule' && needs.prepare-files.outputs.version || format('v{0}', needs.prepare-files.outputs.version) }}
VERSION_NO_V: ${{ needs.prepare-files.outputs.version }}
WINDOWS_CERTIFICATE_THUMBPRINT: F4C9A52FF7BC26EE5E054946F6B11DEEA94C748D
@ -150,36 +142,41 @@ jobs:
- name: List artifacts in out/
run: ls -R out
- name: Prepare the tauri update bundles (macOS)
if: ${{ env.BUILD_RELEASE && matrix.os == 'macos-14' }}
run: |
for ARCH in arm64 x64; do
TAURI_DIR=out/tauri/$VERSION/macos
TEMP_DIR=temp/$ARCH
mkdir -p $TAURI_DIR
mkdir -p $TEMP_DIR
unzip out/*-$ARCH-mac.zip -d $TEMP_DIR
tar -czvf "$TAURI_DIR/Zoo Modeling App-$ARCH.app.tar.gz" -C $TEMP_DIR "Zoo Modeling App.app"
yarn tauri signer sign "$TAURI_DIR/Zoo Modeling App-$ARCH.app.tar.gz"
done
ls -R out
- name: Prepare the tauri update bundles (Windows)
if: ${{ env.BUILD_RELEASE && matrix.os == 'windows-2022' }}
run: |
$env:TAURI_DIR="out/tauri/${env:VERSION}/nsis"
mkdir -p ${env:TAURI_DIR}
$env:OUT_FILE="${env:TAURI_DIR}/Zoo Modeling App_${env:VERSION_NO_V}_x64-setup.nsis.zip"
7z a -mm=Copy "${env:OUT_FILE}" ./out/*-x64-win.exe
yarn tauri signer sign "${env:OUT_FILE}"
ls -R out
- uses: actions/upload-artifact@v3
with:
name: out-${{ matrix.os }}
path: |
out/Zoo*.*
out/latest*.yml
out/tauri
# TODO: add the 'Build for Mac TestFlight (nightly)' stage 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
# TODO: add the updater tests back
publish-apps-release:
@ -195,6 +192,8 @@ jobs:
NOTES: ${{ github.event_name == 'release' && github.event.release.body || format('Non-release build, commit {0}', github.sha) }}
BUCKET_DIR: ${{ github.event_name == 'schedule' && 'dl.kittycad.io/releases/modeling-app/nightly' || 'dl.kittycad.io/releases/modeling-app' }}
WEBSITE_DIR: ${{ github.event_name == 'schedule' && 'dl.zoo.dev/releases/modeling-app/nightly' || 'dl.zoo.dev/releases/modeling-app' }}
BUCKET_DIR_TAURI: 'dl.kittycad.io/releases/modeling-app/tauri-compat'
WEBSITE_DIR_TAURI: 'dl.zoo.dev/releases/modeling-app/tauri-compat'
URL_CODED_NAME: ${{ github.event_name == 'schedule' && 'Zoo%20Modeling%20App%20%28Nightly%29' || 'Zoo%20Modeling%20App' }}
steps:
- uses: actions/checkout@v4
@ -213,7 +212,7 @@ jobs:
with:
name: out-ubuntu-22.04
path: out
- name: Generate the download static endpoint
run: |
RELEASE_DIR=https://${WEBSITE_DIR}
@ -223,10 +222,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.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" \
--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" \
'{
"version": $version,
"pub_date": $pub_date,
@ -238,22 +235,54 @@ jobs:
"dmg-x64": {
"url": $mac_x64_url
},
"exe-arm64": {
"msi-arm64": {
"url": $windows_arm64_url
},
"exe-x64": {
"msi-x64": {
"url": $windows_x64_url
},
"appimage-arm64": {
"url": $linux_arm64_url
},
"appimage-x64": {
"url": $linux_x64_url
}
}
}' > last_download.json
cat last_download.json
- name: Generate the update static endpoint for tauri
run: |
TAURI_DIR=out/tauri/$VERSION
MAC_ARM64_SIG=`cat $TAURI_DIR/macos/*-arm64.app.tar.gz.sig`
MAC_X64_SIG=`cat $TAURI_DIR/macos/*-x64.app.tar.gz.sig`
WINDOWS_SIG=`cat $TAURI_DIR/nsis/*.nsis.zip.sig`
RELEASE_DIR=https://${WEBSITE_DIR_TAURI}/${VERSION}
jq --null-input \
--arg version "${VERSION}" \
--arg pub_date "${PUB_DATE}" \
--arg notes "${NOTES}" \
--arg mac_arm64_sig "$MAC_ARM64_SIG" \
--arg mac_arm64_url "$RELEASE_DIR/macos/${{ env.URL_CODED_NAME }}-arm64.app.tar.gz" \
--arg mac_x64_sig "$MAC_X64_SIG" \
--arg mac_x64_url "$RELEASE_DIR/macos/${{ env.URL_CODED_NAME }}-x64.app.tar.gz" \
--arg windows_sig "$WINDOWS_SIG" \
--arg windows_url "$RELEASE_DIR/nsis/${{ env.URL_CODED_NAME }}_${VERSION_NO_V}_x64-setup.nsis.zip" \
'{
"version": $version,
"pub_date": $pub_date,
"notes": $notes,
"platforms": {
"darwin-x86_64": {
"signature": $mac_x64_sig,
"url": $mac_x64_url
},
"darwin-aarch64": {
"signature": $mac_arm64_sig,
"url": $mac_arm64_url
},
"windows-x86_64": {
"signature": $windows_sig,
"url": $windows_url
}
}
}' > last_update.json
cat last_update.json
- name: List artifacts
run: "ls -R out"
@ -268,7 +297,7 @@ jobs:
project_id: ${{ env.GOOGLE_CLOUD_PROJECT_ID }}
- name: Upload release files to public bucket
uses: google-github-actions/upload-cloud-storage@v2.2.0
uses: google-github-actions/upload-cloud-storage@v2.1.3
with:
path: out
glob: 'Zoo*'
@ -276,19 +305,33 @@ jobs:
destination: ${{ env.BUCKET_DIR }}
- name: Upload update endpoint to public bucket
uses: google-github-actions/upload-cloud-storage@v2.2.0
uses: google-github-actions/upload-cloud-storage@v2.1.3
with:
path: out
glob: 'latest*'
parent: false
destination: ${{ env.BUCKET_DIR }}
destination: ${{ env.BUCKET_DIR }}
- name: Upload download endpoint to public bucket
uses: google-github-actions/upload-cloud-storage@v2.2.0
uses: google-github-actions/upload-cloud-storage@v2.1.3
with:
path: last_download.json
destination: ${{ env.BUCKET_DIR }}
- name: Upload release files to public bucket for tauri
uses: google-github-actions/upload-cloud-storage@v2.1.1
with:
path: "out/tauri/${{ env.VERSION }}"
glob: '*/Zoo*'
parent: false
destination: ${{ env.BUCKET_DIR_TAURI }}/${{ env.VERSION }}
- name: Upload update endpoint to public bucket for tauri
uses: google-github-actions/upload-cloud-storage@v2.1.1
with:
path: last_update.json
destination: ${{ env.BUCKET_DIR }}
- name: Upload release files to Github
if: ${{ github.event_name == 'release' }}
uses: softprops/action-gh-release@v2

View File

@ -45,7 +45,7 @@ jobs:
- run: yarn xstate:typegen
- run: yarn tsc
- name: Lint
run: yarn eslint --max-warnings 0 src e2e packages/codemirror-lsp-client
run: yarn eslint --max-warnings 0 src e2e
check-typos:

View File

@ -28,7 +28,6 @@ jobs:
dir: ['src/wasm-lib']
steps:
- uses: actions/checkout@v4
- uses: taiki-e/install-action@just
- name: Install latest rust
uses: actions-rs/toolchain@v1
with:
@ -42,7 +41,7 @@ jobs:
- name: Run clippy
run: |
cd "${{ matrix.dir }}"
just lint
cargo clippy --all --tests --benches -- -D warnings
# If this fails, run "cargo check" to update Cargo.lock,
# then add Cargo.lock to the PR.
- name: Check Cargo.lock doesn't need updating

View File

@ -34,7 +34,7 @@ jobs:
- 'src/wasm-lib/**'
playwright-chrome:
timeout-minutes: ${{ matrix.os == 'macos-14' && 60 || 50 }}
timeout-minutes: ${{ matrix.os == 'macos-14' && 60 || 40 }}
strategy:
fail-fast: false
matrix:
@ -232,7 +232,6 @@ 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
@ -263,7 +262,7 @@ jobs:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest, macos-14]
timeout-minutes: 40
timeout-minutes: 30
runs-on: ${{ matrix.os }}
needs: check-rust-changes
steps:
@ -382,7 +381,7 @@ jobs:
echo "retried=true" >>$GITHUB_OUTPUT
echo "run playwright with last failed tests and retry $retry"
if [[ "$IS_UBUNTU" == "true" ]]; then
xvfb-run --auto-servernum --server-args="-screen 0 1280x960x24" -- yarn playwright test --config=playwright.electron.config.ts --last-failed --grep=@electron || true
xvfb-run --auto-servernum --server-args="-screen 0 1280x960x24" -- yarn playwright test --config=playwright.electron.config.ts --grep=@electron || true
else
yarn playwright test --config=playwright.electron.config.ts --grep=@electron || true
fi
@ -411,7 +410,6 @@ 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

View File

@ -7,14 +7,6 @@ XSTATE_TYPEGENS := $(wildcard src/machines/*.typegen.ts)
dev: node_modules public/wasm_lib_bg.wasm $(XSTATE_TYPEGENS)
yarn start
# I'm sorry this is so specific to my setup you may as well ignore this.
# This is so you don't have to deal with electron windows popping up constantly.
# It should work for you other Linux users.
lee-electron-test:
Xephyr -br -ac -noreset -screen 1200x500 :2 &
DISPLAY=:2 NODE_ENV=development PW_TEST_CONNECT_WS_ENDPOINT=ws://127.0.0.1:4444/ yarn tron:test -g "when using the file tree"
killall Xephyr
$(XSTATE_TYPEGENS): $(TS_SRC)
yarn xstate typegen 'src/**/*.ts?(x)'

View File

@ -351,6 +351,25 @@ PS: for the debug panel, the following JSON is useful for snapping the camera
</details>
### Tauri e2e tests
#### Windows (local only until the CI edge version mismatch is fixed)
```
yarn install
yarn build:wasm-dev
cp src/wasm-lib/pkg/wasm_lib_bg.wasm public
yarn vite build --mode development
yarn tauri build --debug -b
$env:KITTYCAD_API_TOKEN="<YOUR_KITTYCAD_API_TOKEN>"
$env:VITE_KC_API_BASE_URL="https://api.dev.zoo.dev"
$env:E2E_TAURI_ENABLED="true"
$env:TS_NODE_COMPILER_OPTIONS='{"module": "commonjs"}'
$env:E2E_APPLICATION=".\src-tauri\target\debug\Zoo Modeling App.exe"
Stop-Process -Name msedgedriver
yarn wdio run wdio.conf.ts
```
## KCL
For how to contribute to KCL, [see our KCL README](https://github.com/KittyCAD/modeling-app/tree/main/src/wasm-lib/kcl).

View File

@ -22,3 +22,8 @@ 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.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -19,7 +19,6 @@ layout: manual
* [`angledLineToX`](kcl/angledLineToX)
* [`angledLineToY`](kcl/angledLineToY)
* [`arc`](kcl/arc)
* [`arrayReduce`](kcl/arrayReduce)
* [`asin`](kcl/asin)
* [`assert`](kcl/assert)
* [`assertEqual`](kcl/assertEqual)
@ -57,7 +56,6 @@ layout: manual
* [`line`](kcl/line)
* [`lineTo`](kcl/lineTo)
* [`ln`](kcl/ln)
* [`loft`](kcl/loft)
* [`log`](kcl/log)
* [`log10`](kcl/log10)
* [`log2`](kcl/log2)
@ -65,7 +63,6 @@ layout: manual
* [`max`](kcl/max)
* [`min`](kcl/min)
* [`mm`](kcl/mm)
* [`offsetPlane`](kcl/offsetPlane)
* [`patternCircular2d`](kcl/patternCircular2d)
* [`patternCircular3d`](kcl/patternCircular3d)
* [`patternLinear2d`](kcl/patternLinear2d)

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@ -13,16 +13,14 @@ arrays can hold objects and vice versa.
`true` or `false` work when defining values.
## Constant declaration
## Variable declaration
Constants are defined with the `let` keyword like so:
Variables 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

View File

@ -8,8 +8,8 @@ import {
PERSIST_MODELING_CONTEXT,
} from './test-utils'
test.beforeEach(async ({ context, page }, testInfo) => {
await setup(context, page, testInfo)
test.beforeEach(async ({ context, page }) => {
await setup(context, page)
})
test.afterEach(async ({ page }, testInfo) => {

View File

@ -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 }, testInfo) => {
await setup(context, page, testInfo)
test.beforeEach(async ({ context, page }) => {
await setup(context, page)
})
test.afterEach(async ({ page }, testInfo) => {

View File

@ -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 }, testInfo) => {
await setup(context, page, testInfo)
test.beforeEach(async ({ context, page }) => {
await setup(context, page)
})
test.afterEach(async ({ page }, testInfo) => {
@ -27,19 +27,9 @@ test.describe('Code pane and errors', () => {
const u = await getUtils(page)
// Load the app with the working starter code
await page.addInitScript(() => {
localStorage.setItem(
'persistCode',
`// Extruded Triangle
const sketch001 = startSketchOn('XZ')
|> startProfileAt([0, 0], %)
|> line([10, 0], %)
|> line([-5, 10], %)
|> lineTo([profileStartX(%), profileStartY(%)], %)
|> close(%)
const extrude001 = extrude(5, sketch001)`
)
})
await page.addInitScript((code) => {
localStorage.setItem('persistCode', code)
}, bracket)
await page.setViewportSize({ width: 1200, height: 500 })
await u.waitForAuthSkipAppStart()
@ -65,8 +55,6 @@ 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
@ -92,7 +80,7 @@ const extrude001 = extrude(5, sketch001)`
// Delete a character to break the KCL
await u.openKclCodePanel()
await page.getByText('thickness, bracketLeg1Sketch)').click()
await page.getByText('extrude(').click()
await page.keyboard.press('Backspace')
// Ensure that a badge appears on the button
@ -103,7 +91,7 @@ const extrude001 = extrude(5, sketch001)`
// error text on hover
await page.hover('.cm-lint-marker-error')
await expect(page.locator('.cm-tooltip').first()).toBeVisible()
await expect(page.getByText('Unexpected token: |').first()).toBeVisible()
// Close the code pane
await codePaneButton.click()
@ -126,7 +114,7 @@ const extrude001 = extrude(5, sketch001)`
// error text on hover
await page.hover('.cm-lint-marker-error')
await expect(page.locator('.cm-tooltip').first()).toBeVisible()
await expect(page.getByText('Unexpected token: |').first()).toBeVisible()
})
test('When error is not in view you can click the badge to scroll to it', async ({
@ -273,7 +261,10 @@ test(
await page.getByText('bracket').click()
await u.waitForPageLoad()
await expect(page.getByTestId('loading')).toBeAttached()
await expect(page.getByTestId('loading')).not.toBeAttached({
timeout: 20_000,
})
})
// If they're open by default, we're not actually testing anything.
@ -301,7 +292,16 @@ test(
await page.getByText('router-template-slate').click()
await u.waitForPageLoad()
await expect(page.getByTestId('loading')).toBeAttached()
await expect(page.getByTestId('loading')).not.toBeAttached({
timeout: 20_000,
})
await expect(
page.getByRole('button', { name: 'Start Sketch' })
).toBeEnabled({
timeout: 20_000,
})
})
await test.step('All panes opened before should be visible', async () => {

View File

@ -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 }, testInfo) => {
await setup(context, page, testInfo)
test.beforeEach(async ({ context, page }) => {
await setup(context, page)
})
test.afterEach(async ({ page }, testInfo) => {

View File

@ -1,8 +1,8 @@
import { test, expect } from '@playwright/test'
import { getUtils, setup, tearDown } from './test-utils'
test.beforeEach(async ({ context, page }, testInfo) => {
await setup(context, page, testInfo)
test.beforeEach(async ({ context, page }) => {
await setup(context, page)
})
test.afterEach(async ({ page }, testInfo) => {

View File

@ -43,6 +43,12 @@ test(
// open the project
await page.getByText(`bracket`).click()
// wait for the project to load
await expect(page.getByTestId('loading')).toBeAttached()
await expect(page.getByTestId('loading')).not.toBeAttached({
timeout: 20_000,
})
// expect zero errors in guter
await expect(page.locator('.cm-lint-marker-error')).not.toBeVisible()
@ -50,12 +56,6 @@ test(
const exportButton = page.getByTestId('export-pane-button')
await expect(exportButton).toBeVisible()
// Wait for the model to finish loading
const modelStateIndicator = page.getByTestId(
'model-state-indicator-execution-done'
)
await expect(modelStateIndicator).toBeVisible({ timeout: 60000 })
const gltfOption = page.getByText('glTF')
const submitButton = page.getByText('Confirm Export')
const exportingToastMessage = page.getByText(`Exporting...`)
@ -104,7 +104,7 @@ test(
},
{ timeout: 15_000 }
)
.toBe(482669)
.toBe(477327)
// clean up output.gltf
await fsp.rm('output.gltf')

View File

@ -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 }, testInfo) => {
await setup(context, page, testInfo)
test.beforeEach(async ({ context, page }) => {
await setup(context, page)
})
test.afterEach(async ({ page }, testInfo) => {

View File

@ -1,18 +1,9 @@
import { test, expect } from '@playwright/test'
import * as fsp from 'fs/promises'
import * as fs from 'fs'
import {
executorInputPath,
getUtils,
setup,
setupElectron,
tearDown,
} from './test-utils'
import { join } from 'path'
import { FILE_EXT } from 'lib/constants'
import { getUtils, setup, setupElectron, tearDown } from './test-utils'
test.beforeEach(async ({ context, page }, testInfo) => {
await setup(context, page, testInfo)
test.beforeEach(async ({ context, page }) => {
await setup(context, page)
})
test.afterEach(async ({ page }, testInfo) => {
@ -117,11 +108,11 @@ test.describe('when using the file tree to', () => {
async ({ browser: _ }, testInfo) => {
const { electronApp, page } = await setupElectron({
testInfo,
folderSetupFn: async () => {},
})
const {
openKclCodePanel,
openFilePanel,
panesOpen,
createAndSelectProject,
pasteCodeInEditor,
createNewFileAndSelect,
@ -133,9 +124,9 @@ test.describe('when using the file tree to', () => {
await page.setViewportSize({ width: 1200, height: 500 })
page.on('console', console.log)
await panesOpen(['files', 'code'])
await createAndSelectProject('project-000')
await openKclCodePanel()
await openFilePanel()
// File the main.kcl with contents
const kclCube = await fsp.readFile(
'src/wasm-lib/tests/executor/inputs/cube.kcl',
@ -159,7 +150,6 @@ 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)
@ -211,659 +201,4 @@ test.describe('when using the file tree to', () => {
await electronApp.close()
}
)
test(
'loading small file, then large, then back to small',
{
tag: '@electron',
},
async ({ browser: _ }, testInfo) => {
const { page } = await setupElectron({
testInfo,
})
const {
panesOpen,
createAndSelectProject,
pasteCodeInEditor,
createNewFile,
openDebugPanel,
closeDebugPanel,
expectCmdLog,
} = await getUtils(page, test)
await page.setViewportSize({ width: 1200, height: 500 })
page.on('console', console.log)
await panesOpen(['files', 'code'])
await createAndSelectProject('project-000')
// Create a small file
const kclCube = await fsp.readFile(
'src/wasm-lib/tests/executor/inputs/cube.kcl',
'utf-8'
)
// pasted into main.kcl
await pasteCodeInEditor(kclCube)
// Create a large lego file
await createNewFile('lego')
const legoFile = page.getByRole('listitem').filter({
has: page.getByRole('button', { name: 'lego.kcl' }),
})
await expect(legoFile).toBeVisible({ timeout: 60_000 })
await legoFile.click()
const kclLego = await fsp.readFile(
'src/wasm-lib/tests/executor/inputs/lego.kcl',
'utf-8'
)
await pasteCodeInEditor(kclLego)
const mainFile = page.getByRole('listitem').filter({
has: page.getByRole('button', { name: 'main.kcl' }),
})
// Open settings and enable the debug panel
await page
.getByRole('link', {
name: 'settings Settings',
})
.click()
await page.locator('#showDebugPanel').getByText('OffOn').click()
await page.getByTestId('settings-close-button').click()
await test.step('swap between small and large files', async () => {
await openDebugPanel()
// Previously created a file so we need to start back at main.kcl
await mainFile.click()
await expectCmdLog('[data-message-type="execution-done"]', 60_000)
// Click the large file
await legoFile.click()
// Once it is building, click back to the smaller file
await mainFile.click()
await expectCmdLog('[data-message-type="execution-done"]', 60_000)
await closeDebugPanel()
})
}
)
})
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 () => {})
})

View File

@ -1,270 +0,0 @@
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
}

View File

@ -38,7 +38,7 @@ test(
await expect(page.getByText(notFoundText).first()).not.toBeVisible()
// Find the make button
const makeButton = page.getByRole('button', { name: 'Make part' })
const makeButton = page.getByRole('button', { name: 'Make' })
// Make sure the button is visible but disabled
await expect(makeButton).toBeVisible()
await expect(makeButton).toBeDisabled()

View File

@ -12,6 +12,7 @@ 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)
@ -146,6 +147,9 @@ test.describe('Can export from electron app', () => {
const u = await getUtils(page)
page.on('console', console.log)
await electronApp.context().addInitScript(async () => {
;(window as any).playwrightSkipFilePicker = true
})
const pointOnModel = { x: 630, y: 280 }
@ -169,10 +173,10 @@ test.describe('Can export from electron app', () => {
// gray at this pixel means the stream has loaded in the most
// user way we can verify it (pixel color)
await expect
.poll(() => u.getGreatestPixDiff(pointOnModel, [85, 85, 85]), {
.poll(() => u.getGreatestPixDiff(pointOnModel, [75, 75, 75]), {
timeout: 10_000,
})
.toBeLessThan(15)
.toBeLessThan(10)
})
const exportLocations: Array<Paths> = []
@ -203,7 +207,7 @@ test.describe('Can export from electron app', () => {
},
{ timeout: 15_000 }
)
.toBe(482669)
.toBe(477327)
// clean up output.gltf
await fsp.rm('output.gltf')
@ -491,6 +495,10 @@ test(
await file.click()
await expect(page.getByTestId('loading')).toBeAttached()
await expect(page.getByTestId('loading')).not.toBeAttached({
timeout: 20_000,
})
await expect(u.codeLocator).toContainText(
'A mounting bracket for the Focusrite Scarlett Solo audio interface'
)
@ -848,10 +856,10 @@ const extrude001 = extrude(200, sketch001)`)
// gray at this pixel means the stream has loaded in the most
// user way we can verify it (pixel color)
await expect
.poll(() => u.getGreatestPixDiff(pointOnModel, [143, 143, 143]), {
.poll(() => u.getGreatestPixDiff(pointOnModel, [132, 132, 132]), {
timeout: 10_000,
})
.toBeLessThan(15)
.toBeLessThan(10)
await expect(async () => {
await page.mouse.move(0, 0, { steps: 5 })
@ -859,8 +867,8 @@ const extrude001 = extrude(200, sketch001)`)
await page.mouse.click(pointOnModel.x, pointOnModel.y)
// check user can interact with model by checking it turns yellow
await expect
.poll(() => u.getGreatestPixDiff(pointOnModel, [180, 180, 137]))
.toBeLessThan(15)
.poll(() => u.getGreatestPixDiff(pointOnModel, [176, 180, 132]))
.toBeLessThan(10)
}).toPass({ timeout: 40_000, intervals: [1_000] })
await page.getByTestId('app-logo').click()
@ -934,15 +942,24 @@ test(
await page.getByText('bracket').click()
await u.waitForPageLoad()
await expect(page.getByTestId('loading')).toBeAttached()
await expect(page.getByTestId('loading')).not.toBeAttached({
timeout: 20_000,
})
await expect(
page.getByRole('button', { name: 'Start Sketch' })
).toBeEnabled({
timeout: 20_000,
})
// gray at this pixel means the stream has loaded in the most
// user way we can verify it (pixel color)
await expect
.poll(() => u.getGreatestPixDiff(pointOnModel, [85, 85, 85]), {
.poll(() => u.getGreatestPixDiff(pointOnModel, [75, 75, 75]), {
timeout: 10_000,
})
.toBeLessThan(15)
.toBeLessThan(10)
})
await test.step('Clicking the logo takes us back to the projects page / home', async () => {
@ -959,15 +976,24 @@ test(
await page.getByText('router-template-slate').click()
await u.waitForPageLoad()
await expect(page.getByTestId('loading')).toBeAttached()
await expect(page.getByTestId('loading')).not.toBeAttached({
timeout: 20_000,
})
await expect(
page.getByRole('button', { name: 'Start Sketch' })
).toBeEnabled({
timeout: 20_000,
})
// gray at this pixel means the stream has loaded in the most
// user way we can verify it (pixel color)
await expect
.poll(() => u.getGreatestPixDiff(pointOnModel, [143, 143, 143]), {
.poll(() => u.getGreatestPixDiff(pointOnModel, [132, 132, 132]), {
timeout: 10_000,
})
.toBeLessThan(15)
.toBeLessThan(10)
})
await test.step('Opening the router-template project should load the stream', async () => {
@ -1370,6 +1396,455 @@ 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(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 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' },

View File

@ -11,8 +11,8 @@ import {
import { TEST_CODE_TRIGGER_ENGINE_EXPORT_ERROR } from './storageStates'
import { bracket } from 'lib/exampleKcl'
test.beforeEach(async ({ context, page }, testInfo) => {
await setup(context, page, testInfo)
test.beforeEach(async ({ context, page }) => {
await setup(context, page)
})
test.afterEach(async ({ page }, testInfo) => {
@ -54,67 +54,6 @@ 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 () => {
@ -346,7 +285,10 @@ 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()
@ -416,7 +358,6 @@ const sketch001 = startSketchAt([-0, -0])
await page.addInitScript(
async ({ code }) => {
localStorage.setItem('persistCode', code)
;(window as any).playwrightSkipFilePicker = true
},
{
code: bracket,
@ -452,22 +393,20 @@ const sketch001 = startSketchAt([-0, -0])
await test.step('The second export is blocked', async () => {
// Find the toast.
// Look out for the toast message
await Promise.all([
expect(exportingToastMessage.first()).toBeVisible(),
expect(alreadyExportingToastMessage).toBeVisible(),
])
await expect(exportingToastMessage).toBeVisible()
await expect(alreadyExportingToastMessage).toBeVisible()
await page.waitForTimeout(1000)
})
await test.step('The first export still succeeds', async () => {
await Promise.all([
expect(exportingToastMessage).not.toBeVisible({ timeout: 15_000 }),
expect(errorToastMessage).not.toBeVisible(),
expect(engineErrorToastMessage).not.toBeVisible(),
expect(successToastMessage).toBeVisible({ timeout: 15_000 }),
expect(alreadyExportingToastMessage).not.toBeVisible({
timeout: 15_000,
}),
])
await expect(exportingToastMessage).not.toBeVisible()
await expect(errorToastMessage).not.toBeVisible()
await expect(engineErrorToastMessage).not.toBeVisible()
await expect(successToastMessage).toBeVisible()
await expect(alreadyExportingToastMessage).not.toBeVisible()
})
})
@ -480,12 +419,10 @@ const sketch001 = startSketchAt([-0, -0])
await expect(exportingToastMessage).toBeVisible()
// Expect it to succeed.
await Promise.all([
expect(exportingToastMessage).not.toBeVisible(),
expect(errorToastMessage).not.toBeVisible(),
expect(engineErrorToastMessage).not.toBeVisible(),
expect(alreadyExportingToastMessage).not.toBeVisible(),
])
await expect(exportingToastMessage).not.toBeVisible()
await expect(errorToastMessage).not.toBeVisible()
await expect(engineErrorToastMessage).not.toBeVisible()
await expect(alreadyExportingToastMessage).not.toBeVisible()
await expect(successToastMessage).toBeVisible()
})

View File

@ -9,8 +9,8 @@ import {
} from './test-utils'
import { uuidv4, roundOff } from 'lib/utils'
test.beforeEach(async ({ context, page }, testInfo) => {
await setup(context, page, testInfo)
test.beforeEach(async ({ context, page }) => {
await setup(context, page)
})
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([2.03, 0], %)`
codeStr += ` |> startProfileAt([1.53, 0], %)`
await expect(u.codeLocator).toHaveText(codeStr)
await click00r(30, 0)
codeStr += ` |> line([2.04, 0], %)`
codeStr += ` |> line([1.53, 0], %)`
await expect(u.codeLocator).toHaveText(codeStr)
await click00r(0, 30)
codeStr += ` |> line([0, -2.03], %)`
codeStr += ` |> line([0, -1.53], %)`
await expect(u.codeLocator).toHaveText(codeStr)
await click00r(-30, 0)
codeStr += ` |> line([-2.04, 0], %)`
codeStr += ` |> line([-1.53, 0], %)`
await expect(u.codeLocator).toHaveText(codeStr)
await click00r(undefined, undefined)
@ -954,68 +954,4 @@ 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] })
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 31 KiB

View File

@ -2,8 +2,8 @@ import { test, expect } from '@playwright/test'
import { commonPoints, getUtils, setup, tearDown } from './test-utils'
test.beforeEach(async ({ context, page }, testInfo) => {
await setup(context, page, testInfo)
test.beforeEach(async ({ context, page }) => {
await setup(context, page)
})
test.afterEach(async ({ page }, testInfo) => {

View File

@ -26,9 +26,7 @@ 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 = {
@ -441,78 +439,46 @@ export async function getUtils(page: Page, test_?: typeof test) {
}
return maxDiff
},
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 () => {
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 () => {
await page.screenshot({
path: './e2e/playwright/temp1.png',
path: './e2e/playwright/temp2.png',
fullPage: true,
})
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
}
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)
}
})().catch(reportRejection)
}, 50)
})().catch(reportRejection)
// 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)
}
}, 50)
}),
emulateNetworkConditions: async (
networkOptions: Protocol.Network.emulateNetworkConditionsParameters
@ -582,16 +548,13 @@ export async function getUtils(page: Page, test_?: typeof test) {
createNewFileAndSelect: async (name: string) => {
return test?.step(`Create a file named ${name}, select it`, async () => {
await openFilePanel(page)
await page.getByTestId('create-file-button').click()
await page.getByTestId('file-rename-field').fill(name)
await page.keyboard.press('Enter')
const newFile = page
await page
.locator('[data-testid="file-pane-scroll-container"] button')
.filter({ hasText: name })
await expect(newFile).toBeVisible()
await newFile.click()
.click()
})
},
@ -622,15 +585,6 @@ export async function getUtils(page: Page, test_?: typeof test) {
})
},
/**
* @deprecated Sorry I don't have time to fix this right now, but runs like
* the one linked below show me that setting the open panes in this manner is not reliable.
* You can either set `openPanes` as a part of the same initScript we run in setupElectron/setup,
* or you can imperatively open the panes with functions like {openKclCodePanel}
* (or we can make a general openPane function that takes a paneId).,
* but having a separate initScript does not seem to work reliably.
* @link https://github.com/KittyCAD/modeling-app/actions/runs/10731890169/job/29762700806?pr=3807#step:20:19553
*/
panesOpen: async (paneIds: PaneId[]) => {
return test?.step(`Setting ${paneIds} panes to be open`, async () => {
await page.addInitScript(
@ -851,11 +805,7 @@ 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,
testInfo?: TestInfo
) {
export async function setup(context: BrowserContext, page: Page) {
await context.addInitScript(
async ({ token, settingsKey, settings, IS_PLAYWRIGHT_KEY }) => {
localStorage.clear()
@ -891,8 +841,6 @@ export async function setup(
secure: true,
},
])
failOnConsoleErrors(page, testInfo)
// kill animations, speeds up tests and reduced flakiness
await page.emulateMedia({ reducedMotion: 'reduce' })
@ -904,12 +852,10 @@ export async function setupElectron({
testInfo,
folderSetupFn,
cleanProjectDir = true,
appSettings,
}: {
testInfo: TestInfo
folderSetupFn?: (projectDirName: string) => Promise<void>
cleanProjectDir?: boolean
appSettings?: Partial<SaveSettingsPayload>
}) {
// create or otherwise clear the folder
const projectDirName = testInfo.outputPath('electron-test-projects-dir')
@ -943,19 +889,15 @@ export async function setupElectron({
if (cleanProjectDir) {
const tempSettingsFilePath = join(projectDirName, SETTINGS_FILE_NAME)
const settingsOverrides = TOML.stringify(
appSettings
? { settings: appSettings }
: {
...TEST_SETTINGS,
settings: {
app: {
...TEST_SETTINGS.app,
projectDirectory: projectDirName,
},
},
}
)
const settingsOverrides = TOML.stringify({
...TEST_SETTINGS,
settings: {
app: {
...TEST_SETTINGS.app,
projectDirectory: projectDirName,
},
},
})
await fsp.writeFile(tempSettingsFilePath, settingsOverrides)
}
@ -966,48 +908,6 @@ 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

View File

@ -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 }, testInfo) => {
await setup(context, page, testInfo)
test.beforeEach(async ({ context, page }) => {
await setup(context, page)
})
test.afterEach(async ({ page }, testInfo) => {
@ -12,8 +12,8 @@ test.afterEach(async ({ page }, testInfo) => {
})
test.describe('Testing Camera Movement', () => {
test('Can move camera reliably', async ({ page, context }) => {
test.skip(process.platform === 'darwin', 'Can move camera reliably')
test('Can moving camera', async ({ page, context }) => {
test.skip(process.platform === 'darwin', 'Can moving camera')
const u = await getUtils(page)
await page.setViewportSize({ width: 1200, height: 500 })
@ -102,13 +102,6 @@ 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])
@ -302,11 +295,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

View File

@ -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 }, testInfo) => {
await setup(context, page, testInfo)
test.beforeEach(async ({ context, page }) => {
await setup(context, page)
})
test.afterEach(async ({ page }, testInfo) => {

View File

@ -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 }, testInfo) => {
await setup(context, page, testInfo)
test.beforeEach(async ({ context, page }) => {
await setup(context, page)
})
test.afterEach(async ({ page }, testInfo) => {

View File

@ -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 }, testInfo) => {
await setup(context, page, testInfo)
test.beforeEach(async ({ context, page }) => {
await setup(context, page)
})
test.afterEach(async ({ page }, testInfo) => {

View File

@ -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 }, testInfo) => {
await setup(context, page, testInfo)
test.beforeEach(async ({ context, page }) => {
await setup(context, page)
})
test.afterEach(async ({ page }, testInfo) => {
@ -31,28 +31,12 @@ 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 = () =>
test.step(`Click in empty space`, async () => {
await page.mouse.click(700, 143)
await expect(page.locator('.cm-line').last()).toHaveClass(
/cm-activeLine/
)
})
page.mouse.click(700, 343).then(() => page.waitForTimeout(100))
const topHorzSegmentClick = () =>
page.mouse
.click(startXPx, 500 - PUR * 20)
.then(() => page.waitForTimeout(100))
page.mouse.click(709, 290).then(() => page.waitForTimeout(100))
const bottomHorzSegmentClick = () =>
page.mouse
.click(startXPx + PUR * 10, 500 - PUR * 10)
.then(() => page.waitForTimeout(100))
page.mouse.click(767, 396).then(() => page.waitForTimeout(100))
await u.clearCommandLogs()
await expect(
@ -187,9 +171,7 @@ test.describe('Testing selections', () => {
await emptySpaceClick()
}
await test.step(`Test hovering and selecting on fresh sketch`, async () => {
await selectionSequence()
})
await selectionSequence()
// hovering in fresh sketch worked, lets try exiting and re-entering
await u.openAndClearDebugPanel()
@ -202,17 +184,16 @@ 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(450) // wait for animation
await page.waitForTimeout(300) // wait for animation
await u.openAndClearDebugPanel()
await u.sendCustomCmd({
@ -239,9 +220,8 @@ test.describe('Testing selections', () => {
await u.closeDebugPanel()
await test.step(`Test hovering and selecting on edited sketch`, async () => {
await selectionSequence()
})
// hover again and check it works
await selectionSequence()
})
test('Solids should be select and deletable', async ({ page }) => {
@ -433,7 +413,7 @@ const sketch002 = startSketchOn(launderExtrudeThroughVar, seg02)
|> line([0, 20.03], %)
|> line([62.61, 0], %, $seg03)
|> lineTo([profileStartX(%), profileStartY(%)], %)
|> close(%)
|> close(%)
`
)
}, KCL_DEFAULT_LENGTH)
@ -536,22 +516,11 @@ const sketch002 = startSketchOn(launderExtrudeThroughVar, seg02)
await page.waitForTimeout(100)
await u.closeDebugPanel()
const extrusionTopCap: Coords2d = [800, 240]
const extrusionTop: Coords2d = [800, 240]
const flatExtrusionFace: Coords2d = [960, 160]
const tangentialArcTo: Coords2d = [840, 160]
const arc: 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])
@ -559,141 +528,26 @@ const sketch002 = startSketchOn(launderExtrudeThroughVar, seg02)
await expect(page.getByTestId('hover-highlight')).not.toBeVisible()
await page.waitForTimeout(200)
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(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()
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(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 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(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(
'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 }, %)'
)
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()
})
test("Extrude button should be disabled if there's no extrudable geometry when nothing is selected", async ({
page,
@ -933,7 +787,7 @@ const extrude001 = extrude(50, sketch001)
await expect
.poll(() => u.getGreatestPixDiff(extrudeWall, noHoverColor))
.toBeLessThan(15)
.toBeLessThan(5)
await page.mouse.move(nothing.x, nothing.y)
await page.waitForTimeout(100)
await page.mouse.move(extrudeWall.x, extrudeWall.y)
@ -944,18 +798,18 @@ const extrude001 = extrude(50, sketch001)
await page.waitForTimeout(200)
await expect(
await u.getGreatestPixDiff(extrudeWall, hoverColor)
).toBeLessThan(15)
).toBeLessThan(6)
await page.mouse.click(extrudeWall.x, extrudeWall.y)
await expect(page.locator('.cm-activeLine')).toHaveText(`|> ${extrudeText}`)
await page.waitForTimeout(200)
await expect(
await u.getGreatestPixDiff(extrudeWall, selectColor)
).toBeLessThan(15)
).toBeLessThan(6)
await page.waitForTimeout(1000)
// check color stays there, i.e. not overridden (this was a bug previously)
await expect(
await u.getGreatestPixDiff(extrudeWall, selectColor)
).toBeLessThan(15)
).toBeLessThan(6)
await page.mouse.move(nothing.x, nothing.y)
await page.waitForTimeout(300)
@ -966,21 +820,21 @@ const extrude001 = extrude(50, sketch001)
hoverColor = [145, 145, 145]
selectColor = [168, 168, 120]
await expect(await u.getGreatestPixDiff(cap, noHoverColor)).toBeLessThan(15)
await expect(await u.getGreatestPixDiff(cap, noHoverColor)).toBeLessThan(6)
await page.mouse.move(cap.x, cap.y)
await expect(page.getByTestId('hover-highlight').first()).toBeVisible()
await expect(page.getByTestId('hover-highlight').first()).toContainText(
removeAfterFirstParenthesis(capText)
)
await page.waitForTimeout(200)
await expect(await u.getGreatestPixDiff(cap, hoverColor)).toBeLessThan(15)
await expect(await u.getGreatestPixDiff(cap, hoverColor)).toBeLessThan(6)
await page.mouse.click(cap.x, cap.y)
await expect(page.locator('.cm-activeLine')).toHaveText(`|> ${capText}`)
await page.waitForTimeout(200)
await expect(await u.getGreatestPixDiff(cap, selectColor)).toBeLessThan(15)
await expect(await u.getGreatestPixDiff(cap, selectColor)).toBeLessThan(6)
await page.waitForTimeout(1000)
// check color stays there, i.e. not overridden (this was a bug previously)
await expect(await u.getGreatestPixDiff(cap, selectColor)).toBeLessThan(15)
await expect(await u.getGreatestPixDiff(cap, selectColor)).toBeLessThan(6)
})
test("Various pipe expressions should and shouldn't allow edit and or extrude", async ({
page,
@ -1022,7 +876,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}
@ -1032,7 +886,7 @@ const extrude001 = extrude(50, sketch001)
|> line([-15.79, 17.08], %)
return ''
}
yohey([15.79, -34.6])
`
)

View File

@ -8,16 +8,12 @@ import {
tearDown,
executorInputPath,
} from './test-utils'
import { SaveSettingsPayload, SettingsLevel } from 'lib/settings/settingsTypes'
import {
TEST_SETTINGS_KEY,
TEST_SETTINGS_CORRUPTED,
TEST_SETTINGS,
} from './storageStates'
import { SaveSettingsPayload } from 'lib/settings/settingsTypes'
import { TEST_SETTINGS_KEY, TEST_SETTINGS_CORRUPTED } from './storageStates'
import * as TOML from '@iarna/toml'
test.beforeEach(async ({ context, page }, testInfo) => {
await setup(context, page, testInfo)
test.beforeEach(async ({ context, page }) => {
await setup(context, page)
})
test.afterEach(async ({ page }, testInfo) => {
@ -69,15 +65,12 @@ test.describe('Testing settings', () => {
page,
}) => {
const u = await getUtils(page)
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' })
})
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',
@ -85,23 +78,11 @@ test.describe('Testing settings', () => {
})
const inputLocator = page.locator('input[name="modeling-showDebugPanel"]')
await test.step('Open settings dialog and set "Show debug panel" to on', async () => {
await page.keyboard.press('ControlOrMeta+Shift+,')
await expect(headingLocator).toBeVisible()
// Open the settings modal with the browser keyboard shortcut
await page.keyboard.press('ControlOrMeta+Shift+,')
/** 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()
})
await expect(headingLocator).toBeVisible()
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
@ -173,33 +154,29 @@ test.describe('Testing settings', () => {
test('Project and user settings can be reset', async ({ page }) => {
const u = await getUtils(page)
await test.step(`Setup`, async () => {
await page.setViewportSize({ width: 1200, height: 500 })
await u.waitForAuthSkipAppStart()
})
await page.setViewportSize({ width: 1200, height: 500 })
await u.waitForAuthSkipAppStart()
await page
.getByRole('button', { name: 'Start Sketch' })
.waitFor({ state: 'visible' })
// Selectors and constants
const projectSettingsTab = page.getByRole('radio', { name: 'Project' })
const userSettingsTab = page.getByRole('radio', { name: 'User' })
const resetButton = (level: SettingsLevel) =>
page.getByRole('button', {
name: `Reset ${level}-level settings`,
})
const resetButton = page.getByRole('button', {
name: 'Restore default 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`)
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()
})
// 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('Set up theme color', async () => {
// Verify we're looking at the project-level settings,
@ -218,40 +195,37 @@ test.describe('Testing settings', () => {
await test.step('Reset project settings', async () => {
// Click the reset settings button.
await resetButton('project').click()
await resetButton.click()
await expect(resetToast('project')).toBeVisible()
await expect(resetToast('project')).not.toBeVisible()
await expect(page.getByText('Settings restored to default')).toBeVisible()
await expect(
page.getByText('Settings restored to default')
).not.toBeVisible()
// Verify it is now set to the inherited user value
await expect(themeColorSetting).toHaveValue(settingValues.user)
await expect(themeColorSetting).toHaveValue(settingValues.default)
await test.step(`Check that the user settings did not change`, async () => {
await userSettingsTab.click()
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(`Set project-level again to test the user-level reset`, async () => {
await projectSettingsTab.click()
await themeColorSetting.fill(settingValues.project)
await userSettingsTab.click()
})
// Set project-level value to 50 again to test the user-level reset
await themeColorSetting.fill(settingValues.project)
await userSettingsTab.click()
})
await test.step('Reset user settings', async () => {
// Click the reset settings button.
await resetButton('user').click()
await expect(resetToast('user')).toBeVisible()
await expect(resetToast('user')).not.toBeVisible()
// Change the setting and click the reset settings button.
await themeColorSetting.fill(settingValues.user)
await resetButton.click()
// Verify it is now set to the default value
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)
})
// Check that the project setting also changed
await projectSettingsTab.click()
await expect(themeColorSetting).toHaveValue(settingValues.default)
})
})
@ -314,7 +288,7 @@ test.describe('Testing settings', () => {
})
await test.step('Refresh the application and see project setting applied', async () => {
await page.reload({ waitUntil: 'domcontentloaded' })
await page.reload()
await expect(logoLink).toHaveCSS('--primary-hue', projectThemeColor)
await settingsCloseButton.click()
@ -329,109 +303,53 @@ test.describe('Testing settings', () => {
}
)
test(
`Load desktop app with no settings file`,
{ tag: '@electron' },
async ({ browser: _ }, testInfo) => {
const { electronApp, page } = await setupElectron({
// This is what makes no settings file get created
cleanProjectDir: false,
testInfo,
})
await page.setViewportSize({ width: 1200, height: 500 })
// Selectors and constants
const errorHeading = page.getByRole('heading', {
name: 'An unextected error occurred',
})
const projectDirLink = page.getByText('Loaded from')
// If the app loads without exploding we're in the clear
await expect(errorHeading).not.toBeVisible()
await expect(projectDirLink).toBeVisible()
await electronApp.close()
}
)
test(
`Load desktop app with a settings file, but no project directory setting`,
{ tag: '@electron' },
async ({ browser: _ }, testInfo) => {
const { electronApp, page } = await setupElectron({
testInfo,
appSettings: {
app: {
themeColor: '259',
},
},
})
await page.setViewportSize({ width: 1200, height: 500 })
// Selectors and constants
const errorHeading = page.getByRole('heading', {
name: 'An unextected error occurred',
})
const projectDirLink = page.getByText('Loaded from')
// If the app loads without exploding we're in the clear
await expect(errorHeading).not.toBeVisible()
await expect(projectDirLink).toBeVisible()
await electronApp.close()
}
)
test(
`Closing settings modal should go back to the original file being viewed`,
{ tag: '@electron' },
async ({ browser: _ }, testInfo) => {
const { electronApp, page } = await setupElectron({
testInfo,
folderSetupFn: async (dir) => {
const bracketDir = join(dir, 'project-000')
await fsp.mkdir(bracketDir, { recursive: true })
await fsp.copyFile(
executorInputPath('cube.kcl'),
join(bracketDir, 'main.kcl')
)
await fsp.copyFile(
executorInputPath('cylinder.kcl'),
join(bracketDir, '2.kcl')
)
},
folderSetupFn: async () => {},
})
const kclCube = await fsp.readFile(executorInputPath('cube.kcl'), 'utf-8')
const kclCylinder = await fsp.readFile(
executorInputPath('cylinder.kcl'),
'utf8'
)
const {
openKclCodePanel,
openFilePanel,
waitForPageLoad,
selectFile,
panesOpen,
createAndSelectProject,
pasteCodeInEditor,
clickPane,
createNewFileAndSelect,
editorTextMatches,
} = await getUtils(page, test)
await page.setViewportSize({ width: 1200, height: 500 })
page.on('console', console.log)
await test.step('Precondition: Open to second project file', async () => {
await expect(page.getByTestId('home-section')).toBeVisible()
await page.getByText('project-000').click()
await waitForPageLoad()
await openKclCodePanel()
await openFilePanel()
await editorTextMatches(kclCube)
await panesOpen([])
await selectFile('2.kcl')
await editorTextMatches(kclCylinder)
await test.step('Precondition: No projects exist', async () => {
await expect(page.getByTestId('home-section')).toBeVisible()
const projectLinksPre = page.getByTestId('project-link')
await expect(projectLinksPre).toHaveCount(0)
})
await createAndSelectProject('project-000')
await clickPane('code')
const kclCube = await fsp.readFile(
'src/wasm-lib/tests/executor/inputs/cube.kcl',
'utf-8'
)
await pasteCodeInEditor(kclCube)
await clickPane('files')
await createNewFileAndSelect('2.kcl')
const kclCylinder = await fsp.readFile(
'src/wasm-lib/tests/executor/inputs/cylinder.kcl',
'utf-8'
)
await pasteCodeInEditor(kclCylinder)
const settingsOpenButton = page.getByRole('link', {
name: 'settings Settings',
})
@ -439,9 +357,6 @@ test.describe('Testing settings', () => {
await test.step('Open and close settings', async () => {
await settingsOpenButton.click()
await expect(
page.getByRole('heading', { name: 'Settings', exact: true })
).toBeVisible()
await settingsCloseButton.click()
})
@ -455,37 +370,25 @@ test.describe('Testing settings', () => {
test('Changing modeling default unit', async ({ page }) => {
const u = await getUtils(page)
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' })
})
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()
})
// 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(`Reset unit setting`, async () => {
await userSettingsTab.click()
await defaultUnitSection.hover()
await defaultUnitRollbackButton.click()
await projectSettingsTab.click()
const resetButton = page.getByRole('button', {
name: 'Restore default settings',
})
// Default unit should be mm
await resetButton.click()
await test.step('Change modeling default unit within project tab', async () => {
const changeUnitOfMeasureInProjectTab = async (unitOfMeasure: string) => {
@ -590,148 +493,4 @@ 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()
})
})
})

View File

@ -534,7 +534,7 @@ test.describe('Text-to-CAD tests', () => {
// Ensure the final toast remains.
await expect(page.getByText(`a 2x10 lego`)).not.toBeVisible()
await expect(page.getByText(`Prompt: "a 2x8 lego`)).not.toBeVisible()
await expect(page.getByText(`a 2x8 lego`)).not.toBeVisible()
await expect(page.getByText(`a 2x4 lego`)).toBeVisible()
// Ensure you can copy the code for the final model.
@ -690,53 +690,40 @@ test(
'Text-to-CAD functionality',
{ tag: '@electron' },
async ({ browserName }, testInfo) => {
const projectName = 'project-000'
const prompt = 'lego 2x4'
const textToCadFileName = 'lego-2x4.kcl'
const { electronApp, page, dir } = await setupElectron({ testInfo })
const fileExists = () =>
fs.existsSync(join(dir, projectName, textToCadFileName))
fs.existsSync(join(dir, 'project-000', 'lego-2x4.kcl'))
const {
createAndSelectProject,
openFilePanel,
openKclCodePanel,
waitForPageLoad,
} = await getUtils(page, test)
const { createAndSelectProject, panesOpen } = await getUtils(page, test)
await page.setViewportSize({ width: 1200, height: 500 })
// Locators
const projectMenuButton = page.getByRole('button', { name: projectName })
const textToCadFileButton = page.getByRole('listitem').filter({
has: page.getByRole('button', { name: textToCadFileName }),
})
const textToCadComment = page.getByText(
`// Generated by Text-to-CAD: ${prompt}`
)
await panesOpen(['code', 'files'])
// Create and navigate to the project
await createAndSelectProject('project-000')
// Wait for Start Sketch otherwise you will not have access Text-to-CAD command
await waitForPageLoad()
await openFilePanel()
await openKclCodePanel()
await expect(
page.getByRole('button', { name: 'Start Sketch' })
).toBeEnabled({
timeout: 20_000,
})
await test.step(`Test file creation`, async () => {
await sendPromptFromCommandBar(page, prompt)
await sendPromptFromCommandBar(page, 'lego 2x4')
// File is considered created if it shows up in the Project Files pane
await expect(textToCadFileButton).toBeVisible({ timeout: 20_000 })
const file = page.getByRole('button', { name: 'lego-2x4.kcl' })
await expect(file).toBeVisible({ timeout: 20_000 })
expect(fileExists()).toBeTruthy()
})
await test.step(`Test file navigation`, async () => {
await expect(projectMenuButton).toContainText('main.kcl')
await textToCadFileButton.click()
const file = page.getByRole('button', { name: 'lego-2x4.kcl' })
await file.click()
const kclComment = page.getByText('Lego 2x4 Brick')
// File can be navigated and loaded assuming a specific KCL comment is loaded into the KCL code pane
await expect(textToCadComment).toBeVisible({ timeout: 20_000 })
await expect(projectMenuButton).toContainText(textToCadFileName)
await expect(kclComment).toBeVisible({ timeout: 20_000 })
})
await test.step(`Test file deletion on rejection`, async () => {
@ -750,8 +737,6 @@ test(
)
await expect(submittingToastMessage).toBeVisible()
expect(fileExists()).toBeFalsy()
// Confirm we've navigated back to the main.kcl file after deletion
await expect(projectMenuButton).toContainText('main.kcl')
})
await electronApp.close()

View File

@ -2,8 +2,8 @@ import { test, expect } from '@playwright/test'
import { doExport, getUtils, makeTemplate, setup, tearDown } from './test-utils'
test.beforeEach(async ({ context, page }, testInfo) => {
await setup(context, page, testInfo)
test.beforeEach(async ({ context, page }) => {
await setup(context, page)
})
test.afterEach(async ({ page }, testInfo) => {

View File

@ -1,30 +1,27 @@
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}"
target:
- target: dmg
- target: dmg
arch:
- x64
- arm64
- target: zip
- target: zip
arch:
- x64
- arm64
notarize:
teamId: 92H8YB3B95
fileAssociations:
- ext: kcl
name: kcl
mimeType: text/vnd.zoo.kcl
description: Zoo KCL File
role: Editor
rank: Owner
win:
artifactName: "${productName}-${version}-${arch}-${os}.${ext}"
target:
@ -39,23 +36,21 @@ 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
name: kcl
mimeType: text/vnd.zoo.kcl
description: Zoo KCL File
role: Editor
msi:
oneClick: false
perMachine: true
nsis:
oneClick: false
perMachine: true
allowElevation: true
license: "LICENSE"
installerIcon: "assets/icon.ico"
include: "./installer.nsh"
linux:
artifactName: "${productName}-${version}-${arch}-${os}.${ext}"
target:
@ -63,13 +58,8 @@ linux:
arch:
- x64
- arm64
fileAssociations:
- ext: kcl
name: kcl
mimeType: text/vnd.zoo.kcl
description: Zoo KCL File
role: Editor
publish:
- provider: generic
url: https://dl.zoo.dev/releases/modeling-app
url: https://dl.zoo.dev/releases/modeling-app/test/electron-builder
channel: latest

View File

@ -15,7 +15,6 @@
/>
<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

2
interface.d.ts vendored
View File

@ -30,6 +30,8 @@ export interface IElectronAPI {
join: typeof path.join
sep: typeof path.sep
rename: (prev: string, next: string) => typeof fs.rename
setBaseUrl: (value: string) => void
loadProjectAtStartup: () => Promise<ProjectState | null>
packageJson: {
name: string
}

View File

@ -1,6 +1,6 @@
{
"name": "zoo-modeling-app",
"version": "0.25.2",
"version": "0.24.12",
"private": true,
"productName": "Zoo Modeling App",
"author": {
@ -34,12 +34,12 @@
"@ts-stack/markdown": "^1.5.0",
"@tweenjs/tween.js": "^23.1.1",
"@xstate/inspect": "^0.8.0",
"@xstate/react": "^4.1.1",
"@xstate/react": "^3.2.2",
"bonjour-service": "^1.2.1",
"codemirror": "^6.0.1",
"decamelize": "^6.0.0",
"electron-squirrel-startup": "^1.0.1",
"electron-updater": "^6.3.0",
"electron-updater": "^6.2.1",
"fuse.js": "^7.0.0",
"html2canvas-pro": "^1.5.8",
"isomorphic-fetch": "^3.0.0",
@ -51,7 +51,7 @@
"react": "^18.3.1",
"react-dom": "^18.2.0",
"react-hot-toast": "^2.4.1",
"react-hotkeys-hook": "^4.5.1",
"react-hotkeys-hook": "^4.5.0",
"react-json-view": "^1.21.3",
"react-modal": "^3.16.1",
"react-modal-promise": "^1.0.2",
@ -64,7 +64,7 @@
"vscode-languageserver-protocol": "^3.17.5",
"vscode-uri": "^3.0.8",
"web-vitals": "^3.5.2",
"xstate": "^5.17.4"
"xstate": "^4.38.2"
},
"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 packages/codemirror-lsp-client",
"lint": "eslint --fix src e2e",
"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)\"",
@ -137,6 +137,7 @@
"@iarna/toml": "^2.2.5",
"@lezer/generator": "^1.7.1",
"@playwright/test": "^1.46.1",
"@tauri-apps/cli": "^2.0.0-rc.9",
"@testing-library/jest-dom": "^5.14.1",
"@testing-library/react": "^15.0.2",
"@types/d3-force": "^3.0.10",
@ -168,7 +169,7 @@
"eslint": "^8.0.1",
"eslint-config-react-app": "^7.0.1",
"eslint-plugin-css-modules": "^2.12.0",
"eslint-plugin-import": "^2.30.0",
"eslint-plugin-import": "^2.25.0",
"eslint-plugin-suggest-no-throw": "^1.0.0",
"happy-dom": "^14.3.10",
"http-server": "^14.1.1",
@ -183,7 +184,7 @@
"tailwindcss": "^3.4.1",
"ts-node": "^10.0.0",
"typescript": "^5.0.0",
"vite": "^5.4.3",
"vite": "^5.4.2",
"vite-plugin-eslint": "^1.8.1",
"vite-plugin-package-version": "^1.1.0",
"vite-tsconfig-paths": "^4.3.2",

View File

@ -72,7 +72,6 @@ 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
@ -196,9 +195,6 @@ export class LanguageServerClient {
}
private processNotifications(notification: LSP.NotificationMessage) {
for (const plugin of this.plugins) {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
plugin.processNotification(notification)
}
for (const plugin of this.plugins) plugin.processNotification(notification)
}
}

View File

@ -12,7 +12,6 @@ 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
},

View File

@ -117,7 +117,6 @@ export class LanguageServerPlugin implements PluginValue {
this.processLspNotification = options.processLspNotification
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.initialize({
documentText: this.getDocText(),
})
@ -150,7 +149,6 @@ 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
}
@ -164,9 +162,7 @@ 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()
}
@ -229,9 +225,7 @@ 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)
@ -532,9 +526,7 @@ export class LanguageServerPlugin implements PluginValue {
processDiagnostics(params: PublishDiagnosticsParams) {
if (params.uri !== this.getDocUri()) return
// Commented to avoid the lint. See TODO below.
// const diagnostics =
params.diagnostics
const diagnostics = params.diagnostics
.map(({ range, message, severity }) => ({
from: posToOffset(this.view.state.doc, range.start)!,
to: posToOffset(this.view.state.doc, range.end)!,

View File

@ -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'] },

Binary file not shown.

View File

@ -1,14 +0,0 @@
@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");
}

View File

@ -1,8 +1,12 @@
import { useEffect, useMemo, useRef } from 'react'
import { MouseEventHandler, useEffect, useMemo, useRef } from 'react'
import { uuidv4 } from 'lib/utils'
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'
@ -10,6 +14,7 @@ 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'
@ -39,6 +44,7 @@ export function App() {
}, [projectName, projectPath])
useHotKeyListener()
const { context, state } = useModelingContext()
const { auth, settings } = useSettingsAuthContext()
const token = auth?.context?.token
@ -67,14 +73,61 @@ 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" ref={ref}>
<div
className="relative h-full flex flex-col"
onMouseMove={handleMouseMove}
ref={ref}
>
<AppHeader
className={'transition-opacity transition-duration-75 ' + paneOpacity}
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={
{
'-webkit-app-region': context.store?.buttonDownInStream
? 'no-drag'
: '',
} as React.CSSProperties
}
project={{ project, file }}
enableMenu={true}
/>

View File

@ -41,7 +41,6 @@ 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
@ -70,6 +69,19 @@ const router = createRouter([
path: PATHS.INDEX,
loader: async () => {
const onDesktop = isDesktop()
if (onDesktop) {
const projectStartupFile =
await window.electron.loadProjectAtStartup()
if (projectStartupFile !== null) {
// Redirect to the file if we have a file path.
if (projectStartupFile.length > 0) {
return redirect(
PATHS.FILE + '/' + encodeURIComponent(projectStartupFile)
)
}
}
}
return onDesktop
? redirect(PATHS.HOME)
: redirect(PATHS.FILE + '/%2F' + BROWSER_PROJECT_NAME)
@ -174,23 +186,21 @@ 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',
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,
},
{
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
}

View File

@ -20,8 +20,6 @@ import {
ToolbarItemResolved,
ToolbarModeName,
} from 'lib/toolbar'
import { isDesktop } from 'lib/isDesktop'
import { openExternalBrowserIfDesktop } from 'lib/openWindow'
export function Toolbar({
className = '',
@ -70,12 +68,12 @@ export function Toolbar({
*/
const configCallbackProps: ToolbarItemCallbackProps = useMemo(
() => ({
modelingState: state,
modelingStateMatches: state.matches,
modelingSend: send,
commandBarSend,
sketchPathId,
}),
[state, send, commandBarSend, sketchPathId]
[state.matches, send, commandBarSend, sketchPathId]
)
/**
@ -124,7 +122,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-30 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-20 dark:border-chalkboard-80 border-t-0 shadow-sm">
<ul
{...props}
ref={toolbarButtonsRef}
@ -290,11 +288,6 @@ const ToolbarItemTooltip = memo(function ToolbarItemContents({
return (
<Tooltip
inert={false}
wrapperStyle={
isDesktop()
? ({ '-webkit-app-region': 'no-drag' } as React.CSSProperties)
: {}
}
position="bottom"
wrapperClassName="!p-4 !pointer-events-auto"
contentClassName="!text-left text-wrap !text-xs !p-0 !pb-2 flex gap-2 !max-w-none !w-72 flex-col items-stretch"
@ -344,7 +337,6 @@ const ToolbarItemTooltip = memo(function ToolbarItemContents({
<li key={link.label} className="contents">
<a
href={link.url}
onClick={openExternalBrowserIfDesktop(link.url)}
target="_blank"
rel="noreferrer"
className="flex items-center rounded-sm p-1 no-underline text-inherit hover:bg-primary/10 hover:text-primary dark:hover:bg-chalkboard-70 dark:hover:text-inherit"

View File

@ -22,16 +22,14 @@ import {
UnreliableSubscription,
} from 'lang/std/engineConnection'
import { EngineCommand } from 'lang/std/artifactGraph'
import { toSync, uuidv4 } from 'lib/utils'
import { 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
@ -85,7 +83,7 @@ export class CameraControls {
pendingPan: Vector2 | null = null
interactionGuards: MouseGuard = cameraMouseDragGuards.KittyCAD
isFovAnimationInProgress = false
perspectiveFovBeforeOrtho = 45
fovBeforeOrtho = 45
get isPerspective() {
return this.camera instanceof PerspectiveCamera
}
@ -102,7 +100,6 @@ 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' &&
@ -130,7 +127,6 @@ export class CameraControls {
}
throttledEngCmd = throttle((cmd: EngineCommand) => {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.engineCommandManager.sendSceneCommand(cmd)
}, 1000 / 30)
@ -143,7 +139,6 @@ export class CameraControls {
...convertThreeCamValuesToEngineCam(threeValues),
},
}
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.engineCommandManager.sendSceneCommand(cmd)
}, 1000 / 15)
@ -156,7 +151,6 @@ 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()
}
@ -224,7 +218,6 @@ 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) {
@ -256,7 +249,6 @@ 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: {
@ -274,7 +266,6 @@ 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: {
@ -344,8 +335,7 @@ export class CameraControls {
this.camera.updateProjectionMatrix()
}
onMouseDown = (event: PointerEvent) => {
this.domElement.setPointerCapture(event.pointerId)
onMouseDown = (event: MouseEvent) => {
this.isDragging = true
this.mouseDownPosition.set(event.clientX, event.clientY)
let interaction = this.getInteractionType(event)
@ -365,7 +355,7 @@ export class CameraControls {
}
}
onMouseMove = (event: PointerEvent) => {
onMouseMove = (event: MouseEvent) => {
if (this.isDragging) {
this.mouseNewPosition.set(event.clientX, event.clientY)
const deltaMove = this.mouseNewPosition
@ -399,33 +389,14 @@ export class CameraControls {
const zoomFudgeFactor = 2280
distance = zoomFudgeFactor / (this.camera.zoom * 45)
}
const panSpeed = (distance / 1000 / 45) * this.perspectiveFovBeforeOrtho
const panSpeed = (distance / 1000 / 45) * this.fovBeforeOrtho
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: PointerEvent) => {
this.domElement.releasePointerCapture(event.pointerId)
onMouseUp = (event: MouseEvent) => {
this.isDragging = false
this.handleEnd()
if (this.syncDirection === 'engineToClient') {
@ -444,19 +415,8 @@ export class CameraControls {
}
onMouseWheel = (event: WheelEvent) => {
const interaction = this.getInteractionType(event)
if (interaction === 'none') return
event.preventDefault()
if (this.syncDirection === 'engineToClient') {
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}`
)
}
this.zoomDataFromLastFrame = event.deltaY
return
}
@ -466,16 +426,8 @@ 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()
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.pendingZoom = 1 + (event.deltaY / window.devicePixelRatio) * 0.001
this.handleEnd()
}
@ -507,7 +459,6 @@ 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(),
@ -536,15 +487,19 @@ 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()
@ -586,7 +541,7 @@ export class CameraControls {
const oldFov = this.camera.fov
const viewHeightFactor = (fov: number) => {
/* *
/* *
/|
/ |
/ |
@ -974,7 +929,6 @@ export class CameraControls {
}
if (isReducedMotion()) {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
onComplete()
return
}
@ -983,7 +937,7 @@ export class CameraControls {
.to({ t: tweenEnd }, duration)
.easing(TWEEN.Easing.Quadratic.InOut)
.onUpdate(({ t }) => cameraAtTime(t))
.onComplete(toSync(onComplete, reportRejection))
.onComplete(onComplete)
.start()
})
}
@ -996,9 +950,9 @@ export class CameraControls {
)
this.isFovAnimationInProgress = true
let currentFov = this.lastPerspectiveFov
this.perspectiveFovBeforeOrtho = currentFov
this.fovBeforeOrtho = currentFov
const targetFov = ORTHOGRAPHIC_MAGIC_FOV
const targetFov = 4
const fovAnimationStep = (currentFov - targetFov) / FRAMES_TO_ANIMATE_IN
let frameWaitOnFinish = 10
@ -1008,7 +962,6 @@ 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) {
@ -1034,11 +987,10 @@ export class CameraControls {
)
}
this.isFovAnimationInProgress = true
const targetFov = this.perspectiveFovBeforeOrtho // Target FOV for perspective
this.lastPerspectiveFov = ORTHOGRAPHIC_MAGIC_FOV
let currentFov = ORTHOGRAPHIC_MAGIC_FOV
const targetFov = this.fovBeforeOrtho // Target FOV for perspective
this.lastPerspectiveFov = 4
let currentFov = 4
const initialCameraUp = this.camera.up.clone()
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.usePerspectiveCamera()
const tempVec = new Vector3()
@ -1047,7 +999,6 @@ 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)
}
@ -1072,10 +1023,10 @@ export class CameraControls {
)
}
this.isFovAnimationInProgress = true
const targetFov = this.perspectiveFovBeforeOrtho // Target FOV for perspective
let currentFov = ORTHOGRAPHIC_MAGIC_FOV
const targetFov = this.fovBeforeOrtho // Target FOV for perspective
this.lastPerspectiveFov = 4
let currentFov = 4
const initialCameraUp = this.camera.up.clone()
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.usePerspectiveCamera()
const tempVec = new Vector3()
@ -1142,7 +1093,7 @@ export class CameraControls {
this.deferReactUpdate(this.reactCameraProperties)
Object.values(this._camChangeCallbacks).forEach((cb) => cb())
}
getInteractionType = (event: MouseEvent) =>
getInteractionType = (event: any) =>
_getInteractionType(
this.interactionGuards,
event,
@ -1224,7 +1175,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(upVector.x, upVector.y, upVector.z),
up: new Vector3(0, 0, 1),
vantage: new Vector3(lookAt.eye.x, lookAt.eye.y, lookAt.eye.z),
}
}
@ -1250,21 +1201,16 @@ function _lookAt(position: Vector3, target: Vector3, up: Vector3): Quaternion {
function _getInteractionType(
interactionGuards: MouseGuard,
event: MouseEvent | WheelEvent,
event: any,
enablePan: boolean,
enableRotate: boolean,
enableZoom: boolean
): interactionType | 'none' {
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'
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
}
/**

View File

@ -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, toSync } from 'lib/utils'
import { throttle } from 'lib/utils'
import {
sceneInfra,
kclManager,
@ -34,15 +34,17 @@ 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, reportRejection, trap } from 'lib/trap'
import { err, trap } from 'lib/trap'
function useShouldHideScene(): { hideClient: boolean; hideServer: boolean } {
const [isCamMoving, setIsCamMoving] = useState(false)
@ -122,9 +124,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 {
@ -212,9 +214,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 (
@ -540,10 +542,12 @@ const ConstraintSymbol = ({
iconName: 'dimension',
},
}
const varName = varNameMap?.[_type]?.varName || 'var'
const name: CustomIconName = varNameMap[_type].iconName
const displayName = varNameMap[_type]?.displayName
const implicitDesc = varNameMap[_type]?.implicitConstraintDesc
const varName =
_type in varNameMap ? varNameMap[_type as LineInputsType].varName : 'var'
const name: CustomIconName = varNameMap[_type as LineInputsType].iconName
const displayName = varNameMap[_type as LineInputsType]?.displayName
const implicitDesc =
varNameMap[_type as LineInputsType]?.implicitConstraintDesc
const _node = useMemo(
() => getNodeFromPath<Expr>(kclManager.ast, pathToNode),
@ -578,7 +582,7 @@ const ConstraintSymbol = ({
}}
// disabled={isConstrained || !convertToVarEnabled}
// disabled={implicitDesc} TODO why does this change styles that are hard to override?
onClick={toSync(async () => {
onClick={async () => {
if (!isConstrained) {
send({
type: 'Convert to variable',
@ -600,23 +604,25 @@ const ConstraintSymbol = ({
if (trap(_node1)) return Promise.reject(_node1)
const shallowPath = _node1.shallowPath
if (!context.sketchDetails || !argPosition) return
const transform = removeSingleConstraintInfo(
shallowPath,
const input = makeRemoveSingleConstraintInput(
argPosition,
shallowPath
)
if (!input || !context.sketchDetails) return
const transform = removeSingleConstraintInfo(
input,
kclManager.ast,
kclManager.programMemory
)
if (!transform) return
const { modifiedAst } = transform
// 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>
@ -682,7 +688,7 @@ const ConstraintSymbol = ({
const throttled = throttle((a: ReactCameraProperties) => {
if (a.type === 'perspective' && a.fov) {
sceneInfra.camControls.dollyZoom(a.fov).catch(reportRejection)
sceneInfra.camControls.dollyZoom(a.fov)
}
}, 1000 / 15)
@ -712,7 +718,6 @@ export const CamDebugSettings = () => {
if (camSettings.type === 'perspective') {
sceneInfra.camControls.useOrthographicCamera()
} else {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
sceneInfra.camControls.usePerspectiveCamera(true)
}
}}
@ -720,7 +725,7 @@ export const CamDebugSettings = () => {
<div>
<button
onClick={() => {
sceneInfra.camControls.resetCameraPosition().catch(reportRejection)
sceneInfra.camControls.resetCameraPosition()
}}
>
Reset Camera Position

View File

@ -1,8 +1,10 @@
import {
BoxGeometry,
DoubleSide,
ExtrudeGeometry,
Group,
Intersection,
LineCurve3,
Mesh,
MeshBasicMaterial,
Object3D,
@ -13,6 +15,7 @@ import {
Points,
Quaternion,
Scene,
Shape,
Vector2,
Vector3,
} from 'three'
@ -24,6 +27,9 @@ import {
OnClickCallbackArgs,
OnMouseEnterLeaveArgs,
RAYCASTABLE_PLANE,
SEGMENT_LENGTH_LABEL,
SEGMENT_LENGTH_LABEL_OFFSET_PX,
SEGMENT_LENGTH_LABEL_TEXT,
SKETCH_GROUP_SEGMENTS,
SKETCH_LAYER,
X_AXIS,
@ -32,6 +38,7 @@ import {
import { isQuaternionVertical, quaternionFromUpNForward } from './helpers'
import {
CallExpression,
getTangentialArcToInfo,
parse,
Path,
PathToNode,
@ -55,9 +62,11 @@ import {
import { getNodeFromPath, getNodePathFromSourceRange } from 'lang/queryAst'
import { executeAst } from 'lang/langHelpers'
import {
createProfileStartHandle,
SegmentUtils,
segmentUtils,
createArcGeometry,
dashedStraight,
profileStart,
straightSegment,
tangentialArcToSegment,
} from './segments'
import {
addCallExpressionsToPipe,
@ -66,7 +75,13 @@ import {
changeSketchArguments,
updateStartProfileAtArgs,
} from 'lang/std/sketch'
import { isArray, isOverlap, roundOff } from 'lib/utils'
import {
isArray,
isOverlap,
normaliseAngle,
roundOff,
throttle,
} from 'lib/utils'
import {
addStartProfileAt,
createArrayExpression,
@ -77,6 +92,7 @@ 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'
@ -86,8 +102,8 @@ import {
getRectangleCallExpressions,
updateRectangleSketch,
} from 'lib/rectangleTool'
import { getThemeColorForThreeJs, Themes } from 'lib/theme'
import { err, reportRejection, trap } from 'lib/trap'
import { getThemeColorForThreeJs } from 'lib/theme'
import { err, trap } from 'lib/trap'
import { CSS2DObject } from 'three/examples/jsm/renderers/CSS2DRenderer'
import { Point3d } from 'wasm-lib/kcl/bindings/Point3d'
@ -106,11 +122,6 @@ 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]
@ -144,35 +155,37 @@ 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
) {
update = segmentUtils.straight.update
callbacks.push(
this.updateStraightSegment({
from: segment.userData.from,
to: segment.userData.to,
group: segment,
scale: factor,
})
)
}
if (
segment.userData.from &&
segment.userData.to &&
segment.userData.prevSegment &&
segment.userData.type === TANGENTIAL_ARC_TO_SEGMENT
) {
update = segmentUtils.tangentialArcTo.update
callbacks.push(
this.updateTangentialArcToSegment({
prevSegment: segment.userData.prevSegment,
from: segment.userData.from,
to: segment.userData.to,
group: segment,
scale: factor,
})
)
}
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)
}
@ -311,7 +324,6 @@ 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
@ -409,7 +421,7 @@ export class SceneEntities {
maybeModdedAst,
sketchGroup.start.__geoMeta.sourceRange
)
const _profileStart = createProfileStartHandle({
const _profileStart = profileStart({
from: sketchGroup.start.from,
id: sketchGroup.start.__geoMeta.id,
pathToNode: segPathToNode,
@ -464,31 +476,50 @@ export class SceneEntities {
if (err(_node1)) return
const callExpName = _node1.node?.callee?.name
const initSegment =
segment.type === 'TangentialArcTo'
? segmentUtils.tangentialArcTo.init
: segmentUtils.straight.init
const result = initSegment({
prevSegment: sketchGroup.value[index - 1],
callExpName,
input: {
type: 'straight-segment',
if (segment.type === 'TangentialArcTo') {
seg = tangentialArcToSegment({
prevSegment: sketchGroup.value[index - 1],
from: segment.from,
to: segment.to,
},
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)
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,
})
)
}
seg.layers.set(SKETCH_LAYER)
seg.traverse((child) => {
child.layers.set(SKETCH_LAYER)
@ -571,19 +602,16 @@ export class SceneEntities {
kclManager.programMemory.get(variableDeclarationName),
variableDeclarationName
)
if (err(sg)) return Promise.reject(sg)
const lastSeg = sg?.value?.slice(-1)[0] || sg.start
if (err(sg)) return 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,
input: {
type: 'straight-segment',
to: lastSeg.to,
from: lastSeg.to,
},
to: [lastSeg.to[0], lastSeg.to[1]],
from: [lastSeg.to[0], lastSeg.to[1]],
fnName: segmentName,
pathToNode: sketchPathToNode,
})
@ -606,7 +634,6 @@ 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
@ -654,11 +681,8 @@ export class SceneEntities {
const tmp = addNewSketchLn({
node: kclManager.ast,
programMemory: kclManager.programMemory,
input: {
type: 'straight-segment',
from: [lastSegment.to[0], lastSegment.to[1]],
to: [intersection2d.x, intersection2d.y],
},
to: [intersection2d.x, intersection2d.y],
from: [lastSegment.to[0], lastSegment.to[1]],
fnName:
lastSegment.type === 'TangentialArcTo'
? 'tangentialArcTo'
@ -677,7 +701,7 @@ export class SceneEntities {
if (profileStart) {
sceneInfra.modelingSend({ type: 'CancelSketch' })
} else {
await this.setUpDraftSegment(
this.setUpDraftSegment(
sketchPathToNode,
forward,
up,
@ -747,7 +771,6 @@ 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)
@ -795,7 +818,6 @@ 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
@ -809,14 +831,14 @@ export class SceneEntities {
sketchPathToNode || [],
'VariableDeclaration'
)
if (trap(_node)) return
if (trap(_node)) return Promise.reject(_node)
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
if (trap(_recastAst)) return Promise.reject(_recastAst)
_ast = _recastAst
// Update the primary AST and unequip the rectangle tool
@ -836,7 +858,7 @@ export class SceneEntities {
programMemory.get(variableDeclarationName),
variableDeclarationName
)
if (err(sketchGroup)) return
if (err(sketchGroup)) return sketchGroup
const sgPaths = sketchGroup.value
const orthoFactor = orthoScale(sceneInfra.camControls.camera)
@ -870,11 +892,9 @@ 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,
@ -891,7 +911,6 @@ export class SceneEntities {
})
}
},
// eslint-disable-next-line @typescript-eslint/no-misused-promises
onDrag: async ({
selected,
intersectionPoint,
@ -925,11 +944,8 @@ export class SceneEntities {
const mod = addNewSketchLn({
node: kclManager.ast,
programMemory: kclManager.programMemory,
input: {
type: 'straight-segment',
to: [intersectionPoint.twoD.x, intersectionPoint.twoD.y],
from: prevSegment.from,
},
to: [intersectionPoint.twoD.x, intersectionPoint.twoD.y],
from: [prevSegment.from[0], prevSegment.from[1]],
// 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
@ -942,7 +958,6 @@ 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,
@ -1050,7 +1065,7 @@ export class SceneEntities {
group.userData.from[0],
group.userData.from[1],
]
const dragTo: [number, number] = [intersection2d.x, intersection2d.y]
const to: [number, number] = [intersection2d.x, intersection2d.y]
let modifiedAst = draftInfo ? draftInfo.truncatedAst : { ...kclManager.ast }
const _node = getNodeFromPath<CallExpression>(
@ -1073,11 +1088,8 @@ export class SceneEntities {
modded = updateStartProfileAtArgs({
node: modifiedAst,
pathToNode,
input: {
type: 'straight-segment',
to: dragTo,
from,
},
to,
from,
previousProgramMemory: kclManager.programMemory,
})
} else {
@ -1085,11 +1097,8 @@ export class SceneEntities {
modifiedAst,
kclManager.programMemory,
[node.start, node.end],
{
type: 'straight-segment',
from,
to: dragTo,
}
to,
from
)
}
if (trap(modded)) return
@ -1152,7 +1161,7 @@ export class SceneEntities {
)
)
sceneInfra.overlayCallbacks(callBacks)
})().catch(reportRejection)
})()
}
/**
@ -1192,54 +1201,269 @@ 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) {
update = segmentUtils.tangentialArcTo.update
return this.updateTangentialArcToSegment({
prevSegment: sgPaths[index - 1],
from: segment.from,
to: segment.to,
group: group,
scale: factor,
})
} else if (type === STRAIGHT_SEGMENT) {
update = segmentUtils.straight.update
}
const callBack =
update &&
!err(update) &&
update({
input,
return this.updateStraightSegment({
from: segment.from,
to: segment.to,
group,
scale: factor,
prevSegment: sgPaths[index - 1],
sceneInfra,
})
if (callBack && !err(callBack)) return callBack
if (type === PROFILE_START) {
} else if (type === PROFILE_START) {
group.position.set(segment.from[0], segment.from[1], 0)
group.scale.set(factor, factor, factor)
}
return () => null
}
/**
* 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)
}
})
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,
})
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)
@ -1334,30 +1558,27 @@ 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) {
update = segmentUtils.straight.update
} else if (parent.name === TANGENTIAL_ARC_TO_SEGMENT) {
update = segmentUtils.tangentialArcTo.update
}
update &&
update({
prevSegment: parent.userData.prevSegment,
input,
this.updateStraightSegment({
from: parent.userData.from,
to: parent.userData.to,
group: parent,
scale: factor,
sceneInfra,
})
} else if (parent.name === TANGENTIAL_ARC_TO_SEGMENT) {
this.updateTangentialArcToSegment({
prevSegment: parent.userData.prevSegment,
from: parent.userData.from,
to: parent.userData.to,
group: parent,
scale: factor,
})
}
return
}
editorManager.setHighlightRange([[0, 0]])
@ -1372,30 +1593,27 @@ 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) {
update = segmentUtils.straight.update
} else if (parent.name === TANGENTIAL_ARC_TO_SEGMENT) {
update = segmentUtils.tangentialArcTo.update
}
update &&
update({
prevSegment: parent.userData.prevSegment,
input,
this.updateStraightSegment({
from: parent.userData.from,
to: parent.userData.to,
group: parent,
scale: factor,
sceneInfra,
})
} else if (parent.name === TANGENTIAL_ARC_TO_SEGMENT) {
this.updateTangentialArcToSegment({
prevSegment: parent.userData.prevSegment,
from: parent.userData.from,
to: parent.userData.to,
group: parent,
scale: factor,
})
}
}
const isSelected = parent?.userData?.isSelected
colorSegment(

View File

@ -105,6 +105,10 @@ 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 = () => {}

View File

@ -26,7 +26,6 @@ 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,
@ -36,448 +35,18 @@ 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 { normaliseAngle, roundOff } from 'lib/utils'
import { SegmentOverlayPayload } from 'machines/modelingMachine'
import { SegmentInputs } from 'lang/std/stdTypes'
import { err } from 'lib/trap'
import { roundOff } from 'lib/utils'
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({
export function profileStart({
from,
id,
pathToNode,
@ -516,6 +85,127 @@ export function createProfileStartHandle({
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({
@ -577,12 +267,12 @@ function createLengthIndicator({
from,
to,
scale,
length = 0.1,
length,
}: {
from: Coords2d
to: Coords2d
scale: number
length?: number
length: number
}) {
const lengthIndicatorGroup = new Group()
lengthIndicatorGroup.name = SEGMENT_LENGTH_LABEL
@ -610,6 +300,111 @@ 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,
@ -784,8 +579,3 @@ export function dashedStraight(
geo.userData.type = 'dashed'
return geo
}
export const segmentUtils = {
straight: new StraightSegment(),
tangentialArcTo: new TangentialArcToSegment(),
} as const

View File

@ -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

View File

@ -6,9 +6,6 @@
grid-template-columns: 1fr auto 1fr;
user-select: none;
-webkit-user-select: none;
}
.header.desktopApp {
/* Make the header act as a handle to drag the electron app window,
* per the electron docs: https://www.electronjs.org/docs/latest/tutorial/window-customization#set-custom-draggable-region
* all interactive elements opt-out of this behavior by default in src/index.css

View File

@ -6,7 +6,6 @@ import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
import styles from './AppHeader.module.css'
import { RefreshButton } from 'components/RefreshButton'
import { CommandBarOpenButton } from './CommandBarOpenButton'
import { isDesktop } from 'lib/isDesktop'
interface AppHeaderProps extends React.PropsWithChildren {
showToolbar?: boolean
@ -33,9 +32,7 @@ export const AppHeader = ({
className={
'w-full grid ' +
styles.header +
` ${
isDesktop() ? styles.desktopApp + ' ' : ''
}overlaid-panes sticky top-0 z-20 px-2 items-start ` +
' overlaid-panes sticky top-0 z-20 px-2 items-start ' +
className
}
style={style}

Some files were not shown because too many files have changed in this diff Show More