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
91 changed files with 770 additions and 6998 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

@ -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'
@ -81,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
@ -140,12 +142,37 @@ 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
@ -165,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
@ -183,7 +212,7 @@ jobs:
with:
name: out-ubuntu-22.04
path: out
- name: Generate the download static endpoint
run: |
RELEASE_DIR=https://${WEBSITE_DIR}
@ -195,8 +224,6 @@ jobs:
--arg mac_x64_url "$RELEASE_DIR/${{ env.URL_CODED_NAME }}-${VERSION_NO_V}-x64-mac.dmg" \
--arg windows_arm64_url "$RELEASE_DIR/${{ env.URL_CODED_NAME }}-${VERSION_NO_V}-arm64-win.msi" \
--arg windows_x64_url "$RELEASE_DIR/${{ env.URL_CODED_NAME }}-${VERSION_NO_V}-x64-win.msi" \
--arg 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" \
'{
"version": $version,
"pub_date": $pub_date,
@ -213,17 +240,49 @@ jobs:
},
"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"
@ -238,45 +297,41 @@ 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*'
parent: false
destination: ${{ env.BUCKET_DIR }}
# TODO: remove workaround introduced in https://github.com/KittyCAD/modeling-app/issues/3817
- name: Upload release files to public bucket (test/electron-builder workaround)
uses: google-github-actions/upload-cloud-storage@v2.2.0
with:
path: out
glob: 'Zoo*'
parent: false
destination: '${{ env.BUCKET_DIR }}/test/electron-builder'
- name: Upload update endpoint to public bucket
uses: google-github-actions/upload-cloud-storage@v2.2.0
uses: google-github-actions/upload-cloud-storage@v2.1.3
with:
path: out
glob: 'latest*'
parent: false
destination: ${{ env.BUCKET_DIR }}
# TODO: remove workaround introduced in https://github.com/KittyCAD/modeling-app/issues/3817
- name: Upload update endpoint to public bucket (test/electron-builder workaround)
uses: google-github-actions/upload-cloud-storage@v2.2.0
with:
path: out
glob: 'latest*'
parent: false
destination: '${{ env.BUCKET_DIR }}/test/electron-builder'
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

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

@ -262,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:
@ -381,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

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

@ -56,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)
@ -64,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 it is too large Load Diff

View File

@ -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()
@ -271,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.
@ -299,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

@ -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(477481)
.toBe(477327)
// clean up output.gltf
await fsp.rm('output.gltf')

View File

@ -112,8 +112,7 @@ test.describe('when using the file tree to', () => {
})
const {
openKclCodePanel,
openFilePanel,
panesOpen,
createAndSelectProject,
pasteCodeInEditor,
createNewFileAndSelect,
@ -125,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',
@ -202,78 +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()
})
}
)
})

View File

@ -147,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 }
@ -170,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> = []
@ -204,7 +207,7 @@ test.describe('Can export from electron app', () => {
},
{ timeout: 15_000 }
)
.toBe(477481)
.toBe(477327)
// clean up output.gltf
await fsp.rm('output.gltf')
@ -492,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'
)
@ -849,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 })
@ -860,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()
@ -935,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 () => {
@ -960,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 () => {
@ -1719,7 +1744,7 @@ test.describe('Renaming in the file tree', () => {
})
await test.step('Rename the folder', async () => {
await page.waitForTimeout(2000)
await page.waitForTimeout(60000)
await folderToRename.click({ button: 'right' })
await expect(renameMenuItem).toBeVisible()
await renameMenuItem.click()

View File

@ -358,7 +358,6 @@ const sketch001 = startSketchAt([-0, -0])
await page.addInitScript(
async ({ code }) => {
localStorage.setItem('persistCode', code)
;(window as any).playwrightSkipFilePicker = true
},
{
code: bracket,
@ -394,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()
})
})
@ -422,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()
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 40 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: 31 KiB

After

Width:  |  Height:  |  Size: 32 KiB

View File

@ -548,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()
})
},
@ -588,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(
@ -864,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')
@ -903,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)
}

View File

@ -787,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)
@ -798,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)
@ -820,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,

View File

@ -288,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()
@ -303,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',
})
@ -413,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()
})

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

@ -11,23 +11,16 @@ 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}"
@ -45,12 +38,6 @@ win:
sign: "./sign-win.js"
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
@ -60,6 +47,7 @@ nsis:
oneClick: false
perMachine: true
allowElevation: true
license: "LICENSE"
installerIcon: "assets/icon.ico"
include: "./installer.nsh"
@ -70,14 +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

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.1",
"version": "0.24.12",
"private": true,
"productName": "Zoo Modeling App",
"author": {
@ -39,7 +39,7 @@
"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",
@ -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",

View File

@ -122,11 +122,11 @@ export function App() {
// Override the electron window draggable region behavior as well
// when the button is down in the stream
style={
isDesktop() && context.store?.buttonDownInStream
? ({
'-webkit-app-region': 'no-drag',
} as React.CSSProperties)
: {}
{
'-webkit-app-region': context.store?.buttonDownInStream
? 'no-drag'
: '',
} as React.CSSProperties
}
project={{ project, file }}
enableMenu={true}

View File

@ -69,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)

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 = '',
@ -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

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

View File

@ -2,7 +2,7 @@ import { CommandLog } from 'lang/std/engineConnection'
import { engineCommandManager } from 'lib/singletons'
import { useState, useEffect } from 'react'
export function useEngineCommands(): [CommandLog[], () => void] {
function useEngineCommands(): [CommandLog[], () => void] {
const [engineCommands, setEngineCommands] = useState<CommandLog[]>(
engineCommandManager.commandLogs
)

View File

@ -179,7 +179,10 @@ const FileTreeItem = ({
codeManager.writeToFile()
// Prevent seeing the model built one piece at a time when changing files
kclManager.executeCode(true)
kclManager.isFirstRender = true
kclManager.executeCode(true).then(() => {
kclManager.isFirstRender = false
})
} else {
// Let the lsp servers know we closed a file.
onFileClose(currentFile?.path || null, project?.path || null)

View File

@ -11,8 +11,6 @@ import {
import { engineCommandManager } from '../lib/singletons'
import { Spinner } from './Spinner'
const Loading = ({ children }: React.PropsWithChildren) => {
const [error, setError] = useState<ConnectionError>(ConnectionError.Unset)
@ -67,7 +65,17 @@ const Loading = ({ children }: React.PropsWithChildren) => {
className="body-bg flex flex-col items-center justify-center h-screen"
data-testid="loading"
>
<Spinner />
<svg viewBox="0 0 10 10" className="w-8 h-8">
<circle
cx="5"
cy="5"
r="4"
stroke="var(--primary)"
fill="none"
strokeDasharray="4, 4"
className="animate-spin origin-center"
/>
</svg>
<p className="text-base mt-4 text-primary">{children || 'Loading'}</p>
<p
className={

View File

@ -11,7 +11,6 @@ import toast from 'react-hot-toast'
import { CoreDumpManager } from 'lib/coredump'
import openWindow, { openExternalBrowserIfDesktop } from 'lib/openWindow'
import { NetworkMachineIndicator } from './NetworkMachineIndicator'
import { ModelStateIndicator } from './ModelStateIndicator'
export function LowerRightControls({
children,
@ -66,7 +65,6 @@ export function LowerRightControls({
<section className="fixed bottom-2 right-2 flex flex-col items-end gap-3 pointer-events-none">
{children}
<menu className="flex items-center justify-end gap-3 pointer-events-auto">
{!location.pathname.startsWith(PATHS.HOME) && <ModelStateIndicator />}
<a
onClick={openExternalBrowserIfDesktop(
`https://github.com/KittyCAD/modeling-app/releases/tag/v${APP_VERSION}`

View File

@ -1,45 +0,0 @@
import { useEngineCommands } from './EngineCommands'
import { Spinner } from './Spinner'
import { CustomIcon } from './CustomIcon'
export const ModelStateIndicator = () => {
const [commands] = useEngineCommands()
const lastCommandType = commands[commands.length - 1]?.type
let className = 'w-6 h-6 '
let icon = <Spinner className={className} />
let dataTestId = 'model-state-indicator'
if (lastCommandType === 'receive-reliable') {
className +=
'bg-chalkboard-20 dark:bg-chalkboard-80 !group-disabled:bg-chalkboard-30 !dark:group-disabled:bg-chalkboard-80 rounded-sm bg-succeed-10/30 dark:bg-succeed'
icon = (
<CustomIcon
data-testid={dataTestId + '-receive-reliable'}
name="checkmark"
/>
)
} else if (lastCommandType === 'execution-done') {
className +=
'border-6 border border-solid border-chalkboard-60 dark:border-chalkboard-80 bg-chalkboard-20 dark:bg-chalkboard-80 !group-disabled:bg-chalkboard-30 !dark:group-disabled:bg-chalkboard-80 rounded-sm bg-succeed-10/30 dark:bg-succeed'
icon = (
<CustomIcon
data-testid={dataTestId + '-execution-done'}
name="checkmark"
/>
)
} else if (lastCommandType === 'export-done') {
className +=
'border-6 border border-solid border-chalkboard-60 dark:border-chalkboard-80 bg-chalkboard-20 dark:bg-chalkboard-80 !group-disabled:bg-chalkboard-30 !dark:group-disabled:bg-chalkboard-80 rounded-sm bg-succeed-10/30 dark:bg-succeed'
icon = (
<CustomIcon data-testid={dataTestId + '-export-done'} name="checkmark" />
)
}
return (
<div className={className} data-testid="model-state-indicator">
{icon}
</div>
)
}

View File

@ -66,6 +66,7 @@ import {
hasExtrudableGeometry,
isSingleCursorInPipe,
} from 'lang/queryAst'
import { TEST } from 'env'
import { exportFromEngine } from 'lib/exportFromEngine'
import { Models } from '@kittycad/lib/dist/types/src'
import toast from 'react-hot-toast'
@ -160,7 +161,9 @@ export const ModelingMachineProvider = ({
store.videoElement?.pause()
kclManager.isFirstRender = true
kclManager.executeCode().then(() => {
kclManager.isFirstRender = false
if (engineCommandManager.engineConnection?.idleMode) return
store.videoElement?.play().catch((e) => {
@ -360,7 +363,7 @@ export const ModelingMachineProvider = ({
return {}
}),
Make: async (_, event) => {
if (event.type !== 'Make') return
if (event.type !== 'Make' || TEST) return
// Check if we already have an export intent.
if (engineCommandManager.exportIntent) {
toast.error('Already exporting')
@ -404,7 +407,7 @@ export const ModelingMachineProvider = ({
)
},
'Engine export': async (_, event) => {
if (event.type !== 'Export') return
if (event.type !== 'Export' || TEST) return
if (engineCommandManager.exportIntent) {
toast.error('Already exporting')
return

View File

@ -193,7 +193,10 @@ export const SettingsAuthProviderBase = ({
resetSettingsIncludesUnitChange
) {
// Unit changes requires a re-exec of code
kclManager.executeCode(true)
kclManager.isFirstRender = true
kclManager.executeCode(true).then(() => {
kclManager.isFirstRender = false
})
} else {
// For any future logging we'd like to do
// console.log(

View File

@ -1,17 +0,0 @@
import { SVGProps } from 'react'
export const Spinner = (props: SVGProps<SVGSVGElement>) => {
return (
<svg viewBox="0 0 10 10" className={'w-8 h-8'} {...props}>
<circle
cx="5"
cy="5"
r="4"
stroke="var(--primary)"
fill="none"
strokeDasharray="4, 4"
className="animate-spin origin-center"
/>
</svg>
)
}

View File

@ -54,10 +54,12 @@ export const Stream = () => {
* central place, we can move this code there.
*/
async function executeCodeAndPlayStream() {
kclManager.isFirstRender = true
kclManager.executeCode(true).then(() => {
videoRef.current?.play().catch((e) => {
console.warn('Video playing was prevented', e, videoRef.current)
})
kclManager.isFirstRender = false
setStreamState(StreamState.Playing)
})
}
@ -217,7 +219,7 @@ export const Stream = () => {
* Play the vid
*/
useEffect(() => {
if (!kclManager.isExecuting) {
if (!kclManager.isFirstRender) {
setTimeout(() =>
// execute in the next event loop
videoRef.current?.play().catch((e) => {
@ -225,7 +227,7 @@ export const Stream = () => {
})
)
}
}, [kclManager.isExecuting])
}, [kclManager.isFirstRender])
useEffect(() => {
if (
@ -380,15 +382,15 @@ export const Stream = () => {
</div>
</div>
)}
{(!isNetworkOkay || isLoading) && (
{(!isNetworkOkay || isLoading || kclManager.isFirstRender) && (
<div className="text-center absolute inset-0">
<Loading>
{!isNetworkOkay && !isLoading ? (
{!isNetworkOkay && !isLoading && !kclManager.isFirstRender ? (
<span data-testid="loading-stream">Stream disconnected...</span>
) : !isLoading && kclManager.isFirstRender ? (
<span data-testid="loading-stream">Building scene...</span>
) : (
!isLoading && (
<span data-testid="loading-stream">Loading stream...</span>
)
<span data-testid="loading-stream">Loading stream...</span>
)}
</Loading>
</div>

View File

@ -12,7 +12,6 @@ interface TooltipProps extends React.PropsWithChildren {
position?: TooltipPosition
wrapperClassName?: string
contentClassName?: string
wrapperStyle?: React.CSSProperties
delay?: number
hoverOnly?: boolean
inert?: boolean
@ -23,7 +22,6 @@ export default function Tooltip({
position = 'top',
wrapperClassName: className,
contentClassName,
wrapperStyle = {},
delay = 200,
hoverOnly = false,
inert = true,
@ -38,10 +36,7 @@ export default function Tooltip({
} ${styles.tooltipWrapper} ${hoverOnly ? '' : styles.withFocus} ${
styles[position]
} ${className}`}
style={Object.assign(
{ '--_delay': delay + 'ms' } as React.CSSProperties,
wrapperStyle
)}
style={{ '--_delay': delay + 'ms' } as React.CSSProperties}
>
<div className={`rounded ${styles.tooltip} ${contentClassName || ''}`}>
{children}

View File

@ -8,7 +8,7 @@ import { moveValueIntoNewVariable } from 'lang/modifyAst'
import { isNodeSafeToReplace } from 'lang/queryAst'
import { useEffect, useState } from 'react'
import { useModelingContext } from './useModelingContext'
import { PathToNode, SourceRange } from 'lang/wasm'
import { PathToNode, SourceRange, parse, recast } from 'lang/wasm'
import { useKclContext } from 'lang/KclProvider'
export const getVarNameModal = createSetVarNameModal(SetVarNameModal)
@ -23,7 +23,8 @@ export function useConvertToVariable(range?: SourceRange) {
}, [enable])
useEffect(() => {
const parsed = ast
const parsed = parse(recast(ast))
if (trap(parsed)) return
const meta = isNodeSafeToReplace(
parsed,

View File

@ -50,14 +50,6 @@ body.dark {
@apply text-chalkboard-10;
}
@media (prefers-color-scheme: dark) {
body,
.body-bg,
.dark .body-bg {
@apply bg-chalkboard-100;
}
}
select {
@apply bg-chalkboard-20;
}
@ -295,11 +287,32 @@ code {
}
@layer utilities {
/*
This is where your own custom Tailwind utility classes can go,
which lets you use them with @apply in your CSS, and get
autocomplete in classNames in your JSX.
*/
/* Modified from the very helpful https://www.transition.style/#in:circle:hesitate */
@keyframes circle-in-hesitate {
0% {
clip-path: circle(
var(--circle-size-start, 0%) at var(--circle-x, 50%)
var(--circle-y, 50%)
);
}
40% {
clip-path: circle(
var(--circle-size-mid, 40%) at var(--circle-x, 50%) var(--circle-y, 50%)
);
}
100% {
clip-path: circle(
var(--circle-size-end, 125%) at var(--circle-x, 50%)
var(--circle-y, 50%)
);
}
}
.in-circle-hesitate {
animation: var(--circle-duration, 2.5s)
var(--circle-timing, cubic-bezier(0.25, 1, 0.3, 1)) circle-in-hesitate
both;
}
}
#code-mirror-override .cm-scroller,

View File

@ -60,6 +60,8 @@ export class KclManager {
private _wasmInitFailedCallback: (arg: boolean) => void = () => {}
private _executeCallback: () => void = () => {}
isFirstRender = true
get ast() {
return this._ast
}

View File

@ -3,8 +3,6 @@ import { Models } from '@kittycad/lib'
import { getNodePathFromSourceRange } from 'lang/queryAst'
import { err } from 'lib/trap'
export type ArtifactId = string
interface CommonCommandProperties {
range: SourceRange
pathToNode: PathToNode
@ -12,7 +10,7 @@ interface CommonCommandProperties {
export interface PlaneArtifact {
type: 'plane'
pathIds: Array<ArtifactId>
pathIds: Array<string>
codeRef: CommonCommandProperties
}
export interface PlaneArtifactRich {
@ -23,16 +21,16 @@ export interface PlaneArtifactRich {
export interface PathArtifact {
type: 'path'
planeId: ArtifactId
segIds: Array<ArtifactId>
extrusionId: ArtifactId
solid2dId?: ArtifactId
planeId: string
segIds: Array<string>
extrusionId: string
solid2dId?: string
codeRef: CommonCommandProperties
}
interface solid2D {
type: 'solid2D'
pathId: ArtifactId
pathId: string
}
export interface PathArtifactRich {
type: 'path'
@ -44,10 +42,10 @@ export interface PathArtifactRich {
interface SegmentArtifact {
type: 'segment'
pathId: ArtifactId
surfaceId: ArtifactId
edgeIds: Array<ArtifactId>
edgeCutId?: ArtifactId
pathId: string
surfaceId: string
edgeIds: Array<string>
edgeCutId?: string
codeRef: CommonCommandProperties
}
interface SegmentArtifactRich {
@ -61,9 +59,9 @@ interface SegmentArtifactRich {
interface ExtrusionArtifact {
type: 'extrusion'
pathId: ArtifactId
surfaceIds: Array<ArtifactId>
edgeIds: Array<ArtifactId>
pathId: string
surfaceIds: Array<string>
edgeIds: Array<string>
codeRef: CommonCommandProperties
}
interface ExtrusionArtifactRich {
@ -76,23 +74,23 @@ interface ExtrusionArtifactRich {
interface WallArtifact {
type: 'wall'
segId: ArtifactId
edgeCutEdgeIds: Array<ArtifactId>
extrusionId: ArtifactId
pathIds: Array<ArtifactId>
segId: string
edgeCutEdgeIds: Array<string>
extrusionId: string
pathIds: Array<string>
}
interface CapArtifact {
type: 'cap'
subType: 'start' | 'end'
edgeCutEdgeIds: Array<ArtifactId>
extrusionId: ArtifactId
pathIds: Array<ArtifactId>
edgeCutEdgeIds: Array<string>
extrusionId: string
pathIds: Array<string>
}
interface ExtrudeEdge {
type: 'extrudeEdge'
segId: ArtifactId
extrusionId: ArtifactId
segId: string
extrusionId: string
subType: 'opposite' | 'adjacent'
}
@ -100,16 +98,16 @@ interface ExtrudeEdge {
interface EdgeCut {
type: 'edgeCut'
subType: 'fillet' | 'chamfer'
consumedEdgeId: ArtifactId
edgeIds: Array<ArtifactId>
surfaceId: ArtifactId
consumedEdgeId: string
edgeIds: Array<string>
surfaceId: string
codeRef: CommonCommandProperties
}
interface EdgeCutEdge {
type: 'edgeCutEdge'
edgeCutId: ArtifactId
surfaceId: ArtifactId
edgeCutId: string
surfaceId: string
}
export type Artifact =
@ -124,7 +122,7 @@ export type Artifact =
| EdgeCutEdge
| solid2D
export type ArtifactGraph = Map<ArtifactId, Artifact>
export type ArtifactGraph = Map<string, Artifact>
export type EngineCommand = Models['WebSocketRequest_type']
@ -151,7 +149,7 @@ export function createArtifactGraph({
responseMap: ResponseMap
ast: Program
}) {
const myMap = new Map<ArtifactId, Artifact>()
const myMap = new Map<string, Artifact>()
/** see docstring for {@link getArtifactsToUpdate} as to why this is needed */
let currentPlaneId = ''
@ -168,7 +166,7 @@ export function createArtifactGraph({
const artifactsToUpdate = getArtifactsToUpdate({
orderedCommand,
responseMap,
getArtifact: (id: ArtifactId) => myMap.get(id),
getArtifact: (id: string) => myMap.get(id),
currentPlaneId,
ast,
})
@ -226,11 +224,11 @@ export function getArtifactsToUpdate({
orderedCommand: OrderedCommand
responseMap: ResponseMap
/** Passing in a getter because we don't wan this function to update the map directly */
getArtifact: (id: ArtifactId) => Artifact | undefined
currentPlaneId: ArtifactId
getArtifact: (id: string) => Artifact | undefined
currentPlaneId: string
ast: Program
}): Array<{
id: ArtifactId
id: string
artifact: Artifact
}> {
const pathToNode = getNodePathFromSourceRange(ast, range)
@ -516,7 +514,7 @@ export function filterArtifacts<T extends Artifact['type'][]>(
(!predicate ||
predicate(value as Extract<Artifact, { type: T[number] }>))
)
) as Map<ArtifactId, Extract<Artifact, { type: T[number] }>>
) as Map<string, Extract<Artifact, { type: T[number] }>>
}
export function getArtifactsOfTypes<T extends Artifact['type'][]>(
@ -530,7 +528,7 @@ export function getArtifactsOfTypes<T extends Artifact['type'][]>(
predicate?: (value: Extract<Artifact, { type: T[number] }>) => boolean
},
map: ArtifactGraph
): Map<ArtifactId, Extract<Artifact, { type: T[number] }>> {
): Map<string, Extract<Artifact, { type: T[number] }>> {
return new Map(
[...map].filter(
([key, value]) =>
@ -539,7 +537,7 @@ export function getArtifactsOfTypes<T extends Artifact['type'][]>(
(!predicate ||
predicate(value as Extract<Artifact, { type: T[number] }>))
)
) as Map<ArtifactId, Extract<Artifact, { type: T[number] }>>
) as Map<string, Extract<Artifact, { type: T[number] }>>
}
export function getArtifactOfTypes<T extends Artifact['type'][]>(
@ -547,7 +545,7 @@ export function getArtifactOfTypes<T extends Artifact['type'][]>(
key,
types,
}: {
key: ArtifactId
key: string
types: T
},
map: ArtifactGraph
@ -720,7 +718,7 @@ export function getExtrudeEdgeCodeRef(
}
export function getExtrusionFromSuspectedExtrudeSurface(
id: ArtifactId,
id: string,
artifactGraph: ArtifactGraph
): ExtrusionArtifact | Error {
const artifact = getArtifactOfTypes(
@ -735,7 +733,7 @@ export function getExtrusionFromSuspectedExtrudeSurface(
}
export function getExtrusionFromSuspectedPath(
id: ArtifactId,
id: string,
artifactGraph: ArtifactGraph
): ExtrusionArtifact | Error {
const path = getArtifactOfTypes({ key: id, types: ['path'] }, artifactGraph)

View File

@ -1252,10 +1252,6 @@ export type CommandLog =
type: 'execution-done'
data: null
}
| {
type: 'export-done'
data: null
}
export enum EngineCommandManagerEvents {
// engineConnection is available but scene setup may not have run
@ -1922,13 +1918,7 @@ export class EngineCommandManager extends EventTarget {
} else if (cmd.type === 'export') {
const promise = new Promise<null>((resolve, reject) => {
this.pendingExport = {
resolve: (passThrough) => {
this.addCommandLog({
type: 'export-done',
data: null,
})
resolve(passThrough)
},
resolve,
reject: (reason: string) => {
this.exportIntent = null
reject(reason)

View File

@ -95,6 +95,8 @@ export const wasmUrl = () => {
document.location.pathname.split('/').slice(0, -1).join('/') +
'/wasm_lib_bg.wasm'
console.log(`Full URL for WASM: ${fullUrl}`)
return fullUrl
}

View File

@ -8,6 +8,7 @@ import {
parseProjectSettings,
} from 'lang/wasm'
import {
DEFAULT_HOST,
PROJECT_ENTRYPOINT,
PROJECT_FOLDER,
PROJECT_SETTINGS_FILE_NAME,
@ -461,60 +462,29 @@ export const readProjectSettingsFile = async (
*/
export const readAppSettingsFile = async () => {
let settingsPath = await getAppSettingsFilePath()
const initialProjectDirConfig: DeepPartial<
Configuration['settings']['project']
> = { directory: await getInitialDefaultDir() }
// The file exists, read it and parse it.
if (window.electron.exists(settingsPath)) {
const configToml = await window.electron.readFile(settingsPath)
const parsedAppConfig = parseAppSettings(configToml)
if (err(parsedAppConfig)) {
return Promise.reject(parsedAppConfig)
const configObj = parseAppSettings(configToml)
if (err(configObj)) {
return Promise.reject(configObj)
}
const hasProjectDirectorySetting =
parsedAppConfig.settings?.project?.directory ||
parsedAppConfig.settings?.app?.project_directory
if (hasProjectDirectorySetting) {
return parsedAppConfig
} else {
// inject the default project directory setting
const mergedConfig: DeepPartial<Configuration> = {
...parsedAppConfig,
settings: {
...parsedAppConfig.settings,
project: Object.assign(
{},
parsedAppConfig.settings?.project,
initialProjectDirConfig
),
},
}
return mergedConfig
}
return configObj
}
// The file doesn't exist, create a new one.
// This defaultAppConfig is truly an empty object every time.
const defaultAppConfig = defaultAppSettings()
if (err(defaultAppConfig)) {
return Promise.reject(defaultAppConfig)
}
// inject the default project directory setting
const mergedDefaultConfig: DeepPartial<Configuration> = {
...defaultAppConfig,
settings: {
...defaultAppConfig.settings,
project: Object.assign(
{},
defaultAppConfig.settings?.project,
initialProjectDirConfig
),
},
const initialDirConfig: DeepPartial<Configuration> = {
settings: { project: { directory: await getInitialDefaultDir() } },
}
return mergedDefaultConfig
const config = Object.assign(defaultAppConfig, initialDirConfig)
return config
}
export const writeAppSettingsFile = async (tomlStr: string) => {
@ -555,6 +525,28 @@ export const getUser = async (
token: string,
hostname: string
): Promise<Models['User_type']> => {
// Use the host passed in if it's set.
// Otherwise, use the default host.
const host = !hostname ? DEFAULT_HOST : hostname
// Change the baseURL to the one we want.
let baseurl = host
if (!(host.indexOf('http://') === 0) && !(host.indexOf('https://') === 0)) {
baseurl = `https://${host}`
if (host.indexOf('localhost') === 0) {
baseurl = `http://${host}`
}
}
// Use kittycad library to fetch the user info from /user/me
if (baseurl !== DEFAULT_HOST) {
// The TypeScript generated library uses environment variables for this
// because it was intended for NodeJS.
// Needs to stay like this because window.electron.kittycad needs it
// internally.
window.electron.setBaseUrl(baseurl)
}
try {
const user = await window.electron.kittycad('users.get_user_self', {
client: { token },

View File

@ -14,7 +14,7 @@ const save_ = async (file: ModelingAppFile) => {
extensions.push(extension)
}
if (window.electron.process.env.IS_PLAYWRIGHT) {
if (!(window as any).playwrightSkipFilePicker) {
// skip file picker, save to default location
await window.electron.writeFile(
file.name,

View File

@ -81,6 +81,7 @@ export class MachineManager {
}
this._machines = await window.electron.listMachines()
console.log('Machines:', this._machines)
}
private async updateMachineApiIp(): Promise<void> {

View File

@ -5,7 +5,7 @@ import {
kclManager,
sceneEntitiesManager,
} from 'lib/singletons'
import { CallExpression, SourceRange, Expr, parse } from 'lang/wasm'
import { CallExpression, SourceRange, Expr, parse, recast } from 'lang/wasm'
import { ModelingMachineEvent } from 'machines/modelingMachine'
import { uuidv4 } from 'lib/utils'
import { EditorSelection, SelectionRange } from '@codemirror/state'
@ -300,7 +300,8 @@ export function processCodeMirrorRanges({
}
function updateSceneObjectColors(codeBasedSelections: Selection[]) {
const updated = kclManager.ast
const updated = parse(recast(kclManager.ast))
if (err(updated)) return
Object.values(sceneEntitiesManager.activeSegments).forEach((segmentGroup) => {
if (

View File

@ -14,7 +14,6 @@ import { Configuration } from 'wasm-lib/kcl/bindings/Configuration'
import { mouseControlsToCameraSystem } from 'lib/cameraControls'
import { appThemeToTheme } from 'lib/theme'
import {
getInitialDefaultDir,
readAppSettingsFile,
readProjectSettingsFile,
writeAppSettingsFile,
@ -177,11 +176,6 @@ export async function loadAndValidateSettings(
if (err(appSettingsPayload)) return Promise.reject(appSettingsPayload)
const settings = createSettings()
// Because getting the default directory is async, we need to set it after
if (onDesktop) {
settings.app.projectDirectory.default = await getInitialDefaultDir()
}
setSettingsAtLevel(
settings,
'user',

View File

@ -16,6 +16,7 @@ window.tearDown = engineCommandManager.tearDown
// This needs to be after codeManager is created.
export const kclManager = new KclManager(engineCommandManager)
kclManager.isFirstRender = true
engineCommandManager.kclManager = kclManager
engineCommandManager.getAstCb = () => kclManager.ast

View File

@ -129,16 +129,12 @@ export const toolbarConfig: Record<ToolbarModeName, ToolbarMode> = {
id: 'loft',
onClick: () => console.error('Loft not yet implemented'),
icon: 'loft',
status: 'kcl-only',
status: 'unavailable',
title: 'Loft',
hotkey: 'L',
description:
'Create a 3D body by blending between two or more sketches.',
links: [
{
label: 'KCL docs',
url: 'https://zoo.dev/docs/kcl/loft',
},
{
label: 'GitHub discussion',
url: 'https://github.com/KittyCAD/modeling-app/discussions/613',

View File

@ -2,14 +2,7 @@
// template that ElectronJS provides.
import dotenv from 'dotenv'
import {
app,
BrowserWindow,
ipcMain,
dialog,
shell,
nativeTheme,
} from 'electron'
import { app, BrowserWindow, ipcMain, dialog, shell } from 'electron'
import path from 'path'
import { Issuer } from 'openid-client'
import { Bonjour, Service } from 'bonjour-service'
@ -67,7 +60,7 @@ if (process.defaultApp) {
// Must be done before ready event.
registerStartupListeners()
const createWindow = (filePath?: string): BrowserWindow => {
const createWindow = (): BrowserWindow => {
const newWindow = new BrowserWindow({
autoHideMenuBar: true,
show: false,
@ -82,33 +75,15 @@ const createWindow = (filePath?: string): BrowserWindow => {
icon: path.resolve(process.cwd(), 'assets', 'icon.png'),
frame: os.platform() !== 'darwin',
titleBarStyle: 'hiddenInset',
backgroundColor: nativeTheme.shouldUseDarkColors ? '#1C1C1C' : '#FCFCFC',
})
// and load the index.html of the app.
if (MAIN_WINDOW_VITE_DEV_SERVER_URL) {
newWindow.loadURL(MAIN_WINDOW_VITE_DEV_SERVER_URL)
} else {
getProjectPathAtStartup(filePath).then((projectPath) => {
const startIndex = path.join(
__dirname,
`../renderer/${MAIN_WINDOW_VITE_NAME}/index.html`
)
if (projectPath === null) {
newWindow.loadFile(startIndex)
return
}
console.log('Loading file', projectPath)
const fullUrl = `/file/${encodeURIComponent(projectPath)}`
console.log('Full URL', fullUrl)
newWindow.loadFile(startIndex, {
hash: fullUrl,
})
})
newWindow.loadFile(
path.join(__dirname, `../renderer/${MAIN_WINDOW_VITE_NAME}/index.html`)
)
}
// Open the DevTools.
@ -119,11 +94,13 @@ const createWindow = (filePath?: string): BrowserWindow => {
return newWindow
}
// Quit when all windows are closed, even on macOS. There, it's common
// Quit when all windows are closed, except on macOS. There, it's common
// for applications and their menu bar to stay active until the user quits
// explicitly with Cmd + Q, but it is a really weird behavior with our app.
// explicitly with Cmd + Q.
app.on('window-all-closed', () => {
app.quit()
if (process.platform !== 'darwin') {
app.quit()
}
})
// This method will be called when Electron has finished
@ -258,9 +235,7 @@ app.on('ready', async () => {
})
})
const getProjectPathAtStartup = async (
filePath?: string
): Promise<string | null> => {
ipcMain.handle('loadProjectAtStartup', async () => {
// If we are in development mode, we don't want to load a project at
// startup.
// Since the args passed are always '.'
@ -268,54 +243,52 @@ const getProjectPathAtStartup = async (
return null
}
let projectPath: string | null = filePath || null
if (projectPath === null) {
// macOS: open-file events that were received before the app is ready
const macOpenFiles: string[] = (global as any).macOpenFiles
if (macOpenFiles && macOpenFiles && macOpenFiles.length > 0) {
projectPath = macOpenFiles[0] // We only do one project at a time
}
// Reset this so we don't accidentally use it again.
const macOpenFilesEmpty: string[] = []
// @ts-ignore
global['macOpenFiles'] = macOpenFilesEmpty
let projectPath: string | null = null
// macOS: open-file events that were received before the app is ready
const macOpenFiles: string[] = (global as any).macOpenFiles
if (macOpenFiles && macOpenFiles && macOpenFiles.length > 0) {
projectPath = macOpenFiles[0] // We only do one project at a time
}
// Reset this so we don't accidentally use it again.
const macOpenFilesEmpty: string[] = []
// @ts-ignore
global['macOpenFiles'] = macOpenFilesEmpty
// macOS: open-url events that were received before the app is ready
const getOpenUrls: string[] = (global as any).getOpenUrls
if (getOpenUrls && getOpenUrls.length > 0) {
projectPath = getOpenUrls[0] // We only do one project at a
}
// Reset this so we don't accidentally use it again.
// @ts-ignore
global['getOpenUrls'] = []
// macOS: open-url events that were received before the app is ready
const getOpenUrls: string[] = (global as any).getOpenUrls
if (getOpenUrls && getOpenUrls.length > 0) {
projectPath = getOpenUrls[0] // We only do one project at a
}
// Reset this so we don't accidentally use it again.
// @ts-ignore
global['getOpenUrls'] = []
// Check if we have a project path in the command line arguments
// If we do, we will load the project at that path
if (args._.length > 1) {
if (args._[1].length > 0) {
projectPath = args._[1]
// Reset all this value so we don't accidentally use it again.
args._[1] = ''
}
// Check if we have a project path in the command line arguments
// If we do, we will load the project at that path
if (args._.length > 1) {
if (args._[1].length > 0) {
projectPath = args._[1]
// Reset all this value so we don't accidentally use it again.
args._[1] = ''
}
}
if (projectPath) {
// We have a project path, load the project information.
console.log(`Loading project at startup: ${projectPath}`)
const currentFile = await getCurrentProjectFile(projectPath)
if (currentFile instanceof Error) {
console.error(currentFile)
return null
try {
const currentFile = await getCurrentProjectFile(projectPath)
console.log(`Project loaded: ${currentFile}`)
return currentFile
} catch (e) {
console.error(e)
}
console.log(`Project loaded: ${currentFile}`)
return currentFile
return null
}
return null
}
})
function parseCLIArgs(): minimist.ParsedArgs {
return minimist(process.argv, {})
@ -332,11 +305,10 @@ function registerStartupListeners() {
app.on('open-file', function (event, path) {
event.preventDefault()
macOpenFiles.push(path)
// If we have a mainWindow, lets open another window.
if (mainWindow) {
createWindow(path)
} else {
macOpenFiles.push(path)
createWindow()
}
})
@ -352,11 +324,10 @@ function registerStartupListeners() {
) {
event.preventDefault()
openUrls.push(url)
// If we have a mainWindow, lets open another window.
if (mainWindow) {
createWindow(url)
} else {
openUrls.push(url)
createWindow()
}
}

View File

@ -60,6 +60,9 @@ const listMachines = async (): Promise<MachinesListing> => {
const getMachineApiIp = async (): Promise<String | null> =>
ipcRenderer.invoke('find_machine_api')
const loadProjectAtStartup = async (): Promise<string | null> =>
ipcRenderer.invoke('loadProjectAtStartup')
contextBridge.exposeInMainWorld('electron', {
login,
// Passing fs directly is not recommended since it gives a lot of power
@ -93,6 +96,10 @@ contextBridge.exposeInMainWorld('electron', {
isWindows,
isLinux,
},
loadProjectAtStartup,
// IMPORTANT NOTE: kittycad.ts reads process.env.BASE_URL. But there is
// no way to set it across the bridge boundary. We need to make it a command.
setBaseUrl: (value: string) => (process.env.BASE_URL = value),
process: {
// Setter/getter has to be created because
// these are read-only over the boundary.

View File

@ -107,7 +107,10 @@ function OnboardingWarningWeb(props: OnboardingResetWarningProps) {
codeManager.updateCodeStateEditor(bracket)
await codeManager.writeToFile()
await kclManager.executeCode(true)
kclManager.isFirstRender = true
await kclManager.executeCode(true).then(() => {
kclManager.isFirstRender = false
})
props.setShouldShowWarning(false)
}}
nextText="Overwrite code and continue"

View File

@ -13,7 +13,10 @@ export default function Sketching() {
async function clearEditor() {
// We do want to update both the state and editor here.
codeManager.updateCodeStateEditor('')
await kclManager.executeCode(true)
kclManager.isFirstRender = true
await kclManager.executeCode(true).then(() => {
kclManager.isFirstRender = false
})
}
clearEditor()

View File

@ -82,7 +82,10 @@ export function useDemoCode() {
if (!editorManager.editorView || codeManager.code === bracket) return
setTimeout(async () => {
codeManager.updateCodeStateEditor(bracket)
await kclManager.executeCode(true)
kclManager.isFirstRender = true
await kclManager.executeCode(true).then(() => {
kclManager.isFirstRender = false
})
await codeManager.writeToFile()
})
}, [editorManager.editorView])

View File

@ -58,23 +58,19 @@ const SignIn = () => {
}
return (
<main
className="bg-primary h-screen grid place-items-stretch m-0 p-2"
style={
isDesktop()
? ({
'-webkit-app-region': 'drag',
} as CSSProperties)
: {}
}
>
<main className="bg-primary h-screen grid place-items-stretch m-0 p-2">
<div
style={
isDesktop()
? ({ '-webkit-app-region': 'no-drag' } as CSSProperties)
: {}
{
height: 'calc(100vh - 16px)',
'--circle-x': '14%',
'--circle-y': '12%',
'--circle-size-mid': '15%',
'--circle-size-end': '200%',
'--circle-timing': 'cubic-bezier(0.25, 1, 0.4, 0.9)',
} as CSSProperties
}
className="body-bg py-5 px-12 rounded-lg grid place-items-center overflow-y-auto"
className="in-circle-hesitate body-bg py-5 px-12 rounded-lg grid place-items-center overflow-y-auto"
>
<div className="max-w-7xl grid gap-5 grid-cols-3 xl:grid-cols-4 xl:grid-rows-5">
<div className="col-span-2 xl:col-span-3 xl:row-span-3 max-w-3xl mr-8 mb-8">
@ -198,7 +194,7 @@ const SignIn = () => {
<div className="flex gap-4 flex-wrap items-center">
<ActionButton
Element="externalLink"
to="https://zoo.dev/docs/kcl-samples/a-parametric-bearing-pillow-block"
to="https://zoo.dev/docs/kcl-samples/ball-bearing"
iconStart={{ icon: 'settings' }}
className="border-chalkboard-30 dark:border-chalkboard-80"
>

View File

@ -370,9 +370,9 @@ dependencies = [
[[package]]
name = "clap"
version = "4.5.17"
version = "4.5.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e5a21b8495e732f1b3c364c9949b201ca7bae518c502c80256c96ad79eaf6ac"
checksum = "ed6719fffa43d0d87e5fd8caeab59be1554fb028cd30edc88fc4369b17971019"
dependencies = [
"clap_builder",
"clap_derive",
@ -380,9 +380,9 @@ dependencies = [
[[package]]
name = "clap_builder"
version = "4.5.17"
version = "4.5.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8cf2dd12af7a047ad9d6da2b6b249759a22a7abc0f474c1dae1777afa4b21a73"
checksum = "216aec2b177652e3846684cbfe25c9964d18ec45234f0f5da5157b207ed1aab6"
dependencies = [
"anstyle",
"clap_lex",
@ -620,9 +620,9 @@ dependencies = [
[[package]]
name = "dashmap"
version = "6.1.0"
version = "6.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf"
checksum = "804c8821570c3f8b70230c2ba75ffa5c0f9a4189b9a432b6656c536712acae28"
dependencies = [
"cfg-if",
"crossbeam-utils",
@ -672,7 +672,7 @@ dependencies = [
[[package]]
name = "derive-docs"
version = "0.1.26"
version = "0.1.25"
dependencies = [
"Inflector",
"anyhow",
@ -1345,7 +1345,7 @@ dependencies = [
[[package]]
name = "kcl-lib"
version = "0.2.14"
version = "0.2.11"
dependencies = [
"anyhow",
"approx",
@ -1357,7 +1357,7 @@ dependencies = [
"clap",
"convert_case",
"criterion",
"dashmap 6.1.0",
"dashmap 6.0.1",
"databake",
"derive-docs",
"expectorate",
@ -1399,7 +1399,7 @@ dependencies = [
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"winnow",
"winnow 0.5.40",
"zip",
]
@ -1417,7 +1417,7 @@ dependencies = [
[[package]]
name = "kcl-test-server"
version = "0.1.10"
version = "0.1.9"
dependencies = [
"anyhow",
"hyper",
@ -2581,9 +2581,9 @@ dependencies = [
[[package]]
name = "serde_json"
version = "1.0.128"
version = "1.0.127"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ff5456707a1de34e7e37f2a6fd3d3f808c318259cbd01ab6377795054b483d8"
checksum = "8043c06d9f82bd7271361ed64f415fe5e12a77fdb52e573e7f06a516dea329ad"
dependencies = [
"indexmap 2.2.5",
"itoa",
@ -3117,7 +3117,7 @@ dependencies = [
"serde",
"serde_spanned",
"toml_datetime",
"winnow",
"winnow 0.6.18",
]
[[package]]
@ -3800,6 +3800,15 @@ version = "0.52.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32b752e52a2da0ddfbdbcc6fceadfeede4c939ed16d13e648833a61dfb611ed8"
[[package]]
name = "winnow"
version = "0.5.40"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876"
dependencies = [
"memchr",
]
[[package]]
name = "winnow"
version = "0.6.18"

View File

@ -15,7 +15,7 @@ data-encoding = "2.6.0"
gloo-utils = "0.2.0"
kcl-lib = { path = "kcl" }
kittycad.workspace = true
serde_json = "1.0.128"
serde_json = "1.0.127"
tokio = { version = "1.40.0", features = ["sync"] }
toml = "0.8.19"
uuid = { version = "1.10.0", features = ["v4", "js", "serde"] }

View File

@ -1,7 +1,7 @@
[package]
name = "derive-docs"
description = "A tool for generating documentation from Rust derive macros"
version = "0.1.26"
version = "0.1.25"
edition = "2021"
license = "MIT"
repository = "https://github.com/KittyCAD/modeling-app"

View File

@ -2,6 +2,3 @@
new-test name:
echo "kcl_test!(\"{{name}}\", {{name}});" >> tests/executor/visuals.rs
TWENTY_TWENTY=overwrite cargo nextest run --test executor -E 'test(=visuals::{{name}})'
lint:
cargo clippy --all --tests --benches -- -D warnings

View File

@ -1,7 +1,7 @@
[package]
name = "kcl-test-server"
description = "A test server for KCL"
version = "0.1.10"
version = "0.1.9"
edition = "2021"
license = "MIT"
@ -11,5 +11,5 @@ hyper = { version = "0.14.29", features = ["server"] }
kcl-lib = { version = "0.2", path = "../kcl" }
pico-args = "0.5.0"
serde = { version = "1.0.209", features = ["derive"] }
serde_json = "1.0.128"
serde_json = "1.0.127"
tokio = { version = "1.40.0", features = ["macros", "rt-multi-thread"] }

View File

@ -1,7 +1,7 @@
[package]
name = "kcl-lib"
description = "KittyCAD Language implementation and tools"
version = "0.2.14"
version = "0.2.11"
edition = "2021"
license = "MIT"
repository = "https://github.com/KittyCAD/modeling-app"
@ -16,11 +16,11 @@ async-recursion = "1.1.1"
async-trait = "0.1.82"
base64 = "0.22.1"
chrono = "0.4.38"
clap = { version = "4.5.17", default-features = false, optional = true, features = ["std", "derive"] }
clap = { version = "4.5.16", default-features = false, optional = true, features = ["std", "derive"] }
convert_case = "0.6.0"
dashmap = "6.1.0"
dashmap = "6.0.1"
databake = { version = "0.1.8", features = ["derive"] }
derive-docs = { version = "0.1.26", path = "../derive-docs" }
derive-docs = { version = "0.1.24", path = "../derive-docs" }
form_urlencoded = "1.2.1"
futures = { version = "0.3.30" }
git_rev = "0.1.0"
@ -37,7 +37,7 @@ reqwest = { version = "0.11.26", default-features = false, features = ["stream",
ropey = "1.6.1"
schemars = { version = "0.8.17", features = ["impl_json_schema", "url", "uuid1"] }
serde = { version = "1.0.209", features = ["derive"] }
serde_json = "1.0.128"
serde_json = "1.0.127"
sha2 = "0.10.8"
tabled = { version = "0.15.0", optional = true }
thiserror = "1.0.63"
@ -47,7 +47,7 @@ url = { version = "2.5.2", features = ["serde"] }
urlencoding = "2.1.3"
uuid = { version = "1.10.0", features = ["v4", "js", "serde"] }
validator = { version = "0.18.1", features = ["derive"] }
winnow = "0.6.18"
winnow = "0.5.40"
zip = { version = "2.0.0", default-features = false }
[target.'cfg(target_arch = "wasm32")'.dependencies]

View File

@ -1,5 +1,5 @@
use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion};
use kcl_lib::{settings::types::UnitLength::Mm, test_server};
use kcl_lib::test_server;
use tokio::runtime::Runtime;
pub fn bench_execute(c: &mut Criterion) {
@ -13,42 +13,26 @@ pub fn bench_execute(c: &mut Criterion) {
// Configure Criterion.rs to detect smaller differences and increase sample size to improve
// precision and counteract the resulting noise.
group.sample_size(10);
group.bench_with_input(BenchmarkId::new("execute", name), &code, |b, &s| {
group.bench_with_input(BenchmarkId::new("execute_", name), &code, |b, &s| {
let rt = Runtime::new().unwrap();
// Spawn a future onto the runtime
b.iter(|| {
rt.block_on(test_server::execute_and_snapshot(s, Mm)).unwrap();
rt.block_on(test_server::execute_and_snapshot(
s,
kcl_lib::settings::types::UnitLength::Mm,
))
.unwrap();
});
});
group.finish();
}
}
pub fn bench_lego(c: &mut Criterion) {
let mut group = c.benchmark_group("executor_lego_pattern");
// Configure Criterion.rs to detect smaller differences and increase sample size to improve
// precision and counteract the resulting noise.
group.sample_size(10);
// Create lego bricks with N x 10 bumps, where N is each element of `sizes`.
let sizes = vec![1, 2, 4];
for size in sizes {
group.bench_with_input(BenchmarkId::from_parameter(size), &size, |b, &size| {
let rt = Runtime::new().unwrap();
let code = LEGO_PROGRAM.replace("{{N}}", &size.to_string());
// Spawn a future onto the runtime
b.iter(|| {
rt.block_on(test_server::execute_and_snapshot(&code, Mm)).unwrap();
});
});
}
group.finish();
}
criterion_group!(benches, bench_lego, bench_execute);
criterion_group!(benches, bench_execute);
criterion_main!(benches);
const KITT_PROGRAM: &str = include_str!("../../tests/executor/inputs/kittycad_svg.kcl");
const CUBE_PROGRAM: &str = include_str!("../../tests/executor/inputs/cube.kcl");
const SERVER_RACK_HEAVY_PROGRAM: &str = include_str!("../../tests/executor/inputs/server-rack-heavy.kcl");
const SERVER_RACK_LITE_PROGRAM: &str = include_str!("../../tests/executor/inputs/server-rack-lite.kcl");
const LEGO_PROGRAM: &str = include_str!("../../tests/executor/inputs/slow_lego.kcl.tmpl");

View File

@ -995,20 +995,20 @@ impl SketchSurface {
}
pub(crate) fn x_axis(&self) -> Point3d {
match self {
SketchSurface::Plane(plane) => plane.x_axis,
SketchSurface::Face(face) => face.x_axis,
SketchSurface::Plane(plane) => plane.x_axis.clone(),
SketchSurface::Face(face) => face.x_axis.clone(),
}
}
pub(crate) fn y_axis(&self) -> Point3d {
match self {
SketchSurface::Plane(plane) => plane.y_axis,
SketchSurface::Face(face) => face.y_axis,
SketchSurface::Plane(plane) => plane.y_axis.clone(),
SketchSurface::Face(face) => face.y_axis.clone(),
}
}
pub(crate) fn z_axis(&self) -> Point3d {
match self {
SketchSurface::Plane(plane) => plane.z_axis,
SketchSurface::Face(face) => face.z_axis,
SketchSurface::Plane(plane) => plane.z_axis.clone(),
SketchSurface::Face(face) => face.z_axis.clone(),
}
}
}
@ -1304,7 +1304,7 @@ impl Point2d {
}
}
#[derive(Debug, Deserialize, Serialize, PartialEq, Clone, Copy, ts_rs::TS, JsonSchema, Default)]
#[derive(Debug, Deserialize, Serialize, PartialEq, Clone, ts_rs::TS, JsonSchema, Default)]
#[ts(export)]
pub struct Point3d {
pub x: f64,
@ -1313,7 +1313,6 @@ pub struct Point3d {
}
impl Point3d {
pub const ZERO: Self = Self { x: 0.0, y: 0.0, z: 0.0 };
pub fn new(x: f64, y: f64, z: f64) -> Self {
Self { x, y, z }
}

View File

@ -927,7 +927,7 @@ pub fn function_body(i: TokenSlice) -> PResult<Program> {
match body_items_within_function.parse_next(i) {
Err(ErrMode::Backtrack(_)) => {
i.reset(&start);
i.reset(start);
break;
}
Err(e) => return Err(e),
@ -937,7 +937,7 @@ pub fn function_body(i: TokenSlice) -> PResult<Program> {
}
}
(Err(ErrMode::Backtrack(_)), _) => {
i.reset(&start);
i.reset(start);
break;
}
(Err(e), _) => return Err(e),
@ -1276,7 +1276,7 @@ fn unary_expression(i: TokenSlice) -> PResult<UnaryExpression> {
/// Consume tokens that make up a binary expression, but don't actually return them.
/// Why not?
/// Because this is designed to be used with .take() within the `binary_expression` parser.
/// Because this is designed to be used with .recognize() within the `binary_expression` parser.
fn binary_expression_tokens(i: TokenSlice) -> PResult<Vec<BinaryExpressionToken>> {
let first = operand.parse_next(i).map(BinaryExpressionToken::from)?;
let remaining: Vec<_> = repeat(
@ -1308,7 +1308,7 @@ fn binary_expression(i: TokenSlice) -> PResult<BinaryExpression> {
}
fn binary_expr_in_parens(i: TokenSlice) -> PResult<BinaryExpression> {
let span_with_brackets = bracketed_section.take().parse_next(i)?;
let span_with_brackets = bracketed_section.recognize().parse_next(i)?;
let n = span_with_brackets.len();
let mut span_no_brackets = &span_with_brackets[1..n - 1];
let expr = binary_expression.parse_next(&mut span_no_brackets)?;

View File

@ -1,6 +1,5 @@
use winnow::{
error::{ErrorKind, ParseError, StrContext},
stream::Stream,
Located,
};
@ -103,17 +102,14 @@ impl<C> std::default::Default for ContextError<C> {
}
}
impl<I, C> winnow::error::ParserError<I> for ContextError<C>
where
I: Stream,
{
impl<I, C> winnow::error::ParserError<I> for ContextError<C> {
#[inline]
fn from_error_kind(_input: &I, _kind: ErrorKind) -> Self {
Self::default()
}
#[inline]
fn append(self, _input: &I, _input_checkpoint: &<I as Stream>::Checkpoint, _kind: ErrorKind) -> Self {
fn append(self, _input: &I, _kind: ErrorKind) -> Self {
self
}
@ -123,12 +119,9 @@ where
}
}
impl<C, I> winnow::error::AddContext<I, C> for ContextError<C>
where
I: Stream,
{
impl<C, I> winnow::error::AddContext<I, C> for ContextError<C> {
#[inline]
fn add_context(mut self, _input: &I, _input_checkpoint: &<I as Stream>::Checkpoint, ctx: C) -> Self {
fn add_context(mut self, _input: &I, ctx: C) -> Self {
self.context.push(ctx);
self
}

View File

@ -294,13 +294,6 @@ impl Args {
FromArgs::from_args(self, 0)
}
pub(crate) fn get_sketch_groups_and_data<'a, T>(&'a self) -> Result<(Vec<SketchGroup>, Option<T>), KclError>
where
T: FromArgs<'a> + serde::de::DeserializeOwned + FromKclValue<'a> + Sized,
{
FromArgs::from_args(self, 0)
}
pub(crate) fn get_data_and_optional_tag<'a, T>(&'a self) -> Result<(T, Option<FaceTag>), KclError>
where
T: serde::de::DeserializeOwned + FromKclValue<'a> + Sized,
@ -367,13 +360,6 @@ impl Args {
FromArgs::from_args(self, 0)
}
pub(crate) fn get_data_and_float<'a, T>(&'a self) -> Result<(T, f64), KclError>
where
T: serde::de::DeserializeOwned + FromKclValue<'a> + Sized,
{
FromArgs::from_args(self, 0)
}
pub(crate) fn get_number_sketch_group_set(&self) -> Result<(f64, SketchGroupSet), KclError> {
FromArgs::from_args(self, 0)
}
@ -634,8 +620,6 @@ impl_from_arg_via_json!(super::revolve::RevolveData);
impl_from_arg_via_json!(super::sketch::SketchData);
impl_from_arg_via_json!(crate::std::import::ImportFormat);
impl_from_arg_via_json!(crate::std::polar::PolarCoordsData);
impl_from_arg_via_json!(crate::std::loft::LoftData);
impl_from_arg_via_json!(crate::std::planes::StandardPlane);
impl_from_arg_via_json!(SketchGroup);
impl_from_arg_via_json!(FaceTag);
impl_from_arg_via_json!(String);
@ -706,13 +690,3 @@ impl<'a> FromKclValue<'a> for SketchSurface {
}
}
}
impl<'a> FromKclValue<'a> for Vec<SketchGroup> {
fn from_mem_item(arg: &'a KclValue) -> Option<Self> {
let KclValue::UserVal(uv) = arg else {
return None;
};
uv.get::<Vec<SketchGroup>>().map(|x| x.0)
}
}

View File

@ -1,10 +1,7 @@
//! Functions related to extruding.
use std::collections::HashMap;
use anyhow::Result;
use derive_docs::stdlib;
use kittycad::types::{ExtrusionFaceCapType, ExtrusionFaceInfo};
use schemars::JsonSchema;
use uuid::Uuid;
@ -93,7 +90,7 @@ async fn inner_extrude(length: f64, sketch_group_set: SketchGroupSet, args: Args
adjust_camera: false,
planar_normal: if let SketchSurface::Plane(plane) = &sketch_group.on {
// We pass in the normal for the plane here.
Some(plane.z_axis.into())
Some(plane.z_axis.clone().into())
} else {
None
},
@ -101,7 +98,7 @@ async fn inner_extrude(length: f64, sketch_group_set: SketchGroupSet, args: Args
)
.await?;
args.batch_modeling_cmd(
args.send_modeling_cmd(
id,
kittycad::types::ModelingCmd::Extrude {
target: sketch_group.id,
@ -114,7 +111,7 @@ async fn inner_extrude(length: f64, sketch_group_set: SketchGroupSet, args: Args
// Disable the sketch mode.
args.batch_modeling_cmd(uuid::Uuid::new_v4(), kittycad::types::ModelingCmd::SketchModeDisable {})
.await?;
extrude_groups.push(do_post_extrude(sketch_group.clone(), length, args.clone()).await?);
extrude_groups.push(do_post_extrude(sketch_group.clone(), length, id, args.clone()).await?);
}
Ok(extrude_groups.into())
@ -123,6 +120,7 @@ async fn inner_extrude(length: f64, sketch_group_set: SketchGroupSet, args: Args
pub(crate) async fn do_post_extrude(
sketch_group: SketchGroup,
length: f64,
id: Uuid,
args: Args,
) -> Result<Box<ExtrudeGroup>, KclError> {
// Bring the object to the front of the scene.
@ -166,7 +164,7 @@ pub(crate) async fn do_post_extrude(
let solid3d_info = args
.send_modeling_cmd(
uuid::Uuid::new_v4(),
id,
kittycad::types::ModelingCmd::Solid3DGetExtrusionFaceInfo {
edge_id,
object_id: sketch_group.id,
@ -183,95 +181,91 @@ pub(crate) async fn do_post_extrude(
vec![]
};
for (curve_id, face_id) in face_infos
.iter()
.filter(|face_info| face_info.cap == ExtrusionFaceCapType::None)
.filter_map(|face_info| {
for face_info in face_infos.iter() {
if face_info.cap == kittycad::types::ExtrusionFaceCapType::None {
if let (Some(curve_id), Some(face_id)) = (face_info.curve_id, face_info.face_id) {
Some((curve_id, face_id))
} else {
None
}
})
{
// Batch these commands, because the Rust code doesn't actually care about the outcome.
// So, there's no need to await them.
// Instead, the Typescript codebases (which handles WebSocket sends when compiled via Wasm)
// uses this to build the artifact graph, which the UI needs.
args.batch_modeling_cmd(
uuid::Uuid::new_v4(),
kittycad::types::ModelingCmd::Solid3DGetOppositeEdge {
edge_id: curve_id,
object_id: sketch_group.id,
face_id,
},
)
.await?;
args.batch_modeling_cmd(
uuid::Uuid::new_v4(),
kittycad::types::ModelingCmd::Solid3DGetOppositeEdge {
edge_id: curve_id,
object_id: sketch_group.id,
face_id,
},
)
.await?;
args.batch_modeling_cmd(
uuid::Uuid::new_v4(),
kittycad::types::ModelingCmd::Solid3DGetPrevAdjacentEdge {
edge_id: curve_id,
object_id: sketch_group.id,
face_id,
},
)
.await?;
args.batch_modeling_cmd(
uuid::Uuid::new_v4(),
kittycad::types::ModelingCmd::Solid3DGetPrevAdjacentEdge {
edge_id: curve_id,
object_id: sketch_group.id,
face_id,
},
)
.await?;
}
}
}
let Faces {
sides: face_id_map,
start_cap_id,
end_cap_id,
} = analyze_faces(&args, face_infos);
// Iterate over the sketch_group.value array and add face_id to GeoMeta
let new_value = sketch_group
.value
.iter()
.flat_map(|path| {
if let Some(Some(actual_face_id)) = face_id_map.get(&path.get_base().geo_meta.id) {
match path {
Path::TangentialArc { .. } | Path::TangentialArcTo { .. } => {
let extrude_surface = ExtrudeSurface::ExtrudeArc(crate::executor::ExtrudeArc {
face_id: *actual_face_id,
tag: path.get_base().tag.clone(),
geo_meta: GeoMeta {
id: path.get_base().geo_meta.id,
metadata: path.get_base().geo_meta.metadata.clone(),
},
});
Some(extrude_surface)
}
Path::Base { .. } | Path::ToPoint { .. } | Path::Horizontal { .. } | Path::AngledLineTo { .. } => {
let extrude_surface = ExtrudeSurface::ExtrudePlane(crate::executor::ExtrudePlane {
face_id: *actual_face_id,
tag: path.get_base().tag.clone(),
geo_meta: GeoMeta {
id: path.get_base().geo_meta.id,
metadata: path.get_base().geo_meta.metadata.clone(),
},
});
Some(extrude_surface)
}
}
} else if args.ctx.is_mock {
// Only pre-populate the extrude surface if we are in mock mode.
// Create a hashmap for quick id lookup
let mut face_id_map = std::collections::HashMap::new();
// creating fake ids for start and end caps is to make extrudes mock-execute safe
let mut start_cap_id = if args.ctx.is_mock { Some(Uuid::new_v4()) } else { None };
let mut end_cap_id = if args.ctx.is_mock { Some(Uuid::new_v4()) } else { None };
let extrude_surface = ExtrudeSurface::ExtrudePlane(crate::executor::ExtrudePlane {
// pushing this values with a fake face_id to make extrudes mock-execute safe
face_id: Uuid::new_v4(),
tag: path.get_base().tag.clone(),
geo_meta: GeoMeta {
id: path.get_base().geo_meta.id,
metadata: path.get_base().geo_meta.metadata.clone(),
},
});
Some(extrude_surface)
} else {
None
for face_info in face_infos {
match face_info.cap {
kittycad::types::ExtrusionFaceCapType::Bottom => start_cap_id = face_info.face_id,
kittycad::types::ExtrusionFaceCapType::Top => end_cap_id = face_info.face_id,
_ => {
if let Some(curve_id) = face_info.curve_id {
face_id_map.insert(curve_id, face_info.face_id);
}
}
})
.collect();
}
}
// Iterate over the sketch_group.value array and add face_id to GeoMeta
let mut new_value: Vec<ExtrudeSurface> = Vec::new();
for path in sketch_group.value.iter() {
if let Some(Some(actual_face_id)) = face_id_map.get(&path.get_base().geo_meta.id) {
match path {
Path::TangentialArc { .. } | Path::TangentialArcTo { .. } => {
let extrude_surface = ExtrudeSurface::ExtrudeArc(crate::executor::ExtrudeArc {
face_id: *actual_face_id,
tag: path.get_base().tag.clone(),
geo_meta: GeoMeta {
id: path.get_base().geo_meta.id,
metadata: path.get_base().geo_meta.metadata.clone(),
},
});
new_value.push(extrude_surface);
}
Path::Base { .. } | Path::ToPoint { .. } | Path::Horizontal { .. } | Path::AngledLineTo { .. } => {
let extrude_surface = ExtrudeSurface::ExtrudePlane(crate::executor::ExtrudePlane {
face_id: *actual_face_id,
tag: path.get_base().tag.clone(),
geo_meta: GeoMeta {
id: path.get_base().geo_meta.id,
metadata: path.get_base().geo_meta.metadata.clone(),
},
});
new_value.push(extrude_surface);
}
}
} else if args.ctx.is_mock {
// Only pre-populate the extrude surface if we are in mock mode.
new_value.push(ExtrudeSurface::ExtrudePlane(crate::executor::ExtrudePlane {
// pushing this values with a fake face_id to make extrudes mock-execute safe
face_id: Uuid::new_v4(),
tag: path.get_base().tag.clone(),
geo_meta: GeoMeta {
id: path.get_base().geo_meta.id,
metadata: path.get_base().geo_meta.metadata.clone(),
},
}));
}
}
Ok(Box::new(ExtrudeGroup {
// Ok so you would think that the id would be the id of the extrude group,
@ -279,45 +273,11 @@ pub(crate) async fn do_post_extrude(
// sketch group.
id: sketch_group.id,
value: new_value,
meta: sketch_group.meta.clone(),
sketch_group,
sketch_group: sketch_group.clone(),
height: length,
start_cap_id,
end_cap_id,
edge_cuts: vec![],
meta: sketch_group.meta,
}))
}
#[derive(Default)]
struct Faces {
/// Maps curve ID to face ID for each side.
sides: HashMap<Uuid, Option<Uuid>>,
/// Top face ID.
end_cap_id: Option<Uuid>,
/// Bottom face ID.
start_cap_id: Option<Uuid>,
}
fn analyze_faces(args: &Args, face_infos: Vec<ExtrusionFaceInfo>) -> Faces {
let mut faces = Faces {
sides: HashMap::with_capacity(face_infos.len()),
..Default::default()
};
if args.ctx.is_mock {
// Create fake IDs for start and end caps, to make extrudes mock-execute safe
faces.start_cap_id = Some(Uuid::new_v4());
faces.end_cap_id = Some(Uuid::new_v4());
}
for face_info in face_infos {
match face_info.cap {
ExtrusionFaceCapType::Bottom => faces.start_cap_id = face_info.face_id,
ExtrusionFaceCapType::Top => faces.end_cap_id = face_info.face_id,
ExtrusionFaceCapType::None => {
if let Some(curve_id) = face_info.curve_id {
faces.sides.insert(curve_id, face_info.face_id);
}
}
}
}
faces
}

View File

@ -1,174 +0,0 @@
//! Standard library lofts.
use anyhow::Result;
use derive_docs::stdlib;
use kittycad::types::ModelingCmd;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::{
errors::{KclError, KclErrorDetails},
executor::{ExtrudeGroup, KclValue, SketchGroup},
std::{extrude::do_post_extrude, fillet::default_tolerance, Args},
};
const DEFAULT_V_DEGREE: u32 = 2;
/// Data for a loft.
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, ts_rs::TS, JsonSchema)]
#[ts(export)]
#[serde(rename_all = "camelCase")]
pub struct LoftData {
/// Degree of the interpolation. Must be greater than zero.
/// For example, use 2 for quadratic, or 3 for cubic interpolation in the V direction.
/// This defaults to 2, if not specified.
pub v_degree: Option<std::num::NonZeroU32>,
/// Attempt to approximate rational curves (such as arcs) using a bezier.
/// This will remove banding around interpolations between arcs and non-arcs. It may produce errors in other scenarios
/// Over time, this field won't be necessary.
#[serde(default)]
pub bez_approximate_rational: Option<bool>,
/// This can be set to override the automatically determined topological base curve, which is usually the first section encountered.
#[serde(default)]
pub base_curve_index: Option<u32>,
/// Tolerance for the loft operation.
#[serde(default)]
pub tolerance: Option<f64>,
}
impl Default for LoftData {
fn default() -> Self {
Self {
// This unwrap is safe because the default value is always greater than zero.
v_degree: Some(std::num::NonZeroU32::new(DEFAULT_V_DEGREE).unwrap()),
bez_approximate_rational: None,
base_curve_index: None,
tolerance: None,
}
}
}
/// Create a 3D surface or solid by interpolating between two or more sketches.
pub async fn loft(args: Args) -> Result<KclValue, KclError> {
let (sketch_groups, data): (Vec<SketchGroup>, Option<LoftData>) = args.get_sketch_groups_and_data()?;
let extrude_group = inner_loft(sketch_groups, data, args).await?;
Ok(KclValue::ExtrudeGroup(extrude_group))
}
/// Create a 3D surface or solid by interpolating between two or more sketches.
///
/// The sketches need to closed and on the same plane.
///
/// ```no_run
/// // Loft a square and a triangle.
/// const squareSketch = startSketchOn('XY')
/// |> startProfileAt([-100, 200], %)
/// |> line([200, 0], %)
/// |> line([0, -200], %)
/// |> line([-200, 0], %)
/// |> lineTo([profileStartX(%), profileStartY(%)], %)
/// |> close(%)
///
/// const triangleSketch = startSketchOn(offsetPlane('XY', 75))
/// |> startProfileAt([0, 125], %)
/// |> line([-15, -30], %)
/// |> line([30, 0], %)
/// |> lineTo([profileStartX(%), profileStartY(%)], %)
/// |> close(%)
///
/// loft([squareSketch, triangleSketch])
/// ```
///
/// ```no_run
/// // Loft a square, a circle, and another circle.
/// const squareSketch = startSketchOn('XY')
/// |> startProfileAt([-100, 200], %)
/// |> line([200, 0], %)
/// |> line([0, -200], %)
/// |> line([-200, 0], %)
/// |> lineTo([profileStartX(%), profileStartY(%)], %)
/// |> close(%)
///
/// const circleSketch0 = startSketchOn(offsetPlane('XY', 75))
/// |> circle([0, 100], 50, %)
///
/// const circleSketch1 = startSketchOn(offsetPlane('XY', 150))
/// |> circle([0, 100], 20, %)
///
/// loft([squareSketch, circleSketch0, circleSketch1])
/// ```
///
/// ```no_run
/// // Loft a square, a circle, and another circle with options.
/// const squareSketch = startSketchOn('XY')
/// |> startProfileAt([-100, 200], %)
/// |> line([200, 0], %)
/// |> line([0, -200], %)
/// |> line([-200, 0], %)
/// |> lineTo([profileStartX(%), profileStartY(%)], %)
/// |> close(%)
///
/// const circleSketch0 = startSketchOn(offsetPlane('XY', 75))
/// |> circle([0, 100], 50, %)
///
/// const circleSketch1 = startSketchOn(offsetPlane('XY', 150))
/// |> circle([0, 100], 20, %)
///
/// loft([squareSketch, circleSketch0, circleSketch1], {
/// // This can be set to override the automatically determined
/// // topological base curve, which is usually the first section encountered.
/// baseCurveIndex: 0,
/// // Attempt to approximate rational curves (such as arcs) using a bezier.
/// // This will remove banding around interpolations between arcs and non-arcs.
/// // It may produce errors in other scenarios Over time, this field won't be necessary.
/// bezApproximateRational: false,
/// // Tolerance for the loft operation.
/// tolerance: 0.000001,
/// // Degree of the interpolation. Must be greater than zero.
/// // For example, use 2 for quadratic, or 3 for cubic interpolation in
/// // the V direction. This defaults to 2, if not specified.
/// vDegree: 2,
/// })
/// ```
#[stdlib {
name = "loft",
}]
async fn inner_loft(
sketch_groups: Vec<SketchGroup>,
data: Option<LoftData>,
args: Args,
) -> Result<Box<ExtrudeGroup>, KclError> {
// Make sure we have at least two sketches.
if sketch_groups.len() < 2 {
return Err(KclError::Semantic(KclErrorDetails {
message: format!(
"Loft requires at least two sketches, but only {} were provided.",
sketch_groups.len()
),
source_ranges: vec![args.source_range],
}));
}
// Get the loft data.
let data = data.unwrap_or_default();
let id = uuid::Uuid::new_v4();
args.batch_modeling_cmd(
id,
ModelingCmd::Loft {
section_ids: sketch_groups.iter().map(|group| group.id).collect(),
base_curve_index: data.base_curve_index,
bez_approximate_rational: data.bez_approximate_rational.unwrap_or(false),
tolerance: data.tolerance.unwrap_or(default_tolerance(&args.ctx.settings.units)),
v_degree: data
.v_degree
.unwrap_or_else(|| std::num::NonZeroU32::new(DEFAULT_V_DEGREE).unwrap())
.into(),
},
)
.await?;
// Using the first sketch as the base curve, idk we might want to change this later.
do_post_extrude(sketch_groups[0].clone(), 0.0, args).await
}

View File

@ -9,10 +9,8 @@ pub mod fillet;
pub mod helix;
pub mod import;
pub mod kcl_stdlib;
pub mod loft;
pub mod math;
pub mod patterns;
pub mod planes;
pub mod polar;
pub mod revolve;
pub mod segment;
@ -100,8 +98,6 @@ lazy_static! {
Box::new(crate::std::shell::Shell),
Box::new(crate::std::shell::Hollow),
Box::new(crate::std::revolve::Revolve),
Box::new(crate::std::loft::Loft),
Box::new(crate::std::planes::OffsetPlane),
Box::new(crate::std::import::Import),
Box::new(crate::std::math::Cos),
Box::new(crate::std::math::Sin),
@ -488,7 +484,7 @@ layout: manual
buf.push_str(&fn_docs);
// Write the file.
expectorate::assert_contents(format!("../../../docs/kcl/{}.md", internal_fn.name()), &buf);
expectorate::assert_contents(&format!("../../../docs/kcl/{}.md", internal_fn.name()), &buf);
}
}

View File

@ -1,168 +0,0 @@
//! Standard library plane helpers.
use derive_docs::stdlib;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::{
errors::KclError,
executor::{KclValue, Metadata, Plane, UserVal},
std::{sketch::PlaneData, Args},
};
/// One of the standard planes.
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub enum StandardPlane {
/// The XY plane.
#[serde(rename = "XY", alias = "xy")]
XY,
/// The opposite side of the XY plane.
#[serde(rename = "-XY", alias = "-xy")]
NegXY,
/// The XZ plane.
#[serde(rename = "XZ", alias = "xz")]
XZ,
/// The opposite side of the XZ plane.
#[serde(rename = "-XZ", alias = "-xz")]
NegXZ,
/// The YZ plane.
#[serde(rename = "YZ", alias = "yz")]
YZ,
/// The opposite side of the YZ plane.
#[serde(rename = "-YZ", alias = "-yz")]
NegYZ,
}
impl From<StandardPlane> for PlaneData {
fn from(value: StandardPlane) -> Self {
match value {
StandardPlane::XY => PlaneData::XY,
StandardPlane::NegXY => PlaneData::NegXY,
StandardPlane::XZ => PlaneData::XZ,
StandardPlane::NegXZ => PlaneData::NegXZ,
StandardPlane::YZ => PlaneData::YZ,
StandardPlane::NegYZ => PlaneData::NegYZ,
}
}
}
/// Offset a plane by a distance along its normal.
pub async fn offset_plane(args: Args) -> Result<KclValue, KclError> {
let (std_plane, offset): (StandardPlane, f64) = args.get_data_and_float()?;
let plane = inner_offset_plane(std_plane, offset).await?;
Ok(KclValue::UserVal(UserVal::set(
vec![Metadata {
source_range: args.source_range,
}],
plane,
)))
}
/// Offset a plane by a distance along its normal.
///
/// For example, if you offset the 'XZ' plane by 10, the new plane will be parallel to the 'XZ'
/// plane and 10 units away from it.
///
/// ```no_run
/// // Loft a square and a circle on the `XY` plane using offset.
/// const squareSketch = startSketchOn('XY')
/// |> startProfileAt([-100, 200], %)
/// |> line([200, 0], %)
/// |> line([0, -200], %)
/// |> line([-200, 0], %)
/// |> lineTo([profileStartX(%), profileStartY(%)], %)
/// |> close(%)
///
/// const circleSketch = startSketchOn(offsetPlane('XY', 150))
/// |> circle([0, 100], 50, %)
///
/// loft([squareSketch, circleSketch])
/// ```
///
/// ```no_run
/// // Loft a square and a circle on the `XZ` plane using offset.
/// const squareSketch = startSketchOn('XZ')
/// |> startProfileAt([-100, 200], %)
/// |> line([200, 0], %)
/// |> line([0, -200], %)
/// |> line([-200, 0], %)
/// |> lineTo([profileStartX(%), profileStartY(%)], %)
/// |> close(%)
///
/// const circleSketch = startSketchOn(offsetPlane('XZ', 150))
/// |> circle([0, 100], 50, %)
///
/// loft([squareSketch, circleSketch])
/// ```
///
/// ```no_run
/// // Loft a square and a circle on the `YZ` plane using offset.
/// const squareSketch = startSketchOn('YZ')
/// |> startProfileAt([-100, 200], %)
/// |> line([200, 0], %)
/// |> line([0, -200], %)
/// |> line([-200, 0], %)
/// |> lineTo([profileStartX(%), profileStartY(%)], %)
/// |> close(%)
///
/// const circleSketch = startSketchOn(offsetPlane('YZ', 150))
/// |> circle([0, 100], 50, %)
///
/// loft([squareSketch, circleSketch])
/// ```
///
/// ```no_run
/// // Loft a square and a circle on the `-XZ` plane using offset.
/// const squareSketch = startSketchOn('-XZ')
/// |> startProfileAt([-100, 200], %)
/// |> line([200, 0], %)
/// |> line([0, -200], %)
/// |> line([-200, 0], %)
/// |> lineTo([profileStartX(%), profileStartY(%)], %)
/// |> close(%)
///
/// const circleSketch = startSketchOn(offsetPlane('-XZ', -150))
/// |> circle([0, 100], 50, %)
///
/// loft([squareSketch, circleSketch])
/// ```
#[stdlib {
name = "offsetPlane",
}]
async fn inner_offset_plane(std_plane: StandardPlane, offset: f64) -> Result<PlaneData, KclError> {
// Convert to the plane type.
let plane_data: PlaneData = std_plane.into();
// Convert to a plane.
let mut plane = Plane::from(plane_data);
match std_plane {
StandardPlane::XY => {
plane.origin.z += offset;
}
StandardPlane::XZ => {
plane.origin.y -= offset;
}
StandardPlane::YZ => {
plane.origin.x += offset;
}
StandardPlane::NegXY => {
plane.origin.z -= offset;
}
StandardPlane::NegXZ => {
plane.origin.y += offset;
}
StandardPlane::NegYZ => {
plane.origin.x -= offset;
}
}
Ok(PlaneData::Plane {
origin: Box::new(plane.origin),
x_axis: Box::new(plane.x_axis),
y_axis: Box::new(plane.y_axis),
z_axis: Box::new(plane.z_axis),
})
}

View File

@ -299,7 +299,7 @@ async fn inner_revolve(
}
}
do_post_extrude(sketch_group, 0.0, args).await
do_post_extrude(sketch_group, 0.0, id, args).await
}
#[cfg(test)]

View File

@ -1269,7 +1269,7 @@ pub(crate) async fn inner_start_profile_at(
adjust_camera: false,
planar_normal: if let SketchSurface::Plane(plane) = &sketch_surface {
// We pass in the normal for the plane here.
Some(plane.z_axis.into())
Some(plane.z_axis.clone().into())
} else {
None
},

View File

@ -50,13 +50,13 @@ pub fn token(i: &mut Located<&str>) -> PResult<Token> {
}
fn block_comment(i: &mut Located<&str>) -> PResult<Token> {
let inner = ("/*", take_until(0.., "*/"), "*/").take();
let inner = ("/*", take_until(0.., "*/"), "*/").recognize();
let (value, range) = inner.with_span().parse_next(i)?;
Ok(Token::from_range(range, TokenType::BlockComment, value.to_string()))
}
fn line_comment(i: &mut Located<&str>) -> PResult<Token> {
let inner = (r#"//"#, take_till(0.., ['\n', '\r'])).take();
let inner = (r#"//"#, take_till(0.., ['\n', '\r'])).recognize();
let (value, range) = inner.with_span().parse_next(i)?;
Ok(Token::from_range(range, TokenType::LineComment, value.to_string()))
}
@ -68,7 +68,7 @@ fn number(i: &mut Located<&str>) -> PResult<Token> {
// No digits before the decimal point.
('.', digit1).map(|_| ()),
));
let (value, range) = number_parser.take().with_span().parse_next(i)?;
let (value, range) = number_parser.recognize().with_span().parse_next(i)?;
Ok(Token::from_range(range, TokenType::Number, value.to_string()))
}
@ -79,12 +79,12 @@ fn whitespace(i: &mut Located<&str>) -> PResult<Token> {
fn inner_word(i: &mut Located<&str>) -> PResult<()> {
one_of(('a'..='z', 'A'..='Z', '_')).parse_next(i)?;
repeat::<_, _, (), _, _>(0.., one_of(('a'..='z', 'A'..='Z', '0'..='9', '_'))).parse_next(i)?;
repeat(0.., one_of(('a'..='z', 'A'..='Z', '0'..='9', '_'))).parse_next(i)?;
Ok(())
}
fn word(i: &mut Located<&str>) -> PResult<Token> {
let (value, range) = inner_word.take().with_span().parse_next(i)?;
let (value, range) = inner_word.recognize().with_span().parse_next(i)?;
Ok(Token::from_range(range, TokenType::Word, value.to_string()))
}
@ -162,9 +162,9 @@ fn inner_single_quote(i: &mut Located<&str>) -> PResult<()> {
}
fn string(i: &mut Located<&str>) -> PResult<Token> {
let single_quoted_string = ('\'', inner_single_quote.take(), '\'');
let double_quoted_string = ('"', inner_double_quote.take(), '"');
let either_quoted_string = alt((single_quoted_string.take(), double_quoted_string.take()));
let single_quoted_string = ('\'', inner_single_quote.recognize(), '\'');
let double_quoted_string = ('"', inner_double_quote.recognize(), '"');
let either_quoted_string = alt((single_quoted_string.recognize(), double_quoted_string.recognize()));
let (value, range): (&str, _) = either_quoted_string.with_span().parse_next(i)?;
Ok(Token::from_range(range, TokenType::String, value.to_string()))
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

View File

@ -1,3 +1,3 @@
[toolchain]
channel = "1.81.0"
channel = "1.80.1"
components = ["clippy", "rustfmt"]

View File

@ -55,10 +55,10 @@ const bracketBody = bs
|> fillet({
radius: radius,
tags: [
getPreviousAdjacentEdge(bs.tags.edge7),
getPreviousAdjacentEdge(bs.tags.edge2),
getPreviousAdjacentEdge(bs.tags.edge3),
getPreviousAdjacentEdge(bs.tags.edge6)
getNextAdjacentEdge(bs.tags.edge7),
getNextAdjacentEdge(bs.tags.edge2),
getNextAdjacentEdge(bs.tags.edge3),
getNextAdjacentEdge(bs.tags.edge6)
]
}, %)

View File

@ -1,81 +0,0 @@
// 2x8 Lego Brick
// A standard Lego brick with 2 bumps wide and 8 bumps long.
// Define constants
const lbumps = 10 // number of bumps long
const wbumps = {{N}} // number of bumps wide
const pitch = 8.0
const clearance = 0.1
const bumpDiam = 4.8
const bumpHeight = 1.8
const height = 9.6
const t = (pitch - (2 * clearance) - bumpDiam) / 2.0
const totalLength = lbumps * pitch - (2.0 * clearance)
const totalWidth = wbumps * pitch - (2.0 * clearance)
// Create the plane for the pegs. This is a hack so that the pegs can be patterned along the face of the lego base.
const pegFace = {
plane: {
origin: { x: 0, y: 0, z: height },
xAxis: { x: 1, y: 0, z: 0 },
yAxis: { x: 0, y: 1, z: 0 },
zAxis: { x: 0, y: 0, z: 1 }
}
}
// Create the plane for the tubes underneath the lego. This is a hack so that the tubes can be patterned underneath the lego.
const tubeFace = {
plane: {
origin: { x: 0, y: 0, z: height - t },
xAxis: { x: 1, y: 0, z: 0 },
yAxis: { x: 0, y: 1, z: 0 },
zAxis: { x: 0, y: 0, z: 1 }
}
}
// Make the base
const s = startSketchOn('XY')
|> startProfileAt([-totalWidth / 2, -totalLength / 2], %)
|> line([totalWidth, 0], %)
|> line([0, totalLength], %)
|> line([-totalWidth, 0], %)
|> close(%)
|> extrude(height, %)
// Sketch and extrude a rectangular shape to create the shell underneath the lego. This is a hack until we have a shell function.
const shellExtrude = startSketchOn(s, "start")
|> startProfileAt([
-(totalWidth / 2 - t),
-(totalLength / 2 - t)
], %)
|> line([totalWidth - (2 * t), 0], %)
|> line([0, totalLength - (2 * t)], %)
|> line([-(totalWidth - (2 * t)), 0], %)
|> close(%)
|> extrude(-(height - t), %)
fn tr = (i) => {
let j = i + 1
let x = (j/wbumps) * pitch
let y = (j % wbumps) * pitch
return {
translate: [x, y, 0],
}
}
// Create the pegs on the top of the base
const totalBumps = (wbumps * lbumps)-1
const peg = startSketchOn(s, 'end')
|> circle([
-(pitch*(wbumps-1)/2),
-(pitch*(lbumps-1)/2)
], bumpDiam / 2, %)
|> patternLinear2d({
axis: [1, 0],
repetitions: wbumps-1,
distance: pitch
}, %)
|> patternLinear2d({
axis: [0, 1],
repetitions: lbumps-1,
distance: pitch
}, %)
|> extrude(bumpHeight, %)
// |> patternTransform(int(totalBumps-1), tr, %)

148
yarn.lock
View File

@ -2314,11 +2314,6 @@
resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.21.0.tgz#20c09cf44dcb082140cc7f439dd679fe4bba3375"
integrity sha512-2jsCDZwtQvRhejHLfZ1JY6w6kEuEtfF9nzYsZxzSlNVKDX+DpsDJ+Rbjkm74nvg2rdx0gwBS+IMdvwJuq3S9pQ==
"@rtsao/scc@^1.1.0":
version "1.1.0"
resolved "https://registry.yarnpkg.com/@rtsao/scc/-/scc-1.1.0.tgz#927dd2fae9bc3361403ac2c7a00c32ddce9ad7e8"
integrity sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==
"@rushstack/eslint-patch@^1.1.0":
version "1.10.4"
resolved "https://registry.yarnpkg.com/@rushstack/eslint-patch/-/eslint-patch-1.10.4.tgz#427d5549943a9c6fce808e39ea64dbe60d4047f1"
@ -2353,6 +2348,72 @@
resolved "https://registry.yarnpkg.com/@tanstack/virtual-core/-/virtual-core-3.8.4.tgz#0ff84b6a0e4b394335cf7ccf759c36b58cbd02eb"
integrity sha512-iO5Ujgw3O1yIxWDe9FgUPNkGjyT657b1WNX52u+Wv1DyBFEpdCdGkuVaky0M3hHFqNWjAmHWTn4wgj9rTr7ZQg==
"@tauri-apps/cli-darwin-arm64@2.0.0-rc.9":
version "2.0.0-rc.9"
resolved "https://registry.yarnpkg.com/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.0.0-rc.9.tgz#d6d9522b549a73ffb2c10ee273e6ac766dfa5914"
integrity sha512-RaCx1KpMX27iS1Cn7MYbVA0Gc5NsjU0Z1Qo42ibzF4OHInOkDcx3qjAaE+xD572Lb9ksBO725cIcYCdgqGu4Vw==
"@tauri-apps/cli-darwin-x64@2.0.0-rc.9":
version "2.0.0-rc.9"
resolved "https://registry.yarnpkg.com/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.0.0-rc.9.tgz#7ae9abfbeff998f13608d9248bdadba73b1560c0"
integrity sha512-KKUs8kbHYZrcmY/AjKjxEEm7aHGWQsn3+BGsgamKl97k2K5R5Z0KLJUy6QVhUSISEIievjDPmBDIwgA6mlrCLQ==
"@tauri-apps/cli-linux-arm-gnueabihf@2.0.0-rc.9":
version "2.0.0-rc.9"
resolved "https://registry.yarnpkg.com/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.0.0-rc.9.tgz#8330576565f9ac411011d491a26e94d9116eb5ad"
integrity sha512-OgVCt72g0AnIB3zuKJLEIOCNeviiNeLoQQsVs7ESaqxZ/gMXY35yGVhrFm83eAQ0G4BervHDog15bsY3Dxbc/g==
"@tauri-apps/cli-linux-arm64-gnu@2.0.0-rc.9":
version "2.0.0-rc.9"
resolved "https://registry.yarnpkg.com/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.0.0-rc.9.tgz#9b4b79dd256c39fed495fd8b7ffdb798078c61ab"
integrity sha512-7kQcXXXpCYB0AWbTRaKAim3JVMKdrxVOiqnOW+7elkqDQxDqmLQho2ah1qHv7LzZ6Z83u5QejrRLeHrrdo3PEg==
"@tauri-apps/cli-linux-arm64-musl@2.0.0-rc.9":
version "2.0.0-rc.9"
resolved "https://registry.yarnpkg.com/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.0.0-rc.9.tgz#5afd06c1601ff823b7d82785236f63af379fd6d4"
integrity sha512-2hqANZrydqZpptUsfAHSL5DIaEfHN73UGEu+5keFCV1Irh+QPydr1CYrqhgFF982ev6Ars7nxALwpPhEODjYlg==
"@tauri-apps/cli-linux-x64-gnu@2.0.0-rc.9":
version "2.0.0-rc.9"
resolved "https://registry.yarnpkg.com/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.0.0-rc.9.tgz#39185adc857e3e8474008600b7f0a6e0e42abdbf"
integrity sha512-Zjna6eoVSlmZtzAXgH27sgJRnczNzMKRiGsMpY00PFxN9sbQwlsS3yMfB8GHsBeBoq+qJQsteRwhrn1mj6e3Rg==
"@tauri-apps/cli-linux-x64-musl@2.0.0-rc.9":
version "2.0.0-rc.9"
resolved "https://registry.yarnpkg.com/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.0.0-rc.9.tgz#a8d703010892622cf38e87950f5d2920833fac88"
integrity sha512-8ODcbvwZw29sAWns36BeBYJ3iu3Mtv4J3WkcoVbanVCP8nu7ja3401VnWBjckRiI1iDJIm59m6ojVkGYQhAe9Q==
"@tauri-apps/cli-win32-arm64-msvc@2.0.0-rc.9":
version "2.0.0-rc.9"
resolved "https://registry.yarnpkg.com/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.0.0-rc.9.tgz#8ddea7d990b701357fe3dfd8e8e1783898206d85"
integrity sha512-j6jJId8hlid/W4ezDRNK49DSjxb82W6d1qVqO7zksKdZLy8tVzFkZXwEeKhabzRQsO87KL34I+ciRlmInGis0Q==
"@tauri-apps/cli-win32-ia32-msvc@2.0.0-rc.9":
version "2.0.0-rc.9"
resolved "https://registry.yarnpkg.com/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.0.0-rc.9.tgz#ffa340d2dbf0e87355fa92650fbd707adc12d84e"
integrity sha512-w9utY58kfzJS+iLCjyQyQbJS8YaCM8YCWkgK2ZkySmHAdnqdGeyJEWig1qrLH1TWd+O6K3TlCNv55ujeAtOE4w==
"@tauri-apps/cli-win32-x64-msvc@2.0.0-rc.9":
version "2.0.0-rc.9"
resolved "https://registry.yarnpkg.com/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.0.0-rc.9.tgz#93f0cdc8c6999227aeee86741b553c16cb7ac20f"
integrity sha512-+l2RcpTthzYkw3VsmcZkb099Jfl0d21a9VIFxdk+duKeYieRpb0MsIBP6fS7WlNAeqrinC0zi/zt+Nia6mPuyw==
"@tauri-apps/cli@^2.0.0-rc.9":
version "2.0.0-rc.9"
resolved "https://registry.yarnpkg.com/@tauri-apps/cli/-/cli-2.0.0-rc.9.tgz#b641ad224dd055aae4f101c14d0696d2e06862c0"
integrity sha512-cjj5HVKHUlxL87TN7ZZpnlMgcBS+ToIyfLB6jpaNDZ9Op0/qzccWGZpPbW2P/BnfF/qwHzVJNUPGANFyvBSUeg==
optionalDependencies:
"@tauri-apps/cli-darwin-arm64" "2.0.0-rc.9"
"@tauri-apps/cli-darwin-x64" "2.0.0-rc.9"
"@tauri-apps/cli-linux-arm-gnueabihf" "2.0.0-rc.9"
"@tauri-apps/cli-linux-arm64-gnu" "2.0.0-rc.9"
"@tauri-apps/cli-linux-arm64-musl" "2.0.0-rc.9"
"@tauri-apps/cli-linux-x64-gnu" "2.0.0-rc.9"
"@tauri-apps/cli-linux-x64-musl" "2.0.0-rc.9"
"@tauri-apps/cli-win32-arm64-msvc" "2.0.0-rc.9"
"@tauri-apps/cli-win32-ia32-msvc" "2.0.0-rc.9"
"@tauri-apps/cli-win32-x64-msvc" "2.0.0-rc.9"
"@testing-library/dom@^10.0.0":
version "10.4.0"
resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-10.4.0.tgz#82a9d9462f11d240ecadbf406607c6ceeeff43a8"
@ -3181,7 +3242,7 @@ array-flatten@1.1.1:
resolved "https://registry.yarnpkg.com/array-flatten/-/array-flatten-1.1.1.tgz#9a5f699051b1e7073328f2a008968b64ea2955d2"
integrity sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==
array-includes@^3.1.6, array-includes@^3.1.8:
array-includes@^3.1.6, array-includes@^3.1.7, array-includes@^3.1.8:
version "3.1.8"
resolved "https://registry.yarnpkg.com/array-includes/-/array-includes-3.1.8.tgz#5e370cbe172fdd5dd6530c1d4aadda25281ba97d"
integrity sha512-itaWrbYbqpGXkGhZPGUulwnhVf5Hpy1xiCFsGqyIGglbBxmG5vSjxQen3/WGOjPpNEv1RtBLKxbmVXm8HpJStQ==
@ -3210,7 +3271,7 @@ array.prototype.findlast@^1.2.5:
es-object-atoms "^1.0.0"
es-shim-unscopables "^1.0.2"
array.prototype.findlastindex@^1.2.5:
array.prototype.findlastindex@^1.2.3:
version "1.2.5"
resolved "https://registry.yarnpkg.com/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.5.tgz#8c35a755c72908719453f87145ca011e39334d0d"
integrity sha512-zfETvRFA8o7EiNn++N5f/kaCw221hrpGsDmcpndVupkPzEc1Wuf3VgC0qby1BbHs7f5DVYjgtEU2LLh5bqeGfQ==
@ -3607,14 +3668,6 @@ builder-util-runtime@9.2.4:
debug "^4.3.4"
sax "^1.2.4"
builder-util-runtime@9.2.5:
version "9.2.5"
resolved "https://registry.yarnpkg.com/builder-util-runtime/-/builder-util-runtime-9.2.5.tgz#0afdffa0adb5c84c14926c7dd2cf3c6e96e9be83"
integrity sha512-HjIDfhvqx/8B3TDN4GbABQcgpewTU4LMRTQPkVpKYV3lsuxEJoIfvg09GyWTNmfVNSUAYf+fbTN//JX4TH20pg==
dependencies:
debug "^4.3.4"
sax "^1.2.4"
builder-util@24.13.1:
version "24.13.1"
resolved "https://registry.yarnpkg.com/builder-util/-/builder-util-24.13.1.tgz#4a4c4f9466b016b85c6990a0ea15aa14edec6816"
@ -4560,12 +4613,12 @@ electron-to-chromium@^1.5.4:
resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.13.tgz#1abf0410c5344b2b829b7247e031f02810d442e6"
integrity sha512-lbBcvtIJ4J6sS4tb5TLp1b4LyfCdMkwStzXPyAgVgTRAsep4bvrAGaBOP7ZJtQMNJpSQ9SqG4brWOroNaQtm7Q==
electron-updater@^6.3.0:
version "6.3.0"
resolved "https://registry.yarnpkg.com/electron-updater/-/electron-updater-6.3.0.tgz#13a5c3c3f0b2b114fe33181e24a8270096734b3e"
integrity sha512-3Xlezhk+dKaSQrOnkQNqCGiuGSSUPO9BV9TQZ4Iig6AyTJ4FzJONE5gFFc382sY53Sh9dwJfzKsA3DxRHt2btw==
electron-updater@^6.2.1:
version "6.2.1"
resolved "https://registry.yarnpkg.com/electron-updater/-/electron-updater-6.2.1.tgz#1c9adb9ba2a21a5dc50a8c434c45360d5e9fe6c9"
integrity sha512-83eKIPW14qwZqUUM6wdsIRwVKZyjmHxQ4/8G+1C6iS5PdDt7b1umYQyj1/qPpH510GmHEQe4q0kCPe3qmb3a0Q==
dependencies:
builder-util-runtime "9.2.5"
builder-util-runtime "9.2.4"
fs-extra "^10.1.0"
js-yaml "^4.1.0"
lazy-val "^1.0.5"
@ -4882,10 +4935,10 @@ eslint-import-resolver-node@^0.3.9:
is-core-module "^2.13.0"
resolve "^1.22.4"
eslint-module-utils@^2.9.0:
version "2.9.0"
resolved "https://registry.yarnpkg.com/eslint-module-utils/-/eslint-module-utils-2.9.0.tgz#95d4ac038a68cd3f63482659dffe0883900eb342"
integrity sha512-McVbYmwA3NEKwRQY5g4aWMdcZE5xZxV8i8l7CqJSrameuGSQJtSWaL/LxTEzSKKaCcOhlpDR8XEfYXWPrdo/ZQ==
eslint-module-utils@^2.8.0:
version "2.8.1"
resolved "https://registry.yarnpkg.com/eslint-module-utils/-/eslint-module-utils-2.8.1.tgz#52f2404300c3bd33deece9d7372fb337cc1d7c34"
integrity sha512-rXDXR3h7cs7dy9RNpUlQf80nX31XWJEyGq1tRMo+6GsO5VmTe4UTwtmonAD4ZkAsrfMVDA2wlGJ3790Ys+D49Q==
dependencies:
debug "^3.2.7"
@ -4905,27 +4958,26 @@ eslint-plugin-flowtype@^8.0.3:
lodash "^4.17.21"
string-natural-compare "^3.0.1"
eslint-plugin-import@^2.25.3, eslint-plugin-import@^2.30.0:
version "2.30.0"
resolved "https://registry.yarnpkg.com/eslint-plugin-import/-/eslint-plugin-import-2.30.0.tgz#21ceea0fc462657195989dd780e50c92fe95f449"
integrity sha512-/mHNE9jINJfiD2EKkg1BKyPyUk4zdnT54YgbOgfjSakWT5oyX/qQLVNTkehyfpcMxZXMy1zyonZ2v7hZTX43Yw==
eslint-plugin-import@^2.25.0, eslint-plugin-import@^2.25.3:
version "2.29.1"
resolved "https://registry.yarnpkg.com/eslint-plugin-import/-/eslint-plugin-import-2.29.1.tgz#d45b37b5ef5901d639c15270d74d46d161150643"
integrity sha512-BbPC0cuExzhiMo4Ff1BTVwHpjjv28C5R+btTOGaCRC7UEz801up0JadwkeSk5Ued6TG34uaczuVuH6qyy5YUxw==
dependencies:
"@rtsao/scc" "^1.1.0"
array-includes "^3.1.8"
array.prototype.findlastindex "^1.2.5"
array-includes "^3.1.7"
array.prototype.findlastindex "^1.2.3"
array.prototype.flat "^1.3.2"
array.prototype.flatmap "^1.3.2"
debug "^3.2.7"
doctrine "^2.1.0"
eslint-import-resolver-node "^0.3.9"
eslint-module-utils "^2.9.0"
hasown "^2.0.2"
is-core-module "^2.15.1"
eslint-module-utils "^2.8.0"
hasown "^2.0.0"
is-core-module "^2.13.1"
is-glob "^4.0.3"
minimatch "^3.1.2"
object.fromentries "^2.0.8"
object.groupby "^1.0.3"
object.values "^1.2.0"
object.fromentries "^2.0.7"
object.groupby "^1.0.1"
object.values "^1.1.7"
semver "^6.3.1"
tsconfig-paths "^3.15.0"
@ -6206,10 +6258,10 @@ is-ci@^3.0.0:
dependencies:
ci-info "^3.2.0"
is-core-module@^2.13.0, is-core-module@^2.15.1:
version "2.15.1"
resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.15.1.tgz#a7363a25bee942fefab0de13bf6aa372c82dcc37"
integrity sha512-z0vtXSwucUJtANQWldhbtbt7BnL0vxiFjIdDLAatwhDYty2bad6s+rijD6Ri4YuYJubLzIJLUidCh09e1djEVQ==
is-core-module@^2.13.0, is-core-module@^2.13.1:
version "2.15.0"
resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.15.0.tgz#71c72ec5442ace7e76b306e9d48db361f22699ea"
integrity sha512-Dd+Lb2/zvk9SKy1TGCt1wFJFo/MWBPMX5x7KcvLajWTGuomczdQX61PvY5yK6SVACwpoexWo81IfFyoKY2QnTA==
dependencies:
hasown "^2.0.2"
@ -7332,7 +7384,7 @@ object.entries@^1.1.8:
define-properties "^1.2.1"
es-object-atoms "^1.0.0"
object.fromentries@^2.0.8:
object.fromentries@^2.0.7, object.fromentries@^2.0.8:
version "2.0.8"
resolved "https://registry.yarnpkg.com/object.fromentries/-/object.fromentries-2.0.8.tgz#f7195d8a9b97bd95cbc1999ea939ecd1a2b00c65"
integrity sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==
@ -7342,7 +7394,7 @@ object.fromentries@^2.0.8:
es-abstract "^1.23.2"
es-object-atoms "^1.0.0"
object.groupby@^1.0.3:
object.groupby@^1.0.1:
version "1.0.3"
resolved "https://registry.yarnpkg.com/object.groupby/-/object.groupby-1.0.3.tgz#9b125c36238129f6f7b61954a1e7176148d5002e"
integrity sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==
@ -7351,7 +7403,7 @@ object.groupby@^1.0.3:
define-properties "^1.2.1"
es-abstract "^1.23.2"
object.values@^1.1.6, object.values@^1.2.0:
object.values@^1.1.6, object.values@^1.1.7, object.values@^1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/object.values/-/object.values-1.2.0.tgz#65405a9d92cee68ac2d303002e0b8470a4d9ab1b"
integrity sha512-yBYjY9QX2hnRmZHAjG/f13MzmBzxzYgQhFrke06TTyKY5zSTEqkOeukBzIdVA3j3ulu8Qa3MbVFShV7T2RmGtQ==
@ -7993,10 +8045,10 @@ react-hot-toast@^2.4.1:
dependencies:
goober "^2.1.10"
react-hotkeys-hook@^4.5.1:
version "4.5.1"
resolved "https://registry.yarnpkg.com/react-hotkeys-hook/-/react-hotkeys-hook-4.5.1.tgz#990260ecc7e5a431414148a93b02a2f1a9707897"
integrity sha512-scAEJOh3Irm0g95NIn6+tQVf/OICCjsQsC9NBHfQws/Vxw4sfq1tDQut5fhTEvPraXhu/sHxRd9lOtxzyYuNAg==
react-hotkeys-hook@^4.5.0:
version "4.5.0"
resolved "https://registry.yarnpkg.com/react-hotkeys-hook/-/react-hotkeys-hook-4.5.0.tgz#807b389b15256daf6a813a1ec09e6698064fe97f"
integrity sha512-Samb85GSgAWFQNvVt3PS90LPPGSf9mkH/r4au81ZP1yOIFayLC3QAvqTgGtJ8YEDMXtPmaVBs6NgipHO6h4Mug==
react-is@^16.13.1:
version "16.13.1"