Compare commits

...

63 Commits

Author SHA1 Message Date
2342d04fe2 Add back old window detection heuristic 2025-04-04 18:05:29 -04:00
ddac107dc1 Test attempt number 15345203, action! 2025-04-04 16:43:49 -04:00
313a21e82a Try again 2025-04-04 14:58:06 -04:00
b9875bb8cf Fix camera failure 2025-04-04 14:28:04 -04:00
8940f9b214 tsc lint 2025-04-04 14:24:27 -04:00
0af99af15e Fix native menu tests more 2025-04-04 13:51:44 -04:00
7f18aef49b Add app console.log 2025-04-04 12:43:04 -04:00
062495c02d Hopefully fix snapshots at least 2025-04-04 12:40:14 -04:00
767fde869c Use scene.connectionEstablished 2025-04-04 11:46:04 -04:00
49fcbdddba Try something 2025-04-04 11:39:22 -04:00
e06f76a6bb Qualify fail function 2025-04-04 11:38:05 -04:00
e98a957553 Remove a test race - poll window info. 2025-04-04 11:37:20 -04:00
25af691911 Fix cache count numbers in editor test 2025-04-04 11:37:20 -04:00
cdc0fa4ed9 Fix zoom to fit at the right moments... 2025-04-04 11:37:20 -04:00
4d06a917f2 Fix the bad reload repeat issue kevin started on 2025-04-04 11:37:19 -04:00
682fa50c1a Remove NetworkHealthIndicator test that was shit 2025-04-04 11:36:27 -04:00
496522e27d a new list of circular deps 2025-04-04 11:36:27 -04:00
4c57cea22d Fix zoom to fit being frigged 2025-04-04 11:36:27 -04:00
70f9c8edf1 fmt tsc 2025-04-04 11:36:27 -04:00
9de37879e5 fmt 2025-04-04 11:36:27 -04:00
91b5549a06 Fix another cyclic mfer! 2025-04-04 11:36:27 -04:00
3471f73479 Get rid of 1 cyclic dependency 2025-04-04 11:36:27 -04:00
277c489e38 Fix up new changes 2025-04-04 11:36:26 -04:00
00122aae5e yarn fmt lint tsc 2025-04-04 11:35:44 -04:00
65adfd4497 Fix up named-views tests 2025-04-04 11:34:59 -04:00
8d0343c946 Fix 2 more 2025-04-04 11:34:59 -04:00
4c564bf332 Fix more tests 2025-04-04 11:34:59 -04:00
ed1e2aedfd Fixup after rebase 2025-04-04 11:34:59 -04:00
992fec6afb wip 2025-04-04 11:34:58 -04:00
62a18dd2b3 less flaky point and click 2025-04-04 11:34:31 -04:00
2c673fba82 wip 2025-04-04 11:34:30 -04:00
635314bb8d Fix tsc after rebase 2025-04-04 11:33:39 -04:00
c75aafa60d yarn tsc 2025-04-04 11:33:39 -04:00
fb192ee213 yarn lint 2025-04-04 11:33:39 -04:00
9e19d131eb yarn fmt 2025-04-04 11:33:39 -04:00
81f70251e1 Fix the rest of the mfing tests 2025-04-04 11:33:39 -04:00
74f9afb2ca Deflake revolve some revolve tests 2025-04-04 11:33:39 -04:00
d03343d97d More e2e fixes 2025-04-04 11:33:39 -04:00
6c8a525762 Rework initial root projects dir + deflake many projects tests 2025-04-04 11:33:39 -04:00
c8177564e1 wip 2025-04-04 11:33:39 -04:00
12bf41ab7e Clear diagnostics when unmounting code editor! 2025-04-04 11:33:39 -04:00
fbefff490c Massive extinction event for waitForExecutionDone; try to stop projects view switching from crashing 2025-04-04 11:33:38 -04:00
4c9851efbf yarn fmt 2025-04-04 11:33:20 -04:00
2cca1376e2 Fix streamIdleMode checkbox being wonky 2025-04-04 11:33:20 -04:00
c32a3edb39 wip 2025-04-04 11:33:20 -04:00
955a2ffaf9 Add back better ping indicator 2025-04-04 11:33:20 -04:00
6bc8e45aa7 Use pause/play iconology 2025-04-04 11:33:20 -04:00
be0fdc53b5 Remove camera sync 2025-04-04 11:33:20 -04:00
cfbe805e14 Fix up everything after bumping kittycad/lib 2025-04-04 11:33:20 -04:00
2a60efc5ab Move engineStreamMachine as a global actor; tons of more work 2025-04-04 11:33:20 -04:00
deb83cf62c tsc lint fmt 2025-04-04 11:33:20 -04:00
e3705bf7df cargo fmt 2025-04-04 11:33:20 -04:00
ff887af540 Correct serialization; only expose at user level 2025-04-04 11:33:20 -04:00
1e4b6ce701 Shut up codespell 2025-04-04 11:33:20 -04:00
e642731529 Add back stream idle mode 2025-04-04 11:33:20 -04:00
1aa27bd91c Use all available CPUs to run tests on CI (#6138)
* Use all available CPUs to run tests on CI

* Use less than 100% based on research

* Ensure proper types for worker value
2025-04-04 11:11:26 -04:00
118ec28b04 [fix] Get rid of risky useEffect in restart onboarding flow (#6133)
Get rid of risky useEffect in restart onboarding flow

In certain cases this would cause an infinite loop in the web version of
the app. We should eliminate all uses of this "fire and listen with a
useEffect" antipattern in the code base, it was a mistake I introduced.
This uses the XState `waitFor` function to wait **only once** for the
transition to complete, with some error handling in case it fails.
2025-04-04 14:24:30 +00:00
449b43792b Feature: Traditional menu actions in desktop application part II (#6030)
* chore: skeleton for building and creating menus. Need electron to renderer interface to dynamically set the menu

* chore: skeleton typing for communication between nodes and web side

* chore: more skeleton for the different roles within the menu options, need more type safety

* chore: adding more skeleton and templates of what the menus could be

* chore: implemented first pass for helpRole links

* fix: syntax issue stopped the build step

* feature: loading different menus based on your page

* feature: Home page file role implemented

* chore: handling the build workflow for the signin page

* fix: moving edit actionst to the edit menu

* chore: adding preferences to the file role

* chore: redoing help roles based on the question mark widget

* fix: auto fmt

* chore: examples of accelerator strings for Menu.MenuItems keyboard shortcuts!

* chore: oddly specific toggle API for disabling MenuItems from JS. No rules!

* fix: do not implement a custom label disable thingy, use id on menu and use the native APIga

* fix: auto fmt

* fix: adding some typechecks and auto fmt fixes

* fix: trying to fix custom type?

* fix: nvm we back, the lsp on my editor borked for a second

* fix: adding one more level to the custom type for the labels

* chore: cleaning up type definitions to read easier

* fix: resolving yarn lint errors

* chore: adding file sign out

* chore: adding more file bar actions

* chore: ready for PR draft

* fix: preemptive GC collectoin bug fix if somehow a user interacts with a menu while it is being GCed

* fix: linking the OG source

* fix: set application menu to null to avoid default electron menu

* chore: trying to add more typescript

* chore: BIG workflow changes... better typing, less IPC junk

* fix: remapping the icp functions to the cb option select...

* chore: all og events are rehooked up with new workflow pattern

* feat: adding more options to the native bar!

* fix: todo

* chore: cleaning up some menus and adding more

* fix: desktop vs browser and lint errors

* fix: typescript did not like sample electorn JS code for the basic templates with isMac conditionals...

* fix: PR clean up

* fix: more PR cleanup

* A snapshot a day keeps the bugs away! 📷🐛

* fix: added the new help menu to the default sign in and modeling page

* fix: disabled two menu actions within sign in page since they will not do anything.

* A snapshot a day keeps the bugs away! 📷🐛

* A snapshot a day keeps the bugs away! 📷🐛

* fix: mergining main, auto fixes

* A snapshot a day keeps the bugs away! 📷🐛

* A snapshot a day keeps the bugs away! 📷🐛

* A snapshot a day keeps the bugs away! 📷🐛

* A snapshot a day keeps the bugs away! 📷🐛

* fix: saving off progress found an IPC on/off bug thanks electron!

* fix: fixed ipc renderer off/remove listener bug

* A snapshot a day keeps the bugs away! 📷🐛

* A snapshot a day keeps the bugs away! 📷🐛

* A snapshot a day keeps the bugs away! 📷🐛

* chore: skeleton layout for the file menu in the modeling page.

* fix: adding types

* A snapshot a day keeps the bugs away! 📷🐛

* fix: more skeleton

* feat: adding file preferences project settings

* feat: adding share current part link to file menu

* fix: report a bug to refresha and report a bug

* fix: new type for webContents send payload that does not brick TS

* fix: removing import file from url since it is not working in the command palette for manual user input

* fix: removing old comment

* chore: adding user default units

* A snapshot a day keeps the bugs away! 📷🐛

* A snapshot a day keeps the bugs away! 📷🐛

* fix: trying to create a new file but I don't think this the correct workflow...

* A snapshot a day keeps the bugs away! 📷🐛

* A snapshot a day keeps the bugs away! 📷🐛

* A snapshot a day keeps the bugs away! 📷🐛

* A snapshot a day keeps the bugs away! 📷🐛

* A snapshot a day keeps the bugs away! 📷🐛

* A snapshot a day keeps the bugs away! 📷🐛

* A snapshot a day keeps the bugs away! 📷🐛

* fix: disabling create a file and folder until we get it properly implemented at the commad bar level

* fix: hooking up more commands

* fix: auto fixes

* chore: adding standard views

* chore: adding some E2E tests.

* chore: added E2E tests for each file menu

* fix: auto fixes

* chore: adding more edit role E2E tests

* chore: e2e test

* chore: adding help role e2e test

* A snapshot a day keeps the bugs away! 📷🐛

* chore: e2e test for all the menu options you can interact with in the frontend

* chore: hooking up more menu actions

* chore: adding pane actions

* fix: mac only menu fix and added start sketch

* chore: big edit for state management and command registration

* fix: auto fixes, tsc

* fix: codespell typo

* chore: implementing E2E tests for the menus since we cleared them.

* chore: file export current part e2e test

* chore: added all file role tests in modeling page

* chore: modeling page edit e2e tests

* chore: implemented view e2e test for modeling page

* chore: add all design e2e playright tests

* fix: auto linter,fmt

* chore: added modeling help role e2e tests

* fix: ugh this function isn't available in electron evalulate

* fix: new default project name

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Pierre Jacquier <pierrejacquier39@gmail.com>
2025-04-04 08:39:02 -05:00
f1e95156ea [Bug] fix some UI friction from imports (#6139)
* fix some UI friction from imports

* add test

* console

* Jon's comments
2025-04-04 19:38:53 +11:00
45e5b25cda Use scene fixture to make test more reliable on macOS (#6140) 2025-04-04 08:35:15 +00:00
bdec611cf3 Fix: function composition during playwright setup created a massive page.reload loop (#6137)
fix: prevented the largest function composition known to man
2025-04-04 03:36:51 +00:00
fa612d5f28 Alternative way to make appMachine spawned children type safe (#5890)
Co-authored-by: Frank Noirot <frank@zoo.dev>
2025-04-04 03:25:54 +00:00
d270447777 [BUG] mutate ast to keep comments for pipe split ast-mod (#6128)
* mutate ast to keep comments

* remove commented out code

* fix case where no comments in split
2025-04-04 14:01:23 +11:00
87 changed files with 5363 additions and 1435 deletions

View File

@ -87,7 +87,7 @@ lint: install ## Lint the code
###############################################################################
# RUN
TARGET ?= web
TARGET ?= desktop
.PHONY: run
run: run-$(TARGET)
@ -103,9 +103,9 @@ run-desktop: install build-desktop ## Start the desktop app
###############################################################################
# TEST
E2E_WORKERS ?= 1
E2E_GREP ?=
E2E_WORKERS ?=
E2E_FAILURES ?= 1
E2E_GREP ?= ""
.PHONY: test
test: test-unit test-e2e
@ -121,11 +121,19 @@ test-e2e: test-e2e-$(TARGET)
.PHONY: test-e2e-web
test-e2e-web: install build-web ## Run the web e2e tests
@ curl -fs localhost:3000 >/dev/null || ( echo "Error: localhost:3000 not available, 'make run-web' first" && exit 1 )
yarn chrome:test --headed --workers=$(E2E_WORKERS) --max-failures=$(E2E_FAILURES) --grep=$(E2E_GREP)
ifdef E2E_GREP
yarn chrome:test --headed --grep="$(E2E_GREP)" --max-failures=$(E2E_FAILURES)
else
yarn chrome:test --headed --workers='100%'
endif
.PHONY: test-e2e-desktop
test-e2e-desktop: install build-desktop ## Run the desktop e2e tests
yarn test:playwright:electron --workers=$(E2E_WORKERS) --max-failures=$(E2E_FAILURES) --grep="$(E2E_GREP)"
ifdef E2E_GREP
yarn test:playwright:electron --grep="$(E2E_GREP)" --max-failures=$(E2E_FAILURES)
else
yarn test:playwright:electron --workers='100%'
endif
###############################################################################
# CLEAN

View File

@ -46,8 +46,6 @@ test.describe('Point and click for boolean workflows', () => {
localStorage.setItem('persistCode', file)
}, file)
await homePage.goToModelingScene()
await scene.waitForExecutionDone()
await scene.settled(cmdBar)
// Test coordinates for selection - these might need adjustment based on actual scene layout
@ -77,6 +75,7 @@ test.describe('Point and click for boolean workflows', () => {
// Select first object in the scene, expect there to be a pixel diff from the selection color change
await clickFirstObject({ pixelDiff: 50 })
await page.waitForTimeout(1000)
// For subtract, we need to proceed to the next step before selecting the second object
if (operationName !== 'subtract') {
@ -87,6 +86,8 @@ test.describe('Point and click for boolean workflows', () => {
// Select second object
await clickSecondObject({ pixelDiff: 50 })
await page.waitForTimeout(1000)
// Confirm the operation in the command bar
await cmdBar.progressCmdBar()

View File

@ -4,6 +4,7 @@ import { uuidv4 } from '@src/lib/utils'
import type { HomePageFixture } from '@e2e/playwright/fixtures/homePageFixture'
import type { SceneFixture } from '@e2e/playwright/fixtures/sceneFixture'
import type { ToolbarFixture } from '@e2e/playwright/fixtures/toolbarFixture'
import { getUtils } from '@e2e/playwright/test-utils'
import { expect, test } from '@e2e/playwright/zoo-test'
@ -15,6 +16,7 @@ test.describe(
page: Page,
homePage: HomePageFixture,
scene: SceneFixture,
toolbar: ToolbarFixture,
plane: string,
clickCoords: { x: number; y: number }
) => {
@ -59,9 +61,12 @@ test.describe(
await u.sendCustomCmd(updateCamCommand)
await u.closeDebugPanel()
await page.mouse.click(clickCoords.x, clickCoords.y)
await page.waitForTimeout(600) // wait for animation
await toolbar.waitUntilSketchingReady()
await expect(
page.getByRole('button', { name: 'line Line', exact: true })
).toBeVisible()
@ -117,11 +122,12 @@ test.describe(
]
for (const config of planeConfigs) {
test(config.plane, async ({ page, homePage, scene }) => {
test(config.plane, async ({ page, homePage, scene, toolbar }) => {
await sketchOnPlaneAndBackSideTest(
page,
homePage,
scene,
toolbar,
config.plane,
config.coords
)

View File

@ -15,6 +15,7 @@ test.describe('Code pane and errors', { tag: ['@skipWin'] }, () => {
page,
homePage,
scene,
cmdBar,
}) => {
const u = await getUtils(page)
@ -36,7 +37,7 @@ extrude001 = extrude(sketch001, length = 5)`
await page.setBodyDimensions({ width: 1200, height: 500 })
await homePage.goToModelingScene()
await scene.waitForExecutionDone()
await scene.settled(cmdBar)
// Ensure no badge is present
const codePaneButtonHolder = page.locator('#code-button-holder')
@ -171,6 +172,8 @@ extrude001 = extrude(sketch001, length = 5)`
context,
page,
homePage,
scene,
cmdBar,
}) => {
// Load the app with the working starter code
await context.addInitScript((code) => {
@ -180,9 +183,7 @@ extrude001 = extrude(sketch001, length = 5)`
await page.setBodyDimensions({ width: 1200, height: 500 })
await homePage.goToModelingScene()
// FIXME: await scene.waitForExecutionDone() does not work. It still fails.
// I needed to increase this timeout to get this to pass.
await page.waitForTimeout(10000)
await scene.settled(cmdBar)
// Ensure badge is present
const codePaneButtonHolder = page.locator('#code-button-holder')

View File

@ -317,9 +317,13 @@ test.describe('Command bar tests', { tag: ['@skipWin'] }, () => {
test('Can switch between sketch tools via command bar', async ({
page,
homePage,
scene,
cmdBar,
toolbar,
}) => {
await page.setBodyDimensions({ width: 1200, height: 500 })
await homePage.goToModelingScene()
await scene.settled(cmdBar)
const sketchButton = page.getByRole('button', { name: 'Start Sketch' })
const cmdBarButton = page.getByRole('button', { name: 'Commands' })
@ -343,7 +347,9 @@ test.describe('Command bar tests', { tag: ['@skipWin'] }, () => {
// Start a sketch
await sketchButton.click()
await page.mouse.click(700, 200)
await toolbar.waitUntilSketchingReady()
// Switch between sketch tools via the command bar
await expect(lineToolButton).toHaveAttribute('aria-pressed', 'true')

View File

@ -11,7 +11,7 @@ import { expect, test } from '@e2e/playwright/zoo-test'
test(
'export works on the first try',
{ tag: ['@electron', '@skipLocalEngine'] },
async ({ page, context, scene, tronApp }, testInfo) => {
async ({ page, context, scene, tronApp, cmdBar }, testInfo) => {
if (!tronApp) {
fail()
}
@ -48,10 +48,7 @@ test(
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 })
await scene.settled(cmdBar)
const gltfOption = page.getByText('glTF')
const submitButton = page.getByText('Confirm Export')
@ -124,8 +121,7 @@ test(
// Close the file pane
await u.closeFilePanel()
// FIXME: await scene.waitForExecutionDone() does not work. The modeling indicator stays in -receive-reliable and not execution done
await page.waitForTimeout(10000)
await scene.settled(cmdBar)
// expect zero errors in guter
await expect(page.locator('.cm-lint-marker-error')).not.toBeVisible()

View File

@ -78,12 +78,14 @@ sketch001 = startSketchOn(XY)
// Ensure we execute the first time.
await u.openDebugPanel()
await expect(
page.locator('[data-receive-command-type="scene_clear_all"]')
).toHaveCount(1)
await expect(
page.locator('[data-message-type="execution-done"]')
).toHaveCount(2)
await expect
.poll(() =>
page.locator('[data-receive-command-type="scene_clear_all"]').count()
)
.toBe(1)
await expect
.poll(() => page.locator('[data-message-type="execution-done"]').count())
.toBe(2)
// Add whitespace to the end of the code.
await u.codeLocator.click()
@ -110,12 +112,14 @@ sketch001 = startSketchOn(XY)
test('ensure we use the cache, and do not clear on append', async ({
homePage,
page,
scene,
cmdBar,
}) => {
const u = await getUtils(page)
await page.setBodyDimensions({ width: 1000, height: 500 })
await homePage.goToModelingScene()
await u.waitForPageLoad()
await scene.settled(cmdBar)
await u.codeLocator.click()
await page.keyboard.type(`sketch001 = startSketchOn(XY)
@ -499,7 +503,7 @@ sketch_001 = startSketchOn(XY)
await page.keyboard.press('ArrowLeft')
await page.keyboard.press('ArrowRight')
await scene.waitForExecutionDone()
await scene.connectionEstablished()
// error in guter
await expect(page.locator('.cm-lint-marker-info').first()).toBeVisible()

View File

@ -64,7 +64,7 @@ test.describe('Feature Tree pane', () => {
test(
'User can go to definition and go to function definition',
{ tag: '@electron' },
async ({ context, homePage, scene, editor, toolbar }) => {
async ({ context, homePage, scene, editor, toolbar, cmdBar, page }) => {
await context.folderSetupFn(async (dir) => {
const bracketDir = join(dir, 'test-sample')
await fsp.mkdir(bracketDir, { recursive: true })
@ -86,9 +86,13 @@ test.describe('Feature Tree pane', () => {
sortBy: 'last-modified-desc',
})
await homePage.openProject('test-sample')
await scene.waitForExecutionDone()
await editor.closePane()
await scene.connectionEstablished()
await scene.settled(cmdBar)
await toolbar.openFeatureTreePane()
await expect
.poll(() => page.getByText('Feature tree').count())
.toBeGreaterThan(1)
})
async function testViewSource({
@ -254,7 +258,7 @@ test.describe('Feature Tree pane', () => {
sortBy: 'last-modified-desc',
})
await homePage.openProject('test-sample')
await scene.waitForExecutionDone()
await scene.settled(cmdBar)
await toolbar.openFeatureTreePane()
})
@ -339,7 +343,7 @@ test.describe('Feature Tree pane', () => {
sortBy: 'last-modified-desc',
})
await homePage.openProject('test-sample')
await scene.waitForExecutionDone()
await scene.settled(cmdBar)
await toolbar.openFeatureTreePane()
})
@ -414,8 +418,7 @@ profile003 = startProfileAt([0, -4.93], sketch001)
const planeColor: [number, number, number] = [74, 74, 74]
await homePage.openProject('test-sample')
// FIXME: @lf94 has a better way to verify execution completion, in a PR rn
await scene.waitForExecutionDone()
await scene.settled(cmdBar)
await test.step(`Verify we see the sketch`, async () => {
await scene.expectPixelColor(sketchColor, testPoint, 10)

View File

@ -47,6 +47,7 @@ test.describe('integrations tests', () => {
await scene.connectionEstablished()
await scene.settled(cmdBar)
await clickObj()
await page.waitForTimeout(1000)
await scene.moveNoWhere()
await editor.expectState({
activeLines: [
@ -72,11 +73,11 @@ test.describe('integrations tests', () => {
})
await test.step('setup for next assertion', async () => {
await toolbar.openFile('main.kcl')
await scene.settled(cmdBar)
await page.waitForTimeout(1000)
await clickObj()
await page.waitForTimeout(1000)
await scene.moveNoWhere()
await page.waitForTimeout(1000)
await editor.expectState({
activeLines: [
'|>startProfileAt([75.8,317.2],%)//[$startCapTag,$EndCapTag]',
@ -89,7 +90,7 @@ test.describe('integrations tests', () => {
await toolbar.expectFileTreeState(['main.kcl', fileName])
})
await test.step('check sketch mode is exited when opening a different file', async () => {
await toolbar.openFile(fileName, { wait: false })
await toolbar.openFile(fileName)
// check we're out of sketch mode
await expect(toolbar.exitSketchBtn).not.toBeVisible()

View File

@ -112,22 +112,16 @@ export class CmdBarFixture {
* and assumes we are past the `pickCommand` step.
*/
progressCmdBar = async (shouldFuzzProgressMethod = true) => {
// FIXME: Progressing the command bar is a race condition. We have an async useEffect that reports the final state via useCalculateKclExpression. If this does not run quickly enough, it will not "fail" the continue because you can press continue if the state is not ready. E2E tests do not know this.
// Wait 1250ms to assume the await executeAst of the KCL input field is finished
await this.page.waitForTimeout(1250)
if (shouldFuzzProgressMethod || Math.random() > 0.5) {
const arrowButton = this.page.getByRole('button', {
name: 'arrow right Continue',
})
if (await arrowButton.isVisible()) {
await arrowButton.click()
} else {
await this.page
.getByRole('button', { name: 'checkmark Submit command' })
.click()
}
await this.page.waitForTimeout(2000)
const arrowButton = this.page.getByRole('button', {
name: 'arrow right Continue',
})
if (await arrowButton.isVisible()) {
await arrowButton.click()
} else {
await this.page.keyboard.press('Enter')
await this.page
.getByRole('button', { name: 'checkmark Submit command' })
.click()
}
}

View File

@ -39,7 +39,8 @@ export class AuthenticatedApp {
}
async initialise(code = '') {
await setup(this.context, this.page, this.testInfo)
const testDir = this.testInfo.outputPath('electron-test-projects-dir')
await setup(this.context, this.page, testDir, this.testInfo)
const u = await getUtils(this.page)
await this.page.addInitScript(async (code) => {
@ -102,11 +103,11 @@ export class ElectronZoo {
return resolve(undefined)
}
if (Date.now() - timeA > 10000) {
if (Date.now() - timeA > 3000) {
return resolve(undefined)
}
setTimeout(checkDisconnected, 0)
setTimeout(checkDisconnected, 1)
}
checkDisconnected()
})
@ -128,11 +129,9 @@ export class ElectronZoo {
const that = this
const options = {
timeout: 120000,
args: ['.', '--no-sandbox'],
env: {
...process.env,
TEST_SETTINGS_FILE_KEY: this.projectDirName,
IS_PLAYWRIGHT: 'true',
},
...(process.env.ELECTRON_OVERRIDE_DIST_PATH
@ -155,6 +154,7 @@ export class ElectronZoo {
if (!this.electron) {
this.electron = await electron.launch(options)
this.page = await this.electron.firstWindow()
// Mac takes quite a long time to create the first window in CI.
// Turns out we can't trust firstWindow() either. So loop.
let timeoutId: ReturnType<typeof setTimeout>
@ -177,11 +177,37 @@ export class ElectronZoo {
this.context = this.electron.context()
await this.context.tracing.start({ screenshots: true, snapshots: true })
// We need to patch this because addInitScript will bind too late in our
// electron tests, never running. We need to call reload() after each call
// to guarantee it runs.
const oldContextAddInitScript = this.context.addInitScript
this.context.addInitScript = async function (a, b) {
// @ts-ignore pretty sure way out of tsc's type checking capabilities.
// This code works perfectly fine.
await oldContextAddInitScript.apply(this, [a, b])
await that.page.reload()
}
const oldPageAddInitScript = this.page.addInitScript
this.page.addInitScript = async function (a: any, b: any) {
// @ts-ignore pretty sure way out of tsc's type checking capabilities.
// This code works perfectly fine.
await oldPageAddInitScript.apply(this, [a, b])
await that.page.reload()
}
}
await this.context.tracing.startChunk()
await setup(this.context, this.page, testInfo)
// THIS IS ABSOLUTELY NECESSARY TO CHANGE THE PROJECT DIRECTORY BETWEEN
// TESTS BECAUSE OF THE ELECTRON INSTANCE REUSE.
await this.electron?.evaluate(({ app }, projectDirName) => {
// @ts-ignore can't declaration merge see main.ts
app.testProperty['TEST_SETTINGS_FILE_KEY'] = projectDirName
}, this.projectDirName)
await setup(this.context, this.page, this.projectDirName, testInfo)
await this.cleanProjectDir()
@ -219,26 +245,6 @@ export class ElectronZoo {
}))
}
// We need to patch this because addInitScript will bind too late in our
// electron tests, never running. We need to call reload() after each call
// to guarantee it runs.
const oldContextAddInitScript = this.context.addInitScript
this.context.addInitScript = async function (a, b) {
// @ts-ignore pretty sure way out of tsc's type checking capabilities.
// This code works perfectly fine.
await oldContextAddInitScript.apply(this, [a, b])
await that.page.reload()
}
// No idea why we mix and match page and context's addInitScript but we do
const oldPageAddInitScript = this.page.addInitScript
this.page.addInitScript = async function (a: any, b: any) {
// @ts-ignore pretty sure way out of tsc's type checking capabilities.
// This code works perfectly fine.
await oldPageAddInitScript.apply(this, [a, b])
await that.page.reload()
}
if (!this.firstUrl) {
await this.page.getByText('Your Projects').count()
this.firstUrl = this.page.url()
@ -251,11 +257,6 @@ export class ElectronZoo {
// return app.reuseWindowForTest();
// });
await this.electron?.evaluate(({ app }, projectDirName) => {
// @ts-ignore can't declaration merge see main.ts
app.testProperty['TEST_SETTINGS_FILE_KEY'] = projectDirName
}, this.projectDirName)
// Always start at the root view
await this.page.goto(this.firstUrl)
@ -279,8 +280,9 @@ export class ElectronZoo {
// Not a problem if it already exists.
}
const tempSettingsFilePath = path.join(
const tempSettingsFilePath = path.resolve(
this.projectDirName,
'..',
SETTINGS_FILE_NAME
)

View File

@ -43,21 +43,13 @@ type DragFromHandler = (
export class SceneFixture {
public page: Page
public streamWrapper!: Locator
public loadingIndicator!: Locator
public networkToggleConnected!: Locator
public startEditSketchBtn!: Locator
get exeIndicator() {
return this.page
.getByTestId('model-state-indicator-execution-done')
.or(this.page.getByTestId('model-state-indicator-receive-reliable'))
}
constructor(page: Page) {
this.page = page
this.streamWrapper = page.getByTestId('stream')
this.networkToggleConnected = page.getByTestId('network-toggle-ok')
this.loadingIndicator = this.streamWrapper.getByTestId('loading')
this.startEditSketchBtn = page
.getByRole('button', { name: 'Start Sketch' })
.or(page.getByRole('button', { name: 'Edit Sketch' }))
@ -231,10 +223,6 @@ export class SceneFixture {
}
}
waitForExecutionDone = async () => {
await expect(this.exeIndicator).toBeVisible({ timeout: 30000 })
}
connectionEstablished = async () => {
const timeout = 30000
await expect(this.networkToggleConnected).toBeVisible({ timeout })
@ -243,6 +231,9 @@ export class SceneFixture {
settled = async (cmdBar: CmdBarFixture) => {
const u = await getUtils(this.page)
await expect(this.startEditSketchBtn).not.toBeDisabled()
await expect(this.startEditSketchBtn).toBeVisible()
await cmdBar.openCmdBar()
await cmdBar.chooseCommand('Settings · app · show debug panel')
await cmdBar.selectOption({ name: 'on' }).click()
@ -250,10 +241,6 @@ export class SceneFixture {
await u.openDebugPanel()
await u.expectCmdLog('[data-message-type="execution-done"]')
await u.closeDebugPanel()
await this.waitForExecutionDone()
await expect(this.startEditSketchBtn).not.toBeDisabled()
await expect(this.startEditSketchBtn).toBeVisible()
}
expectPixelColor = async (

View File

@ -84,12 +84,6 @@ export class ToolbarFixture {
return this.page.getByTestId('app-logo')
}
get exeIndicator() {
return this.page
.getByTestId('model-state-indicator-receive-reliable')
.or(this.page.getByTestId('model-state-indicator-execution-done'))
}
startSketchPlaneSelection = async () =>
doAndWaitForImageDiff(this.page, () => this.startSketchBtn.click(), 500)
@ -165,16 +159,10 @@ export class ToolbarFixture {
}
}
/**
* Opens file by it's name and waits for execution to finish
* Opens file by it's name
*/
openFile = async (
fileName: string,
{ wait }: { wait?: boolean } = { wait: true }
) => {
openFile = async (fileName: string) => {
await this.filePane.getByText(fileName).click()
if (wait) {
await expect(this.exeIndicator).toBeVisible({ timeout: 15_000 })
}
}
selectCenterRectangle = async () => {
await this.page

View File

@ -0,0 +1,121 @@
import { expect, test } from '@e2e/playwright/zoo-test'
import * as fsp from 'fs/promises'
import path from 'path'
test.describe('Import UI tests', () => {
test('shows toast when trying to sketch on imported face', async ({
context,
page,
homePage,
toolbar,
scene,
editor,
cmdBar,
}) => {
await context.folderSetupFn(async (dir) => {
const projectDir = path.join(dir, 'import-test')
await fsp.mkdir(projectDir, { recursive: true })
// Create the imported file
await fsp.writeFile(
path.join(projectDir, 'toBeImported.kcl'),
`sketch001 = startSketchOn(XZ)
profile001 = startProfileAt([281.54, 305.81], sketch001)
|> angledLine([0, 123.43], %, $rectangleSegmentA001)
|> angledLine([
segAng(rectangleSegmentA001) - 90,
85.99
], %)
|> angledLine([
segAng(rectangleSegmentA001),
-segLen(rectangleSegmentA001)
], %)
|> line(endAbsolute = [profileStartX(%), profileStartY(%)])
|> close()
extrude(profile001, length = 100)`
)
// Create the main file that imports
await fsp.writeFile(
path.join(projectDir, 'main.kcl'),
`import "toBeImported.kcl" as importedCube
importedCube
sketch001 = startSketchOn(XZ)
profile001 = startProfileAt([-134.53, -56.17], sketch001)
|> angledLine([0, 79.05], %, $rectangleSegmentA001)
|> angledLine([
segAng(rectangleSegmentA001) - 90,
76.28
], %)
|> angledLine([
segAng(rectangleSegmentA001),
-segLen(rectangleSegmentA001)
], %, $seg01)
|> line(endAbsolute = [profileStartX(%), profileStartY(%)], tag = $seg02)
|> close()
extrude001 = extrude(profile001, length = 100)
sketch003 = startSketchOn(extrude001, seg02)
sketch002 = startSketchOn(extrude001, seg01)`
)
})
await homePage.openProject('import-test')
await scene.settled(cmdBar)
await scene.moveCameraTo(
{
x: -114,
y: -897,
z: 475,
},
{
x: -114,
y: -51,
z: 83,
}
)
const [_, hoverOverNonImport] = scene.makeMouseHelpers(611, 364)
const [importedFaceClick, hoverOverImported] = scene.makeMouseHelpers(
940,
150
)
await test.step('check code highlight works for code define in the file being edited', async () => {
await hoverOverNonImport()
await editor.expectState({
highlightedCode: 'startProfileAt([-134.53,-56.17],sketch001)',
diagnostics: [],
activeLines: ['import"toBeImported.kcl"asimportedCube'],
})
})
await test.step('check code does nothing when geometry is defined in an import', async () => {
await hoverOverImported()
await editor.expectState({
highlightedCode: '',
diagnostics: [],
activeLines: ['import"toBeImported.kcl"asimportedCube'],
})
})
await test.step('check the user is warned when sketching on a imported face', async () => {
// Start sketch mode
await toolbar.startSketchPlaneSelection()
// Click on a face from the imported model
// await new Promise(() => {})
await importedFaceClick()
// Verify toast appears with correct content
await expect(page.getByText('This face is from an import')).toBeVisible()
await expect(
page.locator('.font-mono').getByText('toBeImported.kcl')
).toBeVisible()
await expect(
page.getByText('Please select this from the files pane to edit')
).toBeVisible()
})
})
})

View File

@ -0,0 +1,7 @@
export const throwError = (message: string): never => {
throw new Error(message)
}
export const throwTronAppMissing = () => {
throwError('tronApp is missing')
}

View File

@ -7,7 +7,7 @@ import { expect, test } from '@e2e/playwright/zoo-test'
test(
'When machine-api server not found butt is disabled and shows the reason',
{ tag: '@electron' },
async ({ context, page }, testInfo) => {
async ({ context, page, scene, cmdBar }, testInfo) => {
await context.folderSetupFn(async (dir) => {
const bracketDir = join(dir, 'bracket')
await fsp.mkdir(bracketDir, { recursive: true })
@ -23,10 +23,7 @@ test(
await page.getByText('bracket').click()
await expect(page.getByTestId('loading')).toBeAttached()
await expect(page.getByTestId('loading')).not.toBeAttached({
timeout: 20_000,
})
await scene.settled(cmdBar)
const notFoundText = 'Machine API server was not discovered'
await expect(page.getByText(notFoundText).first()).not.toBeVisible()
@ -47,7 +44,7 @@ test(
test(
'When machine-api server not found home screen & project status shows the reason',
{ tag: '@electron' },
async ({ context, page }, testInfo) => {
async ({ context, page, scene, cmdBar }, testInfo) => {
await context.folderSetupFn(async (dir) => {
const bracketDir = join(dir, 'bracket')
await fsp.mkdir(bracketDir, { recursive: true })
@ -71,10 +68,7 @@ test(
await page.getByText('bracket').click()
await expect(page.getByTestId('loading')).toBeAttached()
await expect(page.getByTestId('loading')).not.toBeAttached({
timeout: 20_000,
})
await scene.settled(cmdBar)
await expect(page.getByText(notFoundText).nth(1)).not.toBeVisible()

View File

@ -88,7 +88,7 @@ test.describe('Named view tests', () => {
// Create and load project
await createProject({ name: projectName, page })
await scene.waitForExecutionDone()
await scene.settled(cmdBar)
// Create named view
const projectDirName = testInfo.outputPath('electron-test-projects-dir')
@ -110,14 +110,17 @@ test.describe('Named view tests', () => {
expect(exists).toBe(true)
}).toPass()
// Read project.toml into memory
let tomlString = await fsp.readFile(tempProjectSettingsFilePath, 'utf-8')
// Rewrite the uuids in the named views to match snapshot otherwise they will be randomly generated from rust and break
tomlString = tomlStringOverWriteNamedViewUuids(tomlString)
await expect(async () => {
// Read project.toml into memory
let tomlString = await fsp.readFile(tempProjectSettingsFilePath, 'utf-8')
// Write the entire tomlString to a snapshot.
// There are many key/value pairs to check this is a safer match.
expect(tomlString).toMatchSnapshot('verify-named-view-gets-created')
// Rewrite the uuids in the named views to match snapshot otherwise they will be randomly generated from rust and break
tomlString = tomlStringOverWriteNamedViewUuids(tomlString)
// Write the entire tomlString to a snapshot.
// There are many key/value pairs to check this is a safer match.
expect(tomlString).toMatchSnapshot('verify-named-view-gets-created')
}).toPass()
})
test('Verify named view gets deleted', async ({
cmdBar,
@ -130,7 +133,7 @@ test.describe('Named view tests', () => {
// Create project and go into the project
await createProject({ name: projectName, page })
await scene.waitForExecutionDone()
await scene.settled(cmdBar)
// Create a new named view
await cmdBar.openCmdBar()
@ -152,14 +155,16 @@ test.describe('Named view tests', () => {
expect(exists).toBe(true)
}).toPass()
// Read project.toml into memory
let tomlString = await fsp.readFile(tempProjectSettingsFilePath, 'utf-8')
// Rewrite the uuids in the named views to match snapshot otherwise they will be randomly generated from rust and break
tomlString = tomlStringOverWriteNamedViewUuids(tomlString)
await expect(async () => {
// Read project.toml into memory
let tomlString = await fsp.readFile(tempProjectSettingsFilePath, 'utf-8')
// Rewrite the uuids in the named views to match snapshot otherwise they will be randomly generated from rust and break
tomlString = tomlStringOverWriteNamedViewUuids(tomlString)
// Write the entire tomlString to a snapshot.
// There are many key/value pairs to check this is a safer match.
expect(tomlString).toMatchSnapshot('verify-named-view-gets-created')
// Write the entire tomlString to a snapshot.
// There are many key/value pairs to check this is a safer match.
expect(tomlString).toMatchSnapshot('verify-named-view-gets-created')
}).toPass()
// Delete a named view
await cmdBar.openCmdBar()
@ -167,14 +172,16 @@ test.describe('Named view tests', () => {
cmdBar.selectOption({ name: myNamedView2 })
await cmdBar.progressCmdBar(false)
// Read project.toml into memory again since we deleted a named view
tomlString = await fsp.readFile(tempProjectSettingsFilePath, 'utf-8')
// Rewrite the uuids in the named views to match snapshot otherwise they will be randomly generated from rust and break
tomlString = tomlStringOverWriteNamedViewUuids(tomlString)
await expect(async () => {
// Read project.toml into memory again since we deleted a named view
let tomlString = await fsp.readFile(tempProjectSettingsFilePath, 'utf-8')
// Rewrite the uuids in the named views to match snapshot otherwise they will be randomly generated from rust and break
tomlString = tomlStringOverWriteNamedViewUuids(tomlString)
// // Write the entire tomlString to a snapshot.
// // There are many key/value pairs to check this is a safer match.
expect(tomlString).toMatchSnapshot('verify-named-view-gets-deleted')
// // Write the entire tomlString to a snapshot.
// // There are many key/value pairs to check this is a safer match.
expect(tomlString).toMatchSnapshot('verify-named-view-gets-deleted')
}).toPass()
})
test('Verify named view gets loaded', async ({
cmdBar,
@ -186,7 +193,7 @@ test.describe('Named view tests', () => {
// Create project and go into the project
await createProject({ name: projectName, page })
await scene.waitForExecutionDone()
await scene.settled(cmdBar)
// Create a new named view
await cmdBar.openCmdBar()
@ -208,14 +215,16 @@ test.describe('Named view tests', () => {
expect(exists).toBe(true)
}).toPass()
// Read project.toml into memory
let tomlString = await fsp.readFile(tempProjectSettingsFilePath, 'utf-8')
// Rewrite the uuids in the named views to match snapshot otherwise they will be randomly generated from rust and break
tomlString = tomlStringOverWriteNamedViewUuids(tomlString)
await expect(async () => {
// Read project.toml into memory
let tomlString = await fsp.readFile(tempProjectSettingsFilePath, 'utf-8')
// Rewrite the uuids in the named views to match snapshot otherwise they will be randomly generated from rust and break
tomlString = tomlStringOverWriteNamedViewUuids(tomlString)
// Write the entire tomlString to a snapshot.
// There are many key/value pairs to check this is a safer match.
expect(tomlString).toMatchSnapshot('verify-named-view-gets-created')
// Write the entire tomlString to a snapshot.
// There are many key/value pairs to check this is a safer match.
expect(tomlString).toMatchSnapshot('verify-named-view-gets-created')
}).toPass()
// Create a load a named view
await cmdBar.openCmdBar()
@ -239,7 +248,7 @@ test.describe('Named view tests', () => {
// Create and load project
await createProject({ name: projectName, page })
await scene.waitForExecutionDone()
await scene.settled(cmdBar)
// Create named view
const projectDirName = testInfo.outputPath('electron-test-projects-dir')
@ -282,13 +291,15 @@ test.describe('Named view tests', () => {
expect(exists).toBe(true)
}).toPass()
// Read project.toml into memory
let tomlString = await fsp.readFile(tempProjectSettingsFilePath, 'utf-8')
// Rewrite the uuids in the named views to match snapshot otherwise they will be randomly generated from rust and break
tomlString = tomlStringOverWriteNamedViewUuids(tomlString)
await expect(async () => {
// Read project.toml into memory
let tomlString = await fsp.readFile(tempProjectSettingsFilePath, 'utf-8')
// Rewrite the uuids in the named views to match snapshot otherwise they will be randomly generated from rust and break
tomlString = tomlStringOverWriteNamedViewUuids(tomlString)
// Write the entire tomlString to a snapshot.
// There are many key/value pairs to check this is a safer match.
expect(tomlString).toMatchSnapshot('verify-two-named-view-gets-created')
// Write the entire tomlString to a snapshot.
// There are many key/value pairs to check this is a safer match.
expect(tomlString).toMatchSnapshot('verify-two-named-view-gets-created')
}).toPass()
})
})

File diff suppressed because it is too large Load Diff

View File

@ -5,13 +5,14 @@ import path from 'node:path'
import type { EditorFixture } from '@e2e/playwright/fixtures/editorFixture'
import type { SceneFixture } from '@e2e/playwright/fixtures/sceneFixture'
import type { ToolbarFixture } from '@e2e/playwright/fixtures/toolbarFixture'
import { getUtils, orRunWhenFullSuiteEnabled } from '@e2e/playwright/test-utils'
import { orRunWhenFullSuiteEnabled } from '@e2e/playwright/test-utils'
import { expect, test } from '@e2e/playwright/zoo-test'
// test file is for testing point an click code gen functionality that's not sketch mode related
test.describe('Point-and-click tests', () => {
test('verify extruding circle works', async ({
page,
context,
homePage,
cmdBar,
@ -30,8 +31,9 @@ test.describe('Point-and-click tests', () => {
await context.addInitScript((file) => {
localStorage.setItem('persistCode', file)
}, file)
await homePage.goToModelingScene()
await scene.waitForExecutionDone()
await scene.connectionEstablished()
const [clickCircle, moveToCircle] = scene.makeMouseHelpers(582, 217)
@ -72,7 +74,6 @@ test.describe('Point-and-click tests', () => {
await test.step('do extrude flow and check extrude code is added to editor', async () => {
await toolbar.extrudeButton.click()
await cmdBar.expectState({
stage: 'arguments',
currentArgKey: 'distance',
@ -186,6 +187,7 @@ test.describe('Point-and-click tests', () => {
editor,
toolbar,
scene,
cmdBar,
}) => {
const file = await fs.readFile(
path.resolve(
@ -200,9 +202,7 @@ test.describe('Point-and-click tests', () => {
}, file)
await page.setBodyDimensions({ width: 1000, height: 500 })
await homePage.goToModelingScene()
await expect(
page.getByTestId('model-state-indicator-receive-reliable')
).toBeVisible()
await scene.settled(cmdBar)
const sketchOnAChamfer = _sketchOnAChamfer(page, editor, toolbar, scene)
@ -377,6 +377,7 @@ profile001 = startProfileAt([205.96, 254.59], sketch002)
editor,
toolbar,
scene,
cmdBar,
}) => {
const file = await fs.readFile(
path.resolve(
@ -392,7 +393,7 @@ profile001 = startProfileAt([205.96, 254.59], sketch002)
await page.setBodyDimensions({ width: 1000, height: 500 })
await homePage.goToModelingScene()
await scene.waitForExecutionDone()
await scene.settled(cmdBar)
const sketchOnAChamfer = _sketchOnAChamfer(page, editor, toolbar, scene)
@ -479,6 +480,7 @@ profile001 = startProfileAt([205.96, 254.59], sketch002)
await page.setBodyDimensions(viewPortSize)
await homePage.goToModelingScene()
await scene.connectionEstablished()
// Constants and locators
// These are mappings from screenspace to KCL coordinates,
@ -537,8 +539,7 @@ profile001 = startProfileAt([205.96, 254.59], sketch002)
await toolbar.startSketchPlaneSelection()
await moveToXzPlane()
await clickOnXzPlane()
// timeout wait for engine animation is unavoidable
await page.waitForTimeout(600)
await toolbar.waitUntilSketchingReady()
await editor.expectEditor.toContain(expectedCodeSnippets.sketchOnXzPlane)
})
await test.step(`Place a point a few pixels off the middle, verify it still snaps to 0,0`, async () => {
@ -580,9 +581,8 @@ profile001 = startProfileAt([205.96, 254.59], sketch002)
editor,
toolbar,
scene,
cmdBar,
}) => {
const u = await getUtils(page)
const initialCode = `closedSketch = startSketchOn(XZ)
|> circle(center = [8, 5], radius = 2)
openSketch = startSketchOn(XY)
@ -599,8 +599,6 @@ openSketch = startSketchOn(XY)
}, initialCode)
await homePage.goToModelingScene()
await u.waitForPageLoad()
await page.waitForTimeout(1000)
const pointInsideCircle = {
x: viewPortSize.width * 0.63,
@ -625,15 +623,16 @@ openSketch = startSketchOn(XY)
const exitSketch = async () => {
await test.step(`Exit sketch mode`, async () => {
await toolbar.exitSketchBtn.click()
await expect(toolbar.exitSketchBtn).not.toBeVisible()
await expect(toolbar.startSketchBtn).toBeEnabled()
})
}
await test.step(`Double-click on the closed sketch`, async () => {
await scene.settled(cmdBar)
await moveToCircle()
await page.waitForTimeout(1000)
await dblClickCircle()
await expect(toolbar.startSketchBtn).not.toBeVisible()
await page.waitForTimeout(1000)
await expect(toolbar.exitSketchBtn).toBeVisible()
await editor.expectState({
activeLines: [`|>circle(center=[8,5],radius=2)`],
@ -670,7 +669,6 @@ openSketch = startSketchOn(XY)
// There is a full execution after exiting sketch that clears the scene.
await page.waitForTimeout(500)
await dblClickOpenPath()
await expect(toolbar.startSketchBtn).not.toBeVisible()
await expect(toolbar.exitSketchBtn).toBeVisible()
// Wait for enter sketch mode to complete
await page.waitForTimeout(500)
@ -1031,6 +1029,9 @@ openSketch = startSketchOn(XY)
})
await test.step(`Go through the command bar flow`, async () => {
await toolbar.offsetPlaneButton.click()
await expect
.poll(() => page.getByText('Please select one').count())
.toBe(1)
await cmdBar.expectState({
stage: 'arguments',
currentArgKey: 'plane',
@ -1088,6 +1089,7 @@ openSketch = startSketchOn(XY)
const expectedLine = `axis=X,`
await homePage.goToModelingScene()
await scene.connectionEstablished()
await test.step(`Go through the command bar flow`, async () => {
await toolbar.helixButton.click()
@ -1106,6 +1108,7 @@ openSketch = startSketchOn(XY)
commandName: 'Helix',
})
await cmdBar.progressCmdBar()
await expect.poll(() => page.getByText('Axis').count()).toBe(6)
await cmdBar.progressCmdBar()
await cmdBar.progressCmdBar()
await cmdBar.progressCmdBar()
@ -1233,6 +1236,7 @@ openSketch = startSketchOn(XY)
}, initialCode)
await page.setBodyDimensions({ width: 1000, height: 500 })
await homePage.goToModelingScene()
await scene.settled(cmdBar)
await test.step(`Go through the command bar flow`, async () => {
await toolbar.closePane('code')
@ -1252,15 +1256,22 @@ openSketch = startSketchOn(XY)
commandName: 'Helix',
})
await cmdBar.selectOption({ name: 'Edge' }).click()
await expect
.poll(() => page.getByText('Please select one').count())
.toBe(1)
await clickOnEdge()
await page.waitForTimeout(1000)
await cmdBar.progressCmdBar()
await page.waitForTimeout(1000)
await cmdBar.argumentInput.focus()
await page.waitForTimeout(1000)
await page.keyboard.insertText('20')
await cmdBar.progressCmdBar()
await page.keyboard.insertText('0')
await cmdBar.progressCmdBar()
await page.keyboard.insertText('1')
await cmdBar.progressCmdBar()
await page.keyboard.insertText('100')
await cmdBar.expectState({
stage: 'review',
headerArguments: {
@ -1274,6 +1285,7 @@ openSketch = startSketchOn(XY)
commandName: 'Helix',
})
await cmdBar.progressCmdBar()
await page.waitForTimeout(1000)
})
await test.step(`Confirm code is added to the editor, scene has changed`, async () => {
@ -1369,7 +1381,7 @@ extrude001 = extrude(profile001, length = 100)
}, initialCode)
await page.setBodyDimensions({ width: 1000, height: 500 })
await homePage.goToModelingScene()
await scene.waitForExecutionDone()
await scene.settled(cmdBar)
// One dumb hardcoded screen pixel value
const testPoint = { x: 620, y: 257 }
@ -1530,6 +1542,9 @@ extrude001 = extrude(profile001, length = 100)
if (!shouldPreselect) {
await test.step(`Go through the command bar flow without preselected sketches`, async () => {
await toolbar.loftButton.click()
await expect
.poll(() => page.getByText('Please select one').count())
.toBe(1)
await cmdBar.expectState({
stage: 'arguments',
currentArgKey: 'selection',
@ -1579,6 +1594,7 @@ extrude001 = extrude(profile001, length = 100)
page,
homePage,
scene,
cmdBar,
}) => {
const initialCode = `sketch001 = startSketchOn(XZ)
|> circle(center = [0, 0], radius = 30)
@ -1592,7 +1608,7 @@ loft001 = loft([sketch001, sketch002])
}, initialCode)
await page.setBodyDimensions({ width: 1000, height: 500 })
await homePage.goToModelingScene()
await scene.waitForExecutionDone()
await scene.settled(cmdBar)
// One dumb hardcoded screen pixel value
const testPoint = { x: 575, y: 200 }
@ -1687,7 +1703,7 @@ sketch002 = startSketchOn(XZ)
}, initialCode)
await page.setBodyDimensions({ width: 1000, height: 500 })
await homePage.goToModelingScene()
await scene.waitForExecutionDone()
await scene.settled(cmdBar)
// One dumb hardcoded screen pixel value
const [clickOnSketch1] = scene.makeMouseHelpers(testPoint.x, testPoint.y)
@ -1707,6 +1723,9 @@ sketch002 = startSketchOn(XZ)
await test.step(`Go through the command bar flow`, async () => {
await toolbar.sweepButton.click()
await expect
.poll(() => page.getByText('Please select one').count())
.toBe(1)
await cmdBar.expectState({
commandName: 'Sweep',
currentArgKey: 'target',
@ -1826,7 +1845,7 @@ sketch002 = startSketchOn(XZ)
}, initialCode)
await page.setBodyDimensions({ width: 1000, height: 500 })
await homePage.goToModelingScene()
await scene.waitForExecutionDone()
await scene.settled(cmdBar)
// One dumb hardcoded screen pixel value
const testPoint = { x: 700, y: 250 }
@ -1843,6 +1862,9 @@ sketch002 = startSketchOn(XZ)
await test.step(`Go through the command bar flow and fail validation with a toast`, async () => {
await toolbar.sweepButton.click()
await expect
.poll(() => page.getByText('Please select one').count())
.toBe(1)
await cmdBar.expectState({
commandName: 'Sweep',
currentArgKey: 'target',
@ -2059,6 +2081,9 @@ extrude001 = extrude(sketch001, length = -12)
await test.step(`Open fillet UI without selecting edges`, async () => {
await page.waitForTimeout(100)
await toolbar.filletButton.click()
await expect
.poll(() => page.getByText('Please select one').count())
.toBe(1)
await cmdBar.expectState({
stage: 'arguments',
currentArgKey: 'selection',
@ -2184,6 +2209,7 @@ extrude001 = extrude(sketch001, length = -12)
homePage,
scene,
toolbar,
cmdBar,
}) => {
const initialCode = `sketch001 = startSketchOn(XY)
profile001 = circle(
@ -2200,7 +2226,7 @@ fillet001 = fillet(extrude001, radius = 5, tags = [getOppositeEdge(seg01)])
}, initialCode)
await page.setBodyDimensions({ width: 1000, height: 500 })
await homePage.goToModelingScene()
await scene.waitForExecutionDone()
await scene.settled(cmdBar)
await test.step('Double-click in feature tree and expect error toast', async () => {
await toolbar.openPane('feature-tree')
@ -2521,7 +2547,7 @@ extrude001 = extrude(sketch001, length = -12)
}, initialCode)
await page.setBodyDimensions({ width: 1000, height: 500 })
await homePage.goToModelingScene()
await scene.waitForExecutionDone()
await scene.settled(cmdBar)
})
// Test 1: Command bar flow with preselected edges
@ -2554,6 +2580,7 @@ extrude001 = extrude(sketch001, length = -12)
stage: 'arguments',
})
await cmdBar.progressCmdBar()
await page.waitForTimeout(1000)
await cmdBar.expectState({
commandName: 'Chamfer',
highlightedHeaderArg: 'length',
@ -2565,7 +2592,10 @@ extrude001 = extrude(sketch001, length = -12)
},
stage: 'arguments',
})
await cmdBar.argumentInput.focus()
await page.waitForTimeout(1000)
await cmdBar.progressCmdBar()
await page.waitForTimeout(1000)
await cmdBar.expectState({
commandName: 'Chamfer',
headerArguments: {
@ -2649,6 +2679,9 @@ extrude001 = extrude(sketch001, length = -12)
await test.step(`Open chamfer UI without selecting edges`, async () => {
await page.waitForTimeout(100)
await toolbar.chamferButton.click()
await expect
.poll(() => page.getByText('Please select one').count())
.toBe(1)
await cmdBar.expectState({
stage: 'arguments',
currentArgKey: 'selection',
@ -2771,6 +2804,7 @@ extrude001 = extrude(sketch001, length = -12)
scene,
editor,
toolbar,
cmdBar,
}) => {
// Code samples
const initialCode = `@settings(defaultLengthUnit = in)
@ -2814,7 +2848,7 @@ chamfer04 = chamfer(extrude001, length = 5, tags = [getOppositeEdge(seg02)])
}, initialCode)
await page.setBodyDimensions({ width: 1000, height: 500 })
await homePage.goToModelingScene()
await scene.waitForExecutionDone()
await scene.settled(cmdBar)
// verify modeling scene is loaded
await scene.expectPixelColor(
@ -2936,9 +2970,11 @@ extrude001 = extrude(sketch001, length = 30)
await context.addInitScript((initialCode) => {
localStorage.setItem('persistCode', initialCode)
}, initialCode)
await page.setBodyDimensions({ width: 1000, height: 500 })
await homePage.goToModelingScene()
await scene.waitForExecutionDone()
await scene.connectionEstablished()
// One dumb hardcoded screen pixel value
const testPoint = { x: 575, y: 200 }
@ -2955,6 +2991,9 @@ extrude001 = extrude(sketch001, length = 30)
if (!shouldPreselect) {
await test.step(`Go through the command bar flow without preselected faces`, async () => {
await toolbar.shellButton.click()
await expect
.poll(() => page.getByText('Please select one').count())
.toBe(1)
await cmdBar.expectState({
stage: 'arguments',
currentArgKey: 'selection',
@ -3015,7 +3054,6 @@ extrude001 = extrude(sketch001, length = 30)
})
await test.step('Edit shell via feature tree selection works', async () => {
await toolbar.closePane('code')
await toolbar.openPane('feature-tree')
const operationButton = await toolbar.getFeatureTreeOperation(
'Shell',
@ -3044,7 +3082,6 @@ extrude001 = extrude(sketch001, length = 30)
await cmdBar.progressCmdBar()
await toolbar.closePane('feature-tree')
await scene.expectPixelColor([150, 150, 150], testPoint, 15)
await toolbar.openPane('code')
await editor.expectEditor.toContain(editedShellDeclaration)
await editor.expectState({
diagnostics: [],
@ -3079,7 +3116,7 @@ extrude001 = extrude(sketch001, length = 40)
}, initialCode)
await page.setBodyDimensions({ width: 1000, height: 500 })
await homePage.goToModelingScene()
await scene.waitForExecutionDone()
await scene.settled(cmdBar)
// One dumb hardcoded screen pixel value
const testPoint = { x: 580, y: 180 }
@ -3097,6 +3134,9 @@ extrude001 = extrude(sketch001, length = 40)
await test.step(`Go through the command bar flow, selecting a wall and keeping default thickness`, async () => {
await toolbar.shellButton.click()
await expect
.poll(() => page.getByText('Please select one').count())
.toBe(1)
await cmdBar.expectState({
stage: 'arguments',
currentArgKey: 'selection',
@ -3108,6 +3148,9 @@ extrude001 = extrude(sketch001, length = 40)
highlightedHeaderArg: 'selection',
commandName: 'Shell',
})
await expect
.poll(() => page.getByText('Please select one').count())
.toBe(1)
await clickOnCap()
await page.keyboard.down('Shift')
await clickOnWall()
@ -3116,6 +3159,7 @@ extrude001 = extrude(sketch001, length = 40)
await cmdBar.progressCmdBar()
await page.waitForTimeout(500)
await cmdBar.progressCmdBar()
await page.waitForTimeout(500)
await cmdBar.expectState({
stage: 'review',
headerArguments: {
@ -3124,7 +3168,9 @@ extrude001 = extrude(sketch001, length = 40)
},
commandName: 'Shell',
})
await page.waitForTimeout(500)
await cmdBar.progressCmdBar()
await page.waitForTimeout(500)
})
await test.step(`Confirm code is added to the editor, scene has changed`, async () => {
@ -3139,7 +3185,6 @@ extrude001 = extrude(sketch001, length = 40)
})
await test.step('Edit shell via feature tree selection works', async () => {
await editor.closePane()
const operationButton = await toolbar.getFeatureTreeOperation('Shell', 0)
await operationButton.dblclick({ button: 'left' })
await cmdBar.expectState({
@ -3154,6 +3199,7 @@ extrude001 = extrude(sketch001, length = 40)
})
await page.keyboard.insertText('1')
await cmdBar.progressCmdBar()
await page.waitForTimeout(500)
await cmdBar.expectState({
stage: 'review',
headerArguments: {
@ -3164,7 +3210,6 @@ extrude001 = extrude(sketch001, length = 40)
await cmdBar.progressCmdBar()
await toolbar.closePane('feature-tree')
await scene.expectPixelColor([150, 150, 150], testPoint, 15)
await toolbar.openPane('code')
await editor.expectEditor.toContain(editedShellDeclaration)
await editor.expectState({
diagnostics: [],
@ -3218,7 +3263,7 @@ extrude002 = extrude(sketch002, length = 50)
}, initialCode)
await page.setBodyDimensions({ width: 1200, height: 500 })
await homePage.goToModelingScene()
await scene.waitForExecutionDone()
await scene.settled(cmdBar)
// One dumb hardcoded screen pixel value
const testPoint = { x: 580, y: 320 }
@ -3243,12 +3288,13 @@ extrude002 = extrude(sketch002, length = 50)
highlightedHeaderArg: 'selection',
commandName: 'Shell',
})
await expect
.poll(() => page.getByText('Please select one').count())
.toBe(1)
await clickOnCap()
await page.waitForTimeout(500)
await page.waitForTimeout(1000)
await cmdBar.progressCmdBar()
await page.waitForTimeout(500)
await cmdBar.progressCmdBar()
await page.waitForTimeout(500)
await cmdBar.expectState({
stage: 'review',
headerArguments: {
@ -3306,7 +3352,7 @@ profile001 = startProfileAt([-20, 20], sketch001)
}, initialCode)
await page.setBodyDimensions({ width: 1000, height: 500 })
await homePage.goToModelingScene()
await scene.waitForExecutionDone()
await scene.settled(cmdBar)
await toolbar.openPane('feature-tree')
// One dumb hardcoded screen pixel value
@ -3386,7 +3432,7 @@ sweep001 = sweep(sketch001, path = sketch002)
}, initialCode)
await page.setBodyDimensions({ width: 1000, height: 500 })
await homePage.goToModelingScene()
await scene.waitForExecutionDone()
await scene.settled(cmdBar)
// One dumb hardcoded screen pixel value
const testPoint = { x: 500, y: 250 }
@ -3399,6 +3445,9 @@ sweep001 = sweep(sketch001, path = sketch002)
await test.step(`Go through the Shell flow and fail validation with a toast`, async () => {
await toolbar.shellButton.click()
await expect
.poll(() => page.getByText('Please select one').count())
.toBe(1)
await cmdBar.expectState({
stage: 'arguments',
currentArgKey: 'selection',
@ -3462,12 +3511,13 @@ segAng(rectangleSegmentA002),
}, initialCode)
await page.setBodyDimensions({ width: 1000, height: 500 })
await homePage.goToModelingScene()
await scene.waitForExecutionDone()
await scene.settled(cmdBar)
// select line of code
const codeToSelecton = `segAng(rectangleSegmentA002) - 90,`
const codeToSelection = `segAng(rectangleSegmentA002) - 90,`
// revolve
await page.getByText(codeToSelecton).click()
await editor.scrollToText(codeToSelection)
await page.getByText(codeToSelection).click()
await toolbar.revolveButton.click()
await cmdBar.progressCmdBar()
await cmdBar.progressCmdBar()
@ -3541,15 +3591,17 @@ sketch002 = startSketchOn(extrude001, rectangleSegmentA001)
}, initialCode)
await page.setBodyDimensions({ width: 1000, height: 500 })
await homePage.goToModelingScene()
await scene.waitForExecutionDone()
await scene.connectionEstablished()
await scene.settled(cmdBar)
// select line of code
const codeToSelecton = `center = [-11.34, 10.0]`
const codeToSelection = `center = [-11.34, 10.0]`
// revolve
await page.getByText(codeToSelecton).click()
await editor.scrollToText(codeToSelection)
await page.getByText(codeToSelection).click()
await toolbar.revolveButton.click()
await page.getByText('Edge', { exact: true }).click()
const lineCodeToSelection = `|> angledLine([0, 202.6], %, $rectangleSegmentA001)`
const lineCodeToSelection = `angledLine([0, 202.6], %, $rectangleSegmentA001)`
await page.getByText(lineCodeToSelection).click()
await cmdBar.progressCmdBar()
await cmdBar.progressCmdBar()
@ -3595,6 +3647,7 @@ sketch002 = startSketchOn(extrude001, rectangleSegmentA001)
await editor.expectEditor.toContain(
newCodeToFind.replace('angle = 360', 'angle = angle001')
)
expect(editor.expectEditor.toContain(newCodeToFind)).toBeTruthy()
})
test('revolve sketch circle around line segment from startProfileAt sketch', async ({
context,
@ -3628,15 +3681,20 @@ sketch003 = startSketchOn(extrude001, 'START')
}, initialCode)
await page.setBodyDimensions({ width: 1000, height: 500 })
await homePage.goToModelingScene()
await scene.waitForExecutionDone()
await scene.connectionEstablished()
await scene.settled(cmdBar)
// select line of code
const codeToSelecton = `center = [-0.69, 0.56]`
const codeToSelection = `center = [-0.69, 0.56]`
// revolve
await page.getByText(codeToSelecton).click()
await toolbar.revolveButton.click()
await page.waitForTimeout(1000)
await editor.scrollToText(codeToSelection)
await page.getByText(codeToSelection).click()
await expect.poll(() => page.getByText('AxisOrEdge').count()).toBe(2)
await page.getByText('Edge', { exact: true }).click()
const lineCodeToSelection = `|> xLine(length = 2.6)`
const lineCodeToSelection = `length = 2.6`
await editor.scrollToText(lineCodeToSelection)
await page.getByText(lineCodeToSelection).click()
await cmdBar.progressCmdBar()
await cmdBar.progressCmdBar()
@ -3703,7 +3761,7 @@ extrude001 = extrude(profile001, length = 100)
}, initialCode)
await page.setBodyDimensions({ width: 1000, height: 500 })
await homePage.goToModelingScene()
await scene.waitForExecutionDone()
await scene.settled(cmdBar)
// One dumb hardcoded screen pixel value
const testPoint = { x: 500, y: 250 }

View File

@ -83,7 +83,7 @@ test(
test(
'click help/keybindings from project page',
{ tag: '@electron' },
async ({ context, page }, testInfo) => {
async ({ scene, cmdBar, context, page }, testInfo) => {
await context.folderSetupFn(async (dir) => {
const bracketDir = path.join(dir, 'bracket')
await fsp.mkdir(bracketDir, { recursive: true })
@ -95,17 +95,11 @@ test(
await page.setBodyDimensions({ width: 1200, height: 500 })
page.on('console', console.log)
// expect to see the text bracket
await expect(page.getByText('bracket')).toBeVisible()
await page.getByText('bracket').click()
await expect(page.getByTestId('loading')).toBeAttached()
await expect(page.getByTestId('loading')).not.toBeAttached({
timeout: 20_000,
})
await scene.settled(cmdBar)
// click ? button
await page.getByTestId('help-button').click()
@ -120,7 +114,7 @@ test(
test(
'open a file in a project works and renders, open another file in different project with errors, it should clear the scene',
{ tag: '@electron' },
async ({ context, page, editor }, testInfo) => {
async ({ scene, cmdBar, context, page, editor }, testInfo) => {
await context.folderSetupFn(async (dir) => {
const bracketDir = path.join(dir, 'bracket')
await fsp.mkdir(bracketDir, { recursive: true })
@ -149,24 +143,7 @@ test(
await page.getByText('bracket').click()
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, [110, 110, 110]), {
timeout: 10_000,
})
.toBeLessThan(20)
await scene.settled(cmdBar)
})
await test.step('Clicking the logo takes us back to the projects page / home', async () => {
@ -209,7 +186,7 @@ test(
test(
'open a file in a project works and renders, open another file in different project that is empty, it should clear the scene',
{ tag: '@electron' },
async ({ context, page }, testInfo) => {
async ({ scene, cmdBar, context, page }, testInfo) => {
await context.folderSetupFn(async (dir) => {
const bracketDir = path.join(dir, 'bracket')
await fsp.mkdir(bracketDir, { recursive: true })
@ -235,24 +212,7 @@ test(
await page.getByText('bracket').click()
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, [125, 125, 125]), {
timeout: 10_000,
})
.toBeLessThan(15)
await scene.settled(cmdBar)
})
await test.step('Clicking the logo takes us back to the projects page / home', async () => {
@ -352,7 +312,7 @@ test(
test(
'open a file in a project works and renders, open another file in the same project with errors, it should clear the scene',
{ tag: '@electron' },
async ({ context, page }, testInfo) => {
async ({ scene, cmdBar, context, page }, testInfo) => {
if (runningOnWindows()) {
test.fixme(orRunWhenFullSuiteEnabled())
}
@ -380,10 +340,7 @@ test(
await page.getByText('bracket').click()
await expect(page.getByTestId('loading')).toBeAttached()
await expect(page.getByTestId('loading')).not.toBeAttached({
timeout: 20_000,
})
await scene.settled(cmdBar)
await expect(
page.getByRole('button', { name: 'Start Sketch' })
@ -443,10 +400,10 @@ test(
await expect(page.getByText('broken-code')).toBeVisible()
await page.getByText('broken-code').click()
// Gotcha: You can not use scene.waitForExecutionDone() since the KCL code is going to fail
await expect(page.getByTestId('loading')).not.toBeAttached({
timeout: 20_000,
})
// Gotcha: You can not use scene.settled() since the KCL code is going to fail
await expect(
page.getByTestId('model-state-indicator-playing')
).toBeAttached()
// Gotcha: Scroll to the text content in code mirror because CodeMirror lazy loads DOM content
await editor.scrollToText(
@ -469,7 +426,7 @@ test.describe('Can export from electron app', () => {
test(
`Can export using ${method}`,
{ tag: ['@electron', '@skipLocalEngine'] },
async ({ context, page, tronApp }, testInfo) => {
async ({ scene, cmdBar, context, page, tronApp }, testInfo) => {
if (!tronApp) {
fail()
}
@ -499,10 +456,7 @@ test.describe('Can export from electron app', () => {
await page.getByText('bracket').click()
await expect(page.getByTestId('loading')).toBeAttached()
await expect(page.getByTestId('loading')).not.toBeAttached({
timeout: 20_000,
})
await scene.settled(cmdBar)
await expect(
page.getByRole('button', { name: 'Start Sketch' })
@ -812,7 +766,7 @@ test.describe(`Project management commands`, () => {
test(
`Rename from project page`,
{ tag: '@electron' },
async ({ context, page }, testInfo) => {
async ({ context, page, scene, cmdBar }, testInfo) => {
const projectName = `my_project_to_rename`
await context.folderSetupFn(async (dir) => {
await fsp.mkdir(`${dir}/${projectName}`, { recursive: true })
@ -821,7 +775,6 @@ test.describe(`Project management commands`, () => {
`${dir}/${projectName}/main.kcl`
)
})
const u = await getUtils(page)
// Constants and locators
const projectHomeLink = page.getByTestId('project-link')
@ -843,7 +796,7 @@ test.describe(`Project management commands`, () => {
page.on('console', console.log)
await projectHomeLink.click()
await u.waitForPageLoad()
await scene.settled(cmdBar)
})
await test.step(`Run rename command via command palette`, async () => {
@ -882,7 +835,6 @@ test.describe(`Project management commands`, () => {
`${dir}/${projectName}/main.kcl`
)
})
const u = await getUtils(page)
// Constants and locators
const projectHomeLink = page.getByTestId('project-link')
@ -900,9 +852,9 @@ test.describe(`Project management commands`, () => {
await page.setBodyDimensions({ width: 1200, height: 500 })
page.on('console', console.log)
await page.waitForTimeout(3000)
await projectHomeLink.click()
await u.waitForPageLoad()
await scene.connectionEstablished()
await scene.settled(cmdBar)
})
@ -926,7 +878,7 @@ test.describe(`Project management commands`, () => {
test(
`Rename from home page`,
{ tag: '@electron' },
async ({ context, page, homePage }, testInfo) => {
async ({ context, page, homePage, scene, cmdBar }, testInfo) => {
const projectName = `my_project_to_rename`
await context.folderSetupFn(async (dir) => {
await fsp.mkdir(`${dir}/${projectName}`, { recursive: true })
@ -982,7 +934,7 @@ test.describe(`Project management commands`, () => {
test(
`Delete from home page`,
{ tag: '@electron' },
async ({ context, page }, testInfo) => {
async ({ context, page, scene, cmdBar }, testInfo) => {
const projectName = `my_project_to_delete`
await context.folderSetupFn(async (dir) => {
await fsp.mkdir(`${dir}/${projectName}`, { recursive: true })
@ -1033,6 +985,7 @@ test.describe(`Project management commands`, () => {
homePage,
toolbar,
cmdBar,
scene,
}) => {
const projectName = 'test-project'
await test.step(`Setup`, async () => {
@ -1072,10 +1025,11 @@ test.describe(`Project management commands`, () => {
})
await cmdBar.argumentInput.fill(projectName)
await cmdBar.progressCmdBar()
await scene.settled(cmdBar)
await toolbar.logoLink.click()
})
await test.step(`Check the project was created with a non-colliding name`, async () => {
await toolbar.logoLink.click()
await homePage.expectState({
projectCards: [
{
@ -1106,10 +1060,11 @@ test.describe(`Project management commands`, () => {
})
await cmdBar.argumentInput.fill(projectName)
await cmdBar.progressCmdBar()
await scene.settled(cmdBar)
await toolbar.logoLink.click()
})
await test.step(`Check the second project was created with a non-colliding name`, async () => {
await toolbar.logoLink.click()
await homePage.expectState({
projectCards: [
{
@ -1195,7 +1150,7 @@ test(
test(
'Nested directories in project without main.kcl do not create main.kcl',
{ tag: '@electron' },
async ({ context, page }, testInfo) => {
async ({ scene, cmdBar, context, page }, testInfo) => {
let testDir: string | undefined
await context.folderSetupFn(async (dir) => {
await fsp.mkdir(path.join(dir, 'router-template-slate', 'nested'), {
@ -1218,10 +1173,7 @@ test(
await test.step('Open the project', async () => {
await page.getByText('router-template-slate').click()
await expect(page.getByTestId('loading')).toBeAttached()
await expect(page.getByTestId('loading')).not.toBeAttached({
timeout: 20_000,
})
await scene.settled(cmdBar)
// It actually loads.
await expect(u.codeLocator).toContainText('mounting bracket')
@ -1334,7 +1286,7 @@ test(
test(
'Can load a file with CRLF line endings',
{ tag: '@electron' },
async ({ context, page }, testInfo) => {
async ({ context, page, scene, cmdBar }, testInfo) => {
if (runningOnWindows()) {
test.fixme(orRunWhenFullSuiteEnabled())
}
@ -1357,13 +1309,8 @@ test(
const u = await getUtils(page)
await page.setBodyDimensions({ width: 1200, height: 500 })
page.on('console', console.log)
await page.getByText('router-template-slate').click()
await expect(page.getByTestId('loading')).toBeAttached()
await expect(page.getByTestId('loading')).not.toBeAttached({
timeout: 20_000,
})
await scene.settled(cmdBar)
await expect(u.codeLocator).toContainText('routerDiameter')
await expect(u.codeLocator).toContainText('templateGap')
@ -1578,7 +1525,7 @@ extrude001 = extrude(sketch001, length = 200)`)
test(
'Opening a project should successfully load the stream, (regression test that this also works when switching between projects)',
{ tag: '@electron' },
async ({ context, page, cmdBar, homePage }, testInfo) => {
async ({ context, page, cmdBar, homePage, scene }, testInfo) => {
await context.folderSetupFn(async (dir) => {
await fsp.mkdir(path.join(dir, 'router-template-slate'), {
recursive: true,
@ -1607,13 +1554,10 @@ test(
path.join(dir, 'bracket', 'main.kcl')
)
})
const u = await getUtils(page)
await page.setBodyDimensions({ width: 1200, height: 500 })
page.on('console', console.log)
const pointOnModel = { x: 630, y: 280 }
await test.step('Opening the bracket project via command palette should load the stream', async () => {
await homePage.expectState({
projectCards: [
@ -1647,15 +1591,7 @@ test(
stage: 'commandBarClosed',
})
await u.waitForPageLoad()
// 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]), {
timeout: 10_000,
})
.toBeLessThan(15)
await scene.settled(cmdBar)
})
await test.step('Clicking the logo takes us back to the projects page / home', async () => {
@ -1672,15 +1608,7 @@ test(
await page.getByText('router-template-slate').click()
await u.waitForPageLoad()
// 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]), {
timeout: 10_000,
})
.toBeLessThan(15)
await scene.settled(cmdBar)
})
await test.step('The projects on the home page should still be normal', async () => {
@ -1733,8 +1661,6 @@ test(
})
await page.setBodyDimensions({ width: 1200, height: 500 })
page.on('console', console.log)
// we'll grab this from the settings on screen before we switch
let originalProjectDirName: string
const newProjectDirName = testInfo.outputPath(
@ -1875,7 +1801,7 @@ test(
test(
'file pane is scrollable when there are many files',
{ tag: '@electron' },
async ({ context, page }, testInfo) => {
async ({ scene, cmdBar, context, page }, testInfo) => {
await context.folderSetupFn(async (dir) => {
const testDir = path.join(dir, 'testProject')
await fsp.mkdir(testDir, { recursive: true })
@ -1954,10 +1880,8 @@ test(
await test.step('setup, open file pane', async () => {
await page.getByText('testProject').click()
await expect(page.getByTestId('loading')).toBeAttached()
await expect(page.getByTestId('loading')).not.toBeAttached({
timeout: 20_000,
})
await scene.settled(cmdBar)
await page.getByTestId('files-pane-button').click()
})

View File

@ -63,7 +63,7 @@ test.describe('edit with AI example snapshots', () => {
localStorage.setItem('persistCode', file)
}, file)
await homePage.goToModelingScene()
await scene.waitForExecutionDone()
await scene.settled(cmdBar)
const body1CapCoords = { x: 571, y: 351 }
const [clickBody1Cap] = scene.makeMouseHelpers(

View File

@ -61,7 +61,7 @@ test.describe('Prompt-to-edit tests', { tag: '@skipWin' }, () => {
localStorage.setItem('persistCode', file)
}, file)
await homePage.goToModelingScene()
await scene.waitForExecutionDone()
await scene.settled(cmdBar)
const body1CapCoords = { x: 571, y: 311 }
const greenCheckCoords = { x: 565, y: 305 }
@ -156,7 +156,7 @@ test.describe('Prompt-to-edit tests', { tag: '@skipWin' }, () => {
localStorage.setItem('persistCode', file)
}, file)
await homePage.goToModelingScene()
await scene.waitForExecutionDone()
await scene.settled(cmdBar)
const body1CapCoords = { x: 571, y: 311 }
const [clickBody1Cap] = scene.makeMouseHelpers(
@ -212,7 +212,7 @@ test.describe('Prompt-to-edit tests', { tag: '@skipWin' }, () => {
localStorage.setItem('persistCode', file)
}, file)
await homePage.goToModelingScene()
await scene.waitForExecutionDone()
await scene.settled(cmdBar)
const submittingToast = page.getByText('Submitting to Text-to-CAD API...')
const successToast = page.getByText('Prompt to edit successful')
@ -281,7 +281,7 @@ test.describe('Prompt-to-edit tests', { tag: '@skipWin' }, () => {
localStorage.setItem('persistCode', file)
}, file)
await homePage.goToModelingScene()
await scene.waitForExecutionDone()
await scene.settled(cmdBar)
const submittingToast = page.getByText('Submitting to Text-to-CAD API...')
const successToast = page.getByText('Prompt to edit successful')

View File

@ -689,6 +689,7 @@ extrude002 = extrude(profile002, length = 150)
homePage,
scene,
toolbar,
viewport,
}) => {
await context.folderSetupFn(async (dir) => {
const legoDir = path.join(dir, 'lego')
@ -703,8 +704,8 @@ extrude002 = extrude(profile002, length = 150)
await homePage.openProject('lego')
await toolbar.closePane('code')
})
await test.step(`Waiting for the loading spinner to disappear`, async () => {
await scene.loadingIndicator.waitFor({ state: 'detached' })
await test.step(`Waiting for scene to settle`, async () => {
await scene.connectionEstablished()
})
await test.step(`The part should start loading quickly, not waiting until execution is complete`, async () => {
// TODO: use the viewport size to pick the center point, but the `viewport` fixture's values were wrong.
@ -762,7 +763,7 @@ plane002 = offsetPlane(XZ, offset = -2 * x)`
)
})
await homePage.openProject('test-sample')
await scene.waitForExecutionDone()
await scene.settled(cmdBar)
await expect(toolbar.startSketchBtn).toBeEnabled({ timeout: 20_000 })
const operationButton = await toolbar.getFeatureTreeOperation(
'Offset Plane',

View File

@ -22,6 +22,7 @@ test.describe('Sketch tests', { tag: ['@skipWin'] }, () => {
context,
homePage,
scene,
cmdBar,
}) => {
const u = await getUtils(page)
const selectionsSnippets = {
@ -82,7 +83,7 @@ test.describe('Sketch tests', { tag: ['@skipWin'] }, () => {
await page.setBodyDimensions({ width: 1200, height: 500 })
await homePage.goToModelingScene()
await scene.waitForExecutionDone()
await scene.settled(cmdBar)
// wait for execution done
await u.openDebugPanel()
@ -108,6 +109,7 @@ test.describe('Sketch tests', { tag: ['@skipWin'] }, () => {
page,
scene,
homePage,
cmdBar,
}) => {
const u = await getUtils(page)
await page.addInitScript(async () => {
@ -122,7 +124,7 @@ sketch001 = startSketchOn(XZ)
})
await homePage.goToModelingScene()
await scene.waitForExecutionDone()
await scene.settled(cmdBar)
await scene.expectPixelColor(TEST_COLORS.WHITE, { x: 587, y: 270 }, 15)
@ -673,6 +675,7 @@ sketch001 = startSketchOn(XZ)
homePage,
scene,
editor,
cmdBar,
}) => {
const u = await getUtils(page)
await page.addInitScript(async () => {
@ -689,7 +692,7 @@ sketch001 = startSketchOn(XZ)
})
await homePage.goToModelingScene()
await scene.waitForExecutionDone()
await scene.settled(cmdBar)
await expect(
page.getByRole('button', { name: 'Start Sketch' })
@ -1614,7 +1617,7 @@ profile002 = startProfileAt([117.2, 56.08], sketch001)
test(
`snapToProfile start only works for current profile`,
{ tag: ['@skipWin'] },
async ({ context, page, scene, toolbar, editor, homePage }) => {
async ({ context, page, scene, toolbar, editor, homePage, cmdBar }) => {
// We seed the scene with a single offset plane
await context.addInitScript(() => {
localStorage.setItem(
@ -1630,6 +1633,8 @@ profile003 = startProfileAt([206.63, -56.73], sketch001)
})
await homePage.goToModelingScene()
await scene.settled(cmdBar)
await expect(
page.getByRole('button', { name: 'Start Sketch' })
).not.toBeDisabled()
@ -1651,9 +1656,13 @@ profile003 = startProfileAt([206.63, -56.73], sketch001)
const codeFromTangentialArc = ` |> tangentialArcTo([39.49, 88.22], %)`
await test.step('check that tangential tool does not snap to other profile starts', async () => {
await toolbar.tangentialArcBtn.click()
await page.waitForTimeout(1000)
await endOfLowerSegMove()
await page.waitForTimeout(1000)
await endOfLowerSegClick()
await page.waitForTimeout(1000)
await profileStartOfHigherSegClick()
await page.waitForTimeout(1000)
await editor.expectEditor.toContain(codeFromTangentialArc)
await editor.expectEditor.not.toContain(
`[profileStartX(%), profileStartY(%)]`
@ -2242,8 +2251,9 @@ profile004 = circleThreePoint(sketch001, p1 = [13.44, -6.8], p2 = [13.39, -2.07]
await test.step('enter sketch and setup', async () => {
await moveToClearToolBarPopover()
await page.waitForTimeout(1000)
await pointOnSegment({ shouldDbClick: true })
await page.waitForTimeout(600)
await page.waitForTimeout(2000)
await toolbar.lineBtn.click()
await page.waitForTimeout(100)
@ -2359,7 +2369,7 @@ profile003 = circle(sketch001, center = [6.92, -4.2], radius = 3.16)
await page.setBodyDimensions({ width: 1000, height: 500 })
await homePage.goToModelingScene()
await scene.waitForExecutionDone()
await scene.settled(cmdBar)
await expect(
page.getByRole('button', { name: 'Start Sketch' })
).not.toBeDisabled()
@ -2965,6 +2975,7 @@ test.describe(`Click based selection don't brick the app when clicked out of ran
toolbar,
editor,
homePage,
cmdBar,
}) => {
// We seed the scene with a single offset plane
await context.addInitScript(() => {
@ -2982,7 +2993,7 @@ test.describe(`Click based selection don't brick the app when clicked out of ran
})
await homePage.goToModelingScene()
await scene.waitForExecutionDone()
await scene.settled(cmdBar)
await test.step(`format the code`, async () => {
// doesn't contain condensed version
@ -3047,6 +3058,7 @@ test.describe('Redirecting to home page and back to the original file should cle
toolbar,
editor,
homePage,
cmdBar,
}) => {
// We seed the scene with a single offset plane
await context.addInitScript(() => {
@ -3059,7 +3071,7 @@ test.describe('Redirecting to home page and back to the original file should cle
)
})
await homePage.goToModelingScene()
await scene.waitForExecutionDone()
await scene.settled(cmdBar)
const [objClick] = scene.makeMouseHelpers(634, 274)
await objClick()

View File

@ -103,7 +103,6 @@ part001 = startSketchOn(-XZ)
await u.waitForAuthSkipAppStart()
await scene.connectionEstablished()
await scene.settled(cmdBar)
const axisDirectionPair: Models['AxisDirectionPair_type'] = {
@ -369,7 +368,6 @@ const extrudeDefaultPlane = async (
await page.setViewportSize({ width: 1200, height: 500 })
await u.waitForAuthSkipAppStart()
await scene.connectionEstablished()
await scene.settled(cmdBar)
await expect(page).toHaveScreenshot({
@ -421,8 +419,6 @@ test(
const PUR = 400 / 37.5 //pixeltoUnitRatio
await u.waitForAuthSkipAppStart()
await scene.connectionEstablished()
const startXPx = 600
const [endOfTangentClk, endOfTangentMv] = scene.makeMouseHelpers(
startXPx + PUR * 30,
@ -551,8 +547,6 @@ test(
await u.waitForAuthSkipAppStart()
await scene.connectionEstablished()
// click on "Start Sketch" button
await u.doAndWaitForImageDiff(
() => page.getByRole('button', { name: 'Start Sketch' }).click(),
@ -598,8 +592,6 @@ test(
await u.waitForAuthSkipAppStart()
await scene.connectionEstablished()
await u.doAndWaitForImageDiff(
() => page.getByRole('button', { name: 'Start Sketch' }).click(),
200
@ -650,8 +642,6 @@ test.describe(
await u.waitForAuthSkipAppStart()
await scene.connectionEstablished()
await u.doAndWaitForImageDiff(
() => page.getByRole('button', { name: 'Start Sketch' }).click(),
200
@ -744,7 +734,6 @@ test.describe(
await u.waitForAuthSkipAppStart()
await scene.connectionEstablished()
await scene.settled(cmdBar)
await u.doAndWaitForImageDiff(
@ -846,7 +835,6 @@ part002 = startSketchOn(part001, seg01)
await u.waitForAuthSkipAppStart()
await scene.connectionEstablished()
await scene.settled(cmdBar)
// Wait for the second extrusion to appear
@ -902,7 +890,6 @@ test(
await u.waitForAuthSkipAppStart()
await scene.connectionEstablished()
await scene.settled(cmdBar)
// Wait for the second extrusion to appear
@ -943,7 +930,6 @@ test(
await u.waitForAuthSkipAppStart()
await scene.connectionEstablished()
await scene.settled(cmdBar)
// Wait for the second extrusion to appear
@ -976,7 +962,6 @@ test.describe('Grid visibility', { tag: '@snapshot' }, () => {
await page.goto('/')
await u.waitForAuthSkipAppStart()
await scene.connectionEstablished()
await scene.settled(cmdBar)
await u.closeKclCodePanel()
@ -1041,7 +1026,6 @@ test.describe('Grid visibility', { tag: '@snapshot' }, () => {
await page.goto('/')
await u.waitForAuthSkipAppStart()
await scene.connectionEstablished()
await scene.settled(cmdBar)
await u.closeKclCodePanel()
@ -1086,7 +1070,6 @@ test.describe('Grid visibility', { tag: '@snapshot' }, () => {
await page.goto('/')
await u.waitForAuthSkipAppStart()
await scene.connectionEstablished()
await scene.settled(cmdBar)
await u.closeKclCodePanel()
@ -1205,7 +1188,6 @@ sweepSketch = startSketchOn(XY)
await page.setViewportSize({ width: 1200, height: 1000 })
await u.waitForAuthSkipAppStart()
await scene.connectionEstablished()
await scene.settled(cmdBar)
await expect(page, 'expect small color widget').toHaveScreenshot({
@ -1255,7 +1237,6 @@ sweepSketch = startSketchOn(XY)
await page.setViewportSize({ width: 1200, height: 1000 })
await u.waitForAuthSkipAppStart()
await scene.connectionEstablished()
await scene.settled(cmdBar)
await expect(page.locator('.cm-css-color-picker-wrapper')).toBeVisible()

View File

@ -89,7 +89,7 @@ test.describe('Test network and connection issues', () => {
test(
'Engine disconnect & reconnect in sketch mode',
{ tag: '@skipLocalEngine' },
async ({ page, homePage, toolbar }) => {
async ({ page, homePage, toolbar, scene, cmdBar }) => {
test.fixme(orRunWhenFullSuiteEnabled())
const networkToggle = page.getByTestId('network-toggle')
@ -169,7 +169,7 @@ test.describe('Test network and connection issues', () => {
// Expect the network to be up
await expect(networkToggle).toContainText('Connected')
await expect(page.getByTestId('loading-stream')).not.toBeAttached()
await scene.settled(cmdBar)
// Click off the code pane.
await page.mouse.click(100, 100)

View File

@ -74,7 +74,10 @@ async function waitForPageLoadWithRetry(page: Page) {
await expect(async () => {
await page.goto('/')
const errorMessage = 'App failed to load - 🔃 Retrying ...'
await expect(page.getByTestId('loading'), errorMessage).not.toBeAttached({
await expect(
page.getByTestId('model-state-indicator-playing'),
errorMessage
).toBeAttached({
timeout: 20_000,
})
@ -87,9 +90,10 @@ async function waitForPageLoadWithRetry(page: Page) {
}).toPass({ timeout: 70_000, intervals: [1_000] })
}
// lee: This needs to be replaced by scene.settled() eventually.
async function waitForPageLoad(page: Page) {
// wait for all spinners to be gone
await expect(page.getByTestId('loading')).not.toBeAttached({
await expect(page.getByTestId('model-state-indicator-playing')).toBeVisible({
timeout: 20_000,
})
@ -871,9 +875,10 @@ export async function tearDown(page: Page, testInfo: TestInfo) {
export async function setup(
context: BrowserContext,
page: Page,
testDir: string,
testInfo?: TestInfo
) {
await context.addInitScript(
await page.addInitScript(
async ({
token,
settingsKey,
@ -914,7 +919,7 @@ export async function setup(
},
}),
IS_PLAYWRIGHT_KEY,
PLAYWRIGHT_TEST_DIR: TEST_SETTINGS.project?.directory || '',
PLAYWRIGHT_TEST_DIR: testDir,
PERSIST_MODELING_CONTEXT,
}
)
@ -934,7 +939,7 @@ export async function setup(
await page.emulateMedia({ reducedMotion: 'reduce' })
// Trigger a navigation, since loading file:// doesn't.
// await page.reload()
await page.reload()
}
function failOnConsoleErrors(page: Page, testInfo?: TestInfo) {

View File

@ -5,12 +5,18 @@ import { getUtils, orRunWhenFullSuiteEnabled } from '@e2e/playwright/test-utils'
import { expect, test } from '@e2e/playwright/zoo-test'
test.describe('Testing Camera Movement', { tag: ['@skipWin'] }, () => {
test('Can move camera reliably', async ({ page, context, homePage }) => {
test('Can move camera reliably', async ({
page,
context,
homePage,
scene,
}) => {
const u = await getUtils(page)
await page.setBodyDimensions({ width: 1200, height: 500 })
await homePage.goToModelingScene()
await u.waitForPageLoad()
await scene.connectionEstablished()
await u.openAndClearDebugPanel()
await u.closeKclCodePanel()

View File

@ -77,7 +77,7 @@ test.describe('Testing constraints', { tag: ['@skipWin'] }, () => {
})
.toBe(false)
})
test(`Remove constraints`, async ({ page, homePage }) => {
test(`Remove constraints`, async ({ page, homePage, scene, cmdBar }) => {
await page.addInitScript(async () => {
localStorage.setItem(
'persistCode',
@ -101,7 +101,7 @@ test.describe('Testing constraints', { tag: ['@skipWin'] }, () => {
await page.setBodyDimensions({ width: 1000, height: 500 })
await homePage.goToModelingScene()
await u.waitForPageLoad()
await scene.settled(cmdBar)
await page.getByText('line(end = [74.36, 130.4], tag = $seg01)').click()
await page.getByRole('button', { name: 'Edit Sketch' }).click()
@ -142,7 +142,7 @@ test.describe('Testing constraints', { tag: ['@skipWin'] }, () => {
},
] as const
for (const { testName, offset } of cases) {
test(`${testName}`, async ({ page, homePage }) => {
test(`${testName}`, async ({ page, homePage, scene, cmdBar }) => {
await page.addInitScript(async () => {
localStorage.setItem(
'persistCode',
@ -166,7 +166,7 @@ test.describe('Testing constraints', { tag: ['@skipWin'] }, () => {
await page.setBodyDimensions({ width: 1200, height: 500 })
await homePage.goToModelingScene()
await u.waitForPageLoad()
await scene.settled(cmdBar)
await page.getByText('line(end = [74.36, 130.4], tag = $seg01)').click()
await page.getByRole('button', { name: 'Edit Sketch' }).click()
@ -250,7 +250,12 @@ test.describe('Testing constraints', { tag: ['@skipWin'] }, () => {
},
] as const
for (const { testName, value, constraint } of cases) {
test(`${constraint} - ${testName}`, async ({ page, homePage }) => {
test(`${constraint} - ${testName}`, async ({
page,
homePage,
scene,
cmdBar,
}) => {
await page.addInitScript(async () => {
localStorage.setItem(
'persistCode',
@ -274,7 +279,7 @@ test.describe('Testing constraints', { tag: ['@skipWin'] }, () => {
await page.setBodyDimensions({ width: 1000, height: 500 })
await homePage.goToModelingScene()
await u.waitForPageLoad()
await scene.settled(cmdBar)
await page.getByText('line(end = [74.36, 130.4])').click()
await page.getByRole('button', { name: 'Edit Sketch' }).click()
@ -361,7 +366,12 @@ test.describe('Testing constraints', { tag: ['@skipWin'] }, () => {
},
] as const
for (const { testName, addVariable, value, constraint } of cases) {
test(`${constraint} - ${testName}`, async ({ page, homePage }) => {
test(`${constraint} - ${testName}`, async ({
page,
homePage,
scene,
cmdBar,
}) => {
await page.addInitScript(async () => {
localStorage.setItem(
'persistCode',
@ -385,7 +395,7 @@ test.describe('Testing constraints', { tag: ['@skipWin'] }, () => {
await page.setBodyDimensions({ width: 1200, height: 500 })
await homePage.goToModelingScene()
await u.waitForPageLoad()
await scene.settled(cmdBar)
await page.getByText('line(end = [74.36, 130.4])').click()
await page.getByRole('button', { name: 'Edit Sketch' }).click()
@ -475,7 +485,7 @@ test.describe('Testing constraints', { tag: ['@skipWin'] }, () => {
},
] as const
for (const { testName, addVariable, value, axisSelect } of cases) {
test(`${testName}`, async ({ page, homePage }) => {
test(`${testName}`, async ({ page, homePage, scene, cmdBar }) => {
await page.addInitScript(async () => {
localStorage.setItem(
'persistCode',
@ -499,7 +509,7 @@ test.describe('Testing constraints', { tag: ['@skipWin'] }, () => {
await page.setBodyDimensions({ width: 1200, height: 500 })
await homePage.goToModelingScene()
await u.waitForPageLoad()
await scene.settled(cmdBar)
await page.getByText('line(end = [74.36, 130.4])').click()
await page.getByRole('button', { name: 'Edit Sketch' }).click()
@ -578,7 +588,7 @@ test.describe('Testing constraints', { tag: ['@skipWin'] }, () => {
},
] as const
for (const { testName, addVariable, value, constraint } of cases) {
test(`${testName}`, async ({ page, homePage }) => {
test(`${testName}`, async ({ page, homePage, scene, cmdBar }) => {
await page.addInitScript(async () => {
localStorage.setItem(
'persistCode',
@ -602,7 +612,7 @@ test.describe('Testing constraints', { tag: ['@skipWin'] }, () => {
await page.setBodyDimensions({ width: 1000, height: 500 })
await homePage.goToModelingScene()
await u.waitForPageLoad()
await scene.settled(cmdBar)
await page.getByText('line(end = [74.36, 130.4])').click()
await page.getByRole('button', { name: 'Edit Sketch' }).click()
@ -655,7 +665,14 @@ test.describe('Testing constraints', { tag: ['@skipWin'] }, () => {
},
] as const
for (const { testName, addVariable, value, constraint } of cases) {
test(`${testName}`, async ({ context, homePage, page, editor }) => {
test(`${testName}`, async ({
context,
homePage,
page,
editor,
scene,
cmdBar,
}) => {
// constants and locators
const cmdBarKclInput = page
.getByTestId('cmd-bar-arg-value')
@ -689,7 +706,7 @@ part002 = startSketchOn(XZ)
await page.setBodyDimensions({ width: 1200, height: 500 })
await homePage.goToModelingScene()
await u.waitForPageLoad()
await scene.settled(cmdBar)
await editor.scrollToText('line(end = [74.36, 130.4])', true)
await page.getByText('line(end = [74.36, 130.4])').click()
@ -746,7 +763,7 @@ part002 = startSketchOn(XZ)
},
] as const
for (const { codeAfter, constraintName } of cases) {
test(`${constraintName}`, async ({ page, homePage }) => {
test(`${constraintName}`, async ({ page, homePage, scene, cmdBar }) => {
await page.addInitScript(async (customCode) => {
localStorage.setItem(
'persistCode',
@ -770,7 +787,7 @@ part002 = startSketchOn(XZ)
await page.setBodyDimensions({ width: 1000, height: 500 })
await homePage.goToModelingScene()
await u.waitForPageLoad()
await scene.settled(cmdBar)
await page.getByText('line(end = [74.36, 130.4])').click()
await page.getByRole('button', { name: 'Edit Sketch' }).click()
@ -848,7 +865,7 @@ part002 = startSketchOn(XZ)
},
] as const
for (const { codeAfter, constraintName } of cases) {
test(`${constraintName}`, async ({ page, homePage }) => {
test(`${constraintName}`, async ({ page, homePage, scene, cmdBar }) => {
await page.addInitScript(async () => {
localStorage.setItem(
'persistCode',
@ -871,7 +888,7 @@ part002 = startSketchOn(XZ)
await page.setBodyDimensions({ width: 1000, height: 500 })
await homePage.goToModelingScene()
await u.waitForPageLoad()
await scene.settled(cmdBar)
await page.getByText('line(end = [74.36, 130.4])').click()
await page.getByRole('button', { name: 'Edit Sketch' }).click()
@ -930,7 +947,7 @@ part002 = startSketchOn(XZ)
},
] as const
for (const { codeAfter, constraintName, axisClick } of cases) {
test(`${constraintName}`, async ({ page, homePage }) => {
test(`${constraintName}`, async ({ page, homePage, scene, cmdBar }) => {
await page.addInitScript(async () => {
localStorage.setItem(
'persistCode',
@ -953,7 +970,7 @@ part002 = startSketchOn(XZ)
await page.setBodyDimensions({ width: 1200, height: 500 })
await homePage.goToModelingScene()
await u.waitForPageLoad()
await scene.settled(cmdBar)
await page.getByText('line(end = [74.36, 130.4])').click()
await page.getByRole('button', { name: 'Edit Sketch' }).click()
@ -994,6 +1011,8 @@ part002 = startSketchOn(XZ)
test('Horizontally constrained line remains selected after applying constraint', async ({
page,
homePage,
scene,
cmdBar,
}) => {
test.fixme(orRunWhenFullSuiteEnabled())
test.setTimeout(70_000)
@ -1010,7 +1029,7 @@ part002 = startSketchOn(XZ)
await page.setBodyDimensions({ width: 1200, height: 500 })
await homePage.goToModelingScene()
await u.waitForPageLoad()
await scene.settled(cmdBar)
await page.getByText('line(end = [3.79, 2.68], tag = $seg01)').click()
await expect(page.getByRole('button', { name: 'Edit Sketch' })).toBeEnabled(
@ -1129,7 +1148,7 @@ test.describe('Electron constraint tests', () => {
sortBy: 'last-modified-desc',
})
await homePage.openProject('test-sample')
await scene.waitForExecutionDone()
await scene.settled(cmdBar)
})
async function clickOnFirstSegmentLabel() {

View File

@ -11,7 +11,8 @@ test.describe('Testing in-app sample loading', () => {
* Note this test implicitly depends on the KCL sample "a-parametric-bearing-pillow-block",
* its title, and its units settings. https://github.com/KittyCAD/kcl-samples/blob/main/a-parametric-bearing-pillow-block/main.kcl
*/
test('Web: should overwrite current code, cannot create new file', async ({
// We have no more web tests
test.skip('Web: should overwrite current code, cannot create new file', async ({
editor,
context,
page,
@ -78,7 +79,7 @@ test.describe('Testing in-app sample loading', () => {
test(
'Desktop: should create new file by default, optionally overwrite',
{ tag: '@electron' },
async ({ editor, context, page }, testInfo) => {
async ({ editor, context, page, scene, cmdBar }, testInfo) => {
const { dir } = await context.folderSetupFn(async (dir) => {
const bracketDir = join(dir, 'bracket')
await fsp.mkdir(bracketDir, { recursive: true })
@ -125,7 +126,7 @@ test.describe('Testing in-app sample loading', () => {
await test.step(`Test setup`, async () => {
await page.setBodyDimensions({ width: 1200, height: 500 })
await projectCard.click()
await u.waitForPageLoad()
await scene.settled(cmdBar)
})
await test.step(`Precondition: check the initial code`, async () => {
@ -140,11 +141,14 @@ test.describe('Testing in-app sample loading', () => {
await test.step(`Load a KCL sample with the command palette`, async () => {
await commandBarButton.click()
await page.waitForTimeout(1000)
await commandOption.click()
await page.waitForTimeout(1000)
await commandSampleOption(sampleOne.title).click()
await expect(overwriteWarning).not.toBeVisible()
await expect(newFileWarning).toBeVisible()
await confirmButton.click()
await page.waitForTimeout(1000)
})
await test.step(`Ensure we made and opened a new file`, async () => {
@ -155,14 +159,20 @@ test.describe('Testing in-app sample loading', () => {
await test.step(`Now overwrite the current file`, async () => {
await commandBarButton.click()
await page.waitForTimeout(1000)
await commandOption.click()
await page.waitForTimeout(1000)
await commandSampleOption(sampleTwo.title).click()
await page.waitForTimeout(1000)
await commandMethodArgButton.click()
await page.waitForTimeout(1000)
await commandMethodOption.click()
await page.waitForTimeout(1000)
await expect(commandMethodArgButton).toContainText('overwrite')
await expect(newFileWarning).not.toBeVisible()
await expect(overwriteWarning).toBeVisible()
await confirmButton.click()
await page.waitForTimeout(1000)
})
await test.step(`Ensure we overwrote the current file without navigating`, async () => {

View File

@ -1520,6 +1520,8 @@ part001 = startSketchOn(XZ)
page,
editor,
homePage,
scene,
cmdBar,
}) => {
await page.addInitScript(
async ({ lineToBeDeleted }) => {
@ -1541,7 +1543,8 @@ part001 = startSketchOn(XZ)
await page.setBodyDimensions({ width: 1200, height: 500 })
await homePage.goToModelingScene()
await u.waitForPageLoad()
await scene.connectionEstablished()
await scene.settled(cmdBar)
await page.waitForTimeout(300)
await page.getByText(before).click()

View File

@ -528,6 +528,8 @@ profile001 = startProfileAt([7.49, 9.96], sketch001)
test('Hovering over 3d features highlights code, clicking puts the cursor in the right place and sends selection id to engine', async ({
page,
homePage,
scene,
cmdBar,
}) => {
const u = await getUtils(page)
await page.addInitScript(async (KCL_DEFAULT_LENGTH) => {
@ -779,11 +781,7 @@ part001 = startSketchOn(XZ)
)
`)
await expect(
page
.getByTestId('model-state-indicator-receive-reliable')
.or(page.getByTestId('model-state-indicator-execution-done'))
).toBeVisible()
await scene.settled(cmdBar)
await u.openAndClearDebugPanel()
await u.sendCustomCmd({
@ -953,6 +951,7 @@ part001 = startSketchOn(XZ)
page,
homePage,
scene,
cmdBar,
}) => {
const cases = [
{
@ -989,7 +988,7 @@ part001 = startSketchOn(XZ)
await page.setBodyDimensions({ width: 1200, height: 500 })
await homePage.goToModelingScene()
await scene.waitForExecutionDone()
await scene.settled(cmdBar)
await u.openAndClearDebugPanel()
await u.sendCustomCmd({
@ -1024,6 +1023,7 @@ part001 = startSketchOn(XZ)
page,
homePage,
scene,
cmdBar,
}) => {
await page.addInitScript(async () => {
localStorage.setItem(
@ -1043,7 +1043,7 @@ part001 = startSketchOn(XZ)
await page.setBodyDimensions({ width: 1200, height: 500 })
await homePage.goToModelingScene()
await scene.waitForExecutionDone()
await scene.settled(cmdBar)
await u.openAndClearDebugPanel()
await u.sendCustomCmd({

View File

@ -55,7 +55,8 @@ test.describe('Testing settings', () => {
// Check that the invalid settings were changed to good defaults
expect(storedSettings.settings?.modeling?.base_unit).toBe('in')
expect(storedSettings.settings?.modeling?.mouse_controls).toBe('zoo')
expect(storedSettings.settings?.project?.directory).toBe('')
// Commenting this out because tests need this to be set to work properly.
// expect(storedSettings.settings?.app?.project_directory).toBe('')
expect(storedSettings.settings?.project?.default_project_name).toBe(
'untitled'
)
@ -865,6 +866,8 @@ test.describe('Testing settings', () => {
page,
homePage,
tronApp,
scene,
cmdBar,
}) => {
if (!tronApp) {
fail()
@ -886,6 +889,7 @@ test.describe('Testing settings', () => {
})
await page.setBodyDimensions({ width: 1200, height: 500 })
await homePage.goToModelingScene()
await scene.connectionEstablished()
// Constants and locators
const resizeHandle = page.locator('.sidebar-resize-handles > div.block')
@ -897,6 +901,7 @@ test.describe('Testing settings', () => {
async function setShowDebugPanelTo(value: 'On' | 'Off') {
await commandsButton.click()
await debugPaneOption.scrollIntoViewIfNeeded()
await debugPaneOption.click()
await page.getByRole('option', { name: value }).click()
await expect(

View File

@ -17,7 +17,6 @@ declare module '@playwright/test' {
}
interface Page {
dir: string
TEST_SETTINGS_FILE_KEY?: string
setBodyDimensions: (dims: {
width: number
height: number

1
interface.d.ts vendored
View File

@ -72,7 +72,6 @@ export interface IElectronAPI {
process: {
env: {
BASE_URL: string
TEST_SETTINGS_FILE_KEY: string
IS_PLAYWRIGHT: string
VITE_KC_DEV_TOKEN: string
VITE_KC_API_WS_MODELING_URL: string

View File

@ -4,10 +4,10 @@ $ dpdm --no-warning --no-tree -T --skip-dynamic-imports=circular src/index.tsx
02) src/lang/std/sketch.ts -> src/lang/modifyAst.ts
03) src/lang/std/sketch.ts -> src/lang/modifyAst.ts -> src/lang/std/sketchcombos.ts
04) src/lib/singletons.ts -> src/editor/manager.ts -> src/lib/selections.ts
05) src/lib/singletons.ts -> src/lang/KclSingleton.ts
06) src/lib/singletons.ts -> src/lang/codeManager.ts
07) src/lib/singletons.ts -> src/clientSideScene/sceneEntities.ts -> src/clientSideScene/segments.ts -> src/components/Toolbar/angleLengthInfo.ts
08) src/lib/singletons.ts -> src/clientSideScene/sceneEntities.ts -> src/clientSideScene/segments.ts -> src/machines/commandBarMachine.ts -> src/lib/commandBarConfigs/authCommandConfig.ts -> src/machines/appMachine.ts -> src/machines/settingsMachine.ts
09) src/machines/commandBarMachine.ts -> src/lib/commandBarConfigs/authCommandConfig.ts -> src/machines/appMachine.ts -> src/machines/settingsMachine.ts
05) src/lib/singletons.ts -> src/editor/manager.ts -> src/lib/selections.ts -> src/machines/appMachine.ts -> src/machines/engineStreamMachine.ts
06) src/lib/singletons.ts -> src/editor/manager.ts -> src/lib/selections.ts -> src/machines/appMachine.ts -> src/machines/settingsMachine.ts
07) src/machines/appMachine.ts -> src/machines/settingsMachine.ts -> src/machines/commandBarMachine.ts -> src/lib/commandBarConfigs/authCommandConfig.ts
08) src/lib/singletons.ts -> src/lang/codeManager.ts
09) src/lib/singletons.ts -> src/clientSideScene/sceneEntities.ts -> src/clientSideScene/segments.ts -> src/components/Toolbar/angleLengthInfo.ts
10) src/hooks/useModelingContext.ts -> src/components/ModelingMachineProvider.tsx -> src/components/Toolbar/Intersect.tsx -> src/components/SetHorVertDistanceModal.tsx -> src/lib/useCalculateKclExpression.ts
11) src/routes/Onboarding/index.tsx -> src/routes/Onboarding/Camera.tsx -> src/routes/Onboarding/utils.tsx

View File

@ -1,10 +1,29 @@
import { defineConfig, devices } from '@playwright/test'
import os from 'os'
/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
*/
// require('dotenv').config();
const platform = os.platform() // 'linux' (Ubuntu), 'darwin' (macOS), 'win32' (Windows)
let workers: number | string
if (process.env.E2E_WORKERS) {
workers = process.env.E2E_WORKERS.includes('%')
? process.env.E2E_WORKERS
: parseInt(process.env.E2E_WORKERS)
} else if (!process.env.CI) {
workers = 1 // Local dev: keep things simple and deterministic by default
} else {
// On CI: adjust based on OS
switch (platform) {
case 'linux':
workers = '100%' // CI Linux runners are generally beefier
break
case 'darwin':
case 'win32':
default:
workers = '75%' // Slightly conservative for GUI-based OSes
break
}
}
/**
* See https://playwright.dev/docs/test-configuration.
@ -19,8 +38,8 @@ export default defineConfig({
forbidOnly: !!process.env.CI,
/* Do not retry */
retries: process.env.CI ? 0 : 0,
/* Different amount of parallelism on CI and local. */
workers: process.env.CI ? 1 : 4,
/* Use all available CPU cores */
workers: workers,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: [
[process.env.CI ? 'dot' : 'list'],

View File

@ -1,5 +1,29 @@
import { defineConfig, devices } from '@playwright/test'
import { platform } from 'os'
import os from 'os'
const platform = os.platform() // 'linux' (Ubuntu), 'darwin' (macOS), 'win32' (Windows)
let workers: number | string
if (process.env.E2E_WORKERS) {
workers = process.env.E2E_WORKERS.includes('%')
? process.env.E2E_WORKERS
: parseInt(process.env.E2E_WORKERS)
} else if (!process.env.CI) {
workers = 1 // Local dev: keep things simple and deterministic by default
} else {
// On CI: adjust based on OS
switch (platform) {
case 'linux':
workers = '50%' // CI Linux runners are generally beefier
break
case 'darwin':
case 'win32':
default:
workers = '25%' // Lower concurrency for heavier Electron processes
break
}
}
/**
* See https://playwright.dev/docs/test-configuration.
@ -14,8 +38,8 @@ export default defineConfig({
forbidOnly: true,
/* Do not retry */
retries: 0,
/* Different amount of parallelism on CI and local. */
workers: platform() === 'win32' ? 1 : 2,
/* Use all available CPU cores */
workers: workers,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: [
['dot'],

View File

@ -5,7 +5,7 @@ pub mod project;
use anyhow::Result;
use parse_display::{Display, FromStr};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde::{Deserialize, Deserializer, Serialize};
use validator::{Validate, ValidateRange};
const DEFAULT_THEME_COLOR: f64 = 264.5;
@ -131,9 +131,14 @@ pub struct AppSettings {
/// This setting only applies to the web app. And is temporary until we have Linux support.
#[serde(default, alias = "dismissWebBanner", skip_serializing_if = "is_default")]
pub dismiss_web_banner: bool,
/// When the user is idle, and this is true, the stream will be torn down.
#[serde(default, alias = "streamIdleMode", skip_serializing_if = "is_default")]
pub stream_idle_mode: bool,
/// When the user is idle, teardown the stream after some time.
#[serde(
default,
deserialize_with = "deserialize_stream_idle_mode",
alias = "streamIdleMode",
skip_serializing_if = "is_default"
)]
stream_idle_mode: Option<u32>,
/// When the user is idle, and this is true, the stream will be torn down.
#[serde(default, alias = "allowOrbitInSketchMode", skip_serializing_if = "is_default")]
pub allow_orbit_in_sketch_mode: bool,
@ -143,7 +148,31 @@ pub struct AppSettings {
pub show_debug_panel: bool,
}
// TODO: When we remove backwards compatibility with the old settings file, we can remove this.
fn deserialize_stream_idle_mode<'de, D>(deserializer: D) -> Result<Option<u32>, D::Error>
where
D: Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum StreamIdleModeValue {
Number(u32),
String(String),
Boolean(bool),
}
const DEFAULT_TIMEOUT: u32 = 1000 * 60 * 5;
Ok(match StreamIdleModeValue::deserialize(deserializer) {
Ok(StreamIdleModeValue::Number(value)) => Some(value),
Ok(StreamIdleModeValue::String(value)) => Some(value.parse::<u32>().unwrap_or(DEFAULT_TIMEOUT)),
// The old type of this value. I'm willing to say no one used it but
// we can never guarantee it.
Ok(StreamIdleModeValue::Boolean(true)) => Some(DEFAULT_TIMEOUT),
Ok(StreamIdleModeValue::Boolean(false)) => None,
_ => None,
})
}
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq)]
#[ts(export)]
#[serde(untagged)]
@ -626,7 +655,7 @@ textWrapping = true
theme_color: None,
dismiss_web_banner: false,
enable_ssao: None,
stream_idle_mode: false,
stream_idle_mode: None,
allow_orbit_in_sketch_mode: false,
show_debug_panel: true,
},
@ -691,7 +720,7 @@ includeSettings = false
dismiss_web_banner: false,
enable_ssao: None,
show_debug_panel: true,
stream_idle_mode: false,
stream_idle_mode: None,
allow_orbit_in_sketch_mode: false,
},
modeling: ModelingSettings {
@ -759,7 +788,7 @@ defaultProjectName = "projects-$nnn"
theme_color: None,
dismiss_web_banner: false,
enable_ssao: None,
stream_idle_mode: false,
stream_idle_mode: None,
allow_orbit_in_sketch_mode: false,
show_debug_panel: true,
},
@ -841,7 +870,7 @@ projectDirectory = "/Users/macinatormax/Documents/kittycad-modeling-projects""#;
dismiss_web_banner: false,
enable_ssao: None,
show_debug_panel: false,
stream_idle_mode: false,
stream_idle_mode: None,
allow_orbit_in_sketch_mode: false,
},
modeling: ModelingSettings {

View File

@ -6,16 +6,17 @@ import {
useLoaderData,
useNavigate,
useRouteLoaderData,
useSearchParams,
} from 'react-router-dom'
import { AppHeader } from '@src/components/AppHeader'
import { CameraProjectionToggle } from '@src/components/CameraProjectionToggle'
import { useEngineCommands } from '@src/components/EngineCommands'
import { EngineStream } from '@src/components/EngineStream'
import Gizmo from '@src/components/Gizmo'
import { LowerRightControls } from '@src/components/LowerRightControls'
import { useLspContext } from '@src/components/LspProvider'
import { ModelingSidebar } from '@src/components/ModelingSidebar/ModelingSidebar'
import { Stream } from '@src/components/Stream'
import { UnitsMenu } from '@src/components/UnitsMenu'
import { useAbsoluteFilePath } from '@src/hooks/useAbsoluteFilePath'
import { useCreateFileLinkQuery } from '@src/hooks/useCreateFileLinkQueryWatcher'
@ -31,13 +32,22 @@ import {
codeManager,
engineCommandManager,
rustContext,
sceneInfra,
} from '@src/lib/singletons'
import { maybeWriteToDisk } from '@src/lib/telemetry'
import { type IndexLoaderData } from '@src/lib/types'
import { useSettings, useToken } from '@src/machines/appMachine'
import {
engineStreamActor,
useSettings,
useToken,
} from '@src/machines/appMachine'
import { commandBarActor } from '@src/machines/commandBarMachine'
import { EngineStreamTransition } from '@src/machines/engineStreamMachine'
import { onboardingPaths } from '@src/routes/Onboarding/paths'
// CYCLIC REF
sceneInfra.camControls.engineStreamActor = engineStreamActor
maybeWriteToDisk()
.then(() => {})
.catch(() => {})
@ -64,6 +74,10 @@ export function App() {
// the coredump.
const ref = useRef<HTMLDivElement>(null)
// Stream related refs and data
let [searchParams] = useSearchParams()
const pool = searchParams.get('pool')
const projectName = project?.name || null
const projectPath = project?.path || null
@ -78,7 +92,7 @@ export function App() {
useHotKeyListener()
const settings = useSettings()
const token = useToken()
const authToken = useToken()
const coreDumpManager = useMemo(
() =>
@ -86,7 +100,7 @@ export function App() {
engineCommandManager,
codeManager,
rustContext,
token
authToken
),
[]
)
@ -140,6 +154,13 @@ export function App() {
}
}, [lastCommandType])
useEffect(() => {
// When leaving the modeling scene, cut the engine stream.
return () => {
engineStreamActor.send({ type: EngineStreamTransition.Pause })
}
}, [])
return (
<div className="relative h-full flex flex-col" ref={ref}>
<AppHeader
@ -149,7 +170,7 @@ export function App() {
/>
<ModalContainer />
<ModelingSidebar paneOpacity={paneOpacity} />
<Stream />
<EngineStream pool={pool} authToken={authToken} />
{/* <CamToggle /> */}
<LowerRightControls coreDumpManager={coreDumpManager}>
<UnitsMenu />

View File

@ -11,6 +11,7 @@ import { useNetworkContext } from '@src/hooks/useNetworkContext'
import { NetworkHealthState } from '@src/hooks/useNetworkStatus'
import { useKclContext } from '@src/lang/KclProvider'
import { isCursorInFunctionDefinition } from '@src/lang/queryAst'
import { EngineConnectionStateType } from '@src/lang/std/engineConnection'
import { isCursorInSketchCommandRange } from '@src/lang/util'
import { isDesktop } from '@src/lib/isDesktop'
import { openExternalBrowserIfDesktop } from '@src/lib/openWindow'
@ -52,7 +53,7 @@ export function Toolbar({
}, [kclManager.artifactGraph, context.selectionRanges])
const toolbarButtonsRef = useRef<HTMLUListElement>(null)
const { overallState } = useNetworkContext()
const { overallState, immediateState } = useNetworkContext()
const { isExecuting } = useKclContext()
const { isStreamReady } = useAppState()
const [showRichContent, setShowRichContent] = useState(false)
@ -61,6 +62,7 @@ export function Toolbar({
(overallState !== NetworkHealthState.Ok &&
overallState !== NetworkHealthState.Weak) ||
isExecuting ||
immediateState.type !== EngineConnectionStateType.ConnectionEstablished ||
!isStreamReady
const currentMode =

View File

@ -1,4 +1,8 @@
import type { CameraDragInteractionType_type } from '@kittycad/lib/dist/types/src/models'
import type {
CameraDragInteractionType_type,
CameraViewState_type,
} from '@kittycad/lib/dist/types/src/models'
import type { EngineStreamActor } from '@src/machines/engineStreamMachine'
import * as TWEEN from '@tweenjs/tween.js'
import {
Euler,
@ -97,6 +101,7 @@ class CameraRateLimiter {
export class CameraControls {
engineCommandManager: EngineCommandManager
engineStreamActor?: EngineStreamActor
syncDirection: 'clientToEngine' | 'engineToClient' = 'engineToClient'
camera: PerspectiveCamera | OrthographicCamera
target: Vector3
@ -105,6 +110,7 @@ export class CameraControls {
wasDragging: boolean
mouseDownPosition: Vector2
mouseNewPosition: Vector2
oldCameraState: undefined | CameraViewState_type
rotationSpeed = 0.3
enableRotate = true
enablePan = true
@ -285,6 +291,7 @@ export class CameraControls {
camSettings.center.y,
camSettings.center.z
)
const orientation = new Quaternion(
camSettings.orientation.x,
camSettings.orientation.y,
@ -468,12 +475,13 @@ export class CameraControls {
if (this.syncDirection === 'engineToClient') {
const newCmdId = uuidv4()
const videoRef = this.engineStreamActor?.getSnapshot().context.videoRef
// Nonsense to do anything until the video stream is established.
if (!this.engineCommandManager.elVideo) return
if (!videoRef?.current) return
const { x, y } = getNormalisedCoordinates(
event,
this.engineCommandManager.elVideo,
videoRef.current,
this.engineCommandManager.streamDimensions
)
this.throttledEngCmd({
@ -956,6 +964,46 @@ export class CameraControls {
})
}
async restoreRemoteCameraStateAndTriggerSync() {
if (this.oldCameraState) {
await this.engineCommandManager.sendSceneCommand({
type: 'modeling_cmd_req',
cmd_id: uuidv4(),
cmd: {
type: 'default_camera_set_view',
view: this.oldCameraState,
},
})
}
await this.engineCommandManager.sendSceneCommand({
type: 'modeling_cmd_req',
cmd_id: uuidv4(),
cmd: {
type: 'default_camera_get_settings',
},
})
}
async saveRemoteCameraState() {
const cameraViewStateResponse =
await this.engineCommandManager.sendSceneCommand({
type: 'modeling_cmd_req',
cmd_id: uuidv4(),
cmd: { type: 'default_camera_get_view' },
})
if (!cameraViewStateResponse) return
if (
'resp' in cameraViewStateResponse &&
'modeling_response' in cameraViewStateResponse.resp.data &&
'data' in cameraViewStateResponse.resp.data.modeling_response &&
'view' in cameraViewStateResponse.resp.data.modeling_response.data
) {
this.oldCameraState =
cameraViewStateResponse.resp.data.modeling_response.data.view
}
}
async tweenCameraToQuaternion(
targetQuaternion: Quaternion,
targetPosition = new Vector3(),

View File

@ -7,6 +7,8 @@ import CommandBarReview from '@src/components/CommandBar/CommandBarReview'
import CommandComboBox from '@src/components/CommandComboBox'
import { CustomIcon } from '@src/components/CustomIcon'
import Tooltip from '@src/components/Tooltip'
import { useNetworkContext } from '@src/hooks/useNetworkContext'
import { EngineConnectionStateType } from '@src/lang/std/engineConnection'
import useHotkeyWrapper from '@src/lib/hotkeyWrapper'
import {
commandBarActor,
@ -18,6 +20,7 @@ export const COMMAND_PALETTE_HOTKEY = 'mod+k'
export const CommandBar = () => {
const { pathname } = useLocation()
const commandBarState = useCommandBarState()
const { immediateState } = useNetworkContext()
const {
context: { selectedCommand, currentArgument, commands },
} = commandBarState
@ -32,6 +35,14 @@ export const CommandBar = () => {
commandBarActor.send({ type: 'Close' })
}, [pathname])
useEffect(() => {
if (
immediateState.type !== EngineConnectionStateType.ConnectionEstablished
) {
commandBarActor.send({ type: 'Close' })
}
}, [immediateState])
// Hook up keyboard shortcuts
useHotkeyWrapper([COMMAND_PALETTE_HOTKEY], () => {
if (commandBarState.context.commands.length === 0) return

View File

@ -0,0 +1,431 @@
import { useAppState } from '@src/AppState'
import { ClientSideScene } from '@src/clientSideScene/ClientSideSceneComp'
import { ViewControlContextMenu } from '@src/components/ViewControlMenu'
import { useModelingContext } from '@src/hooks/useModelingContext'
import { useNetworkContext } from '@src/hooks/useNetworkContext'
import { NetworkHealthState } from '@src/hooks/useNetworkStatus'
import { getArtifactOfTypes } from '@src/lang/std/artifactGraph'
import { EngineCommandManagerEvents } from '@src/lang/std/engineConnection'
import { btnName } from '@src/lib/cameraControls'
import { PATHS } from '@src/lib/paths'
import { sendSelectEventToEngine } from '@src/lib/selections'
import {
engineCommandManager,
kclManager,
sceneInfra,
} from '@src/lib/singletons'
import { REASONABLE_TIME_TO_REFRESH_STREAM_SIZE } from '@src/lib/timings'
import { err, reportRejection, trap } from '@src/lib/trap'
import type { IndexLoaderData } from '@src/lib/types'
import { uuidv4 } from '@src/lib/utils'
import { engineStreamActor, useSettings } from '@src/machines/appMachine'
import { useCommandBarState } from '@src/machines/commandBarMachine'
import {
EngineStreamState,
EngineStreamTransition,
} from '@src/machines/engineStreamMachine'
import { useSelector } from '@xstate/react'
import type { MouseEventHandler } from 'react'
import { useEffect, useRef, useState } from 'react'
import { useRouteLoaderData } from 'react-router-dom'
export const EngineStream = (props: {
pool: string | null
authToken: string | undefined
}) => {
const { setAppState } = useAppState()
const [firstPlay, setFirstPlay] = useState(true)
const { overallState } = useNetworkContext()
const settings = useSettings()
const engineStreamState = useSelector(engineStreamActor, (state) => state)
const { file } = useRouteLoaderData(PATHS.FILE) as IndexLoaderData
const last = useRef<number>(Date.now())
const videoWrapperRef = useRef<HTMLDivElement>(null)
const settingsEngine = {
theme: settings.app.theme.current,
enableSSAO: settings.modeling.enableSSAO.current,
highlightEdges: settings.modeling.highlightEdges.current,
showScaleGrid: settings.modeling.showScaleGrid.current,
cameraProjection: settings.modeling.cameraProjection.current,
}
const { state: modelingMachineState, send: modelingMachineActorSend } =
useModelingContext()
const commandBarState = useCommandBarState()
const streamIdleMode = settings.app.streamIdleMode.current
const startOrReconfigureEngine = () => {
engineStreamActor.send({
type: EngineStreamTransition.StartOrReconfigureEngine,
modelingMachineActorSend,
settings: settingsEngine,
setAppState,
// It's possible a reconnect happens as we drag the window :')
onMediaStream(mediaStream: MediaStream) {
engineStreamActor.send({
type: EngineStreamTransition.SetMediaStream,
mediaStream,
})
},
})
}
// When the scene is ready play the stream and execute!
const play = () => {
engineStreamActor.send({
type: EngineStreamTransition.Play,
})
const kmp = kclManager.executeCode().catch(trap)
if (!firstPlay) return
setFirstPlay(false)
console.log('scene is ready, fire!')
kmp
.then(() =>
// It makes sense to also call zoom to fit here, when a new file is
// loaded for the first time, but not overtaking the work kevin did
// so the camera isn't moving all the time.
engineCommandManager.sendSceneCommand({
type: 'modeling_cmd_req',
cmd_id: uuidv4(),
cmd: {
type: 'zoom_to_fit',
object_ids: [], // leave empty to zoom to all objects
padding: 0.1, // padding around the objects
animated: false, // don't animate the zoom for now
},
})
)
.catch(trap)
}
useEffect(() => {
engineCommandManager.addEventListener(
EngineCommandManagerEvents.SceneReady,
play
)
return () => {
engineCommandManager.removeEventListener(
EngineCommandManagerEvents.SceneReady,
play
)
}
}, [firstPlay])
useEffect(() => {
engineCommandManager.addEventListener(
EngineCommandManagerEvents.SceneReady,
play
)
engineStreamActor.send({
type: EngineStreamTransition.SetPool,
data: { pool: props.pool },
})
engineStreamActor.send({
type: EngineStreamTransition.SetAuthToken,
data: { authToken: props.authToken },
})
return () => {
engineCommandManager.tearDown()
}
}, [])
// In the past we'd try to play immediately, but the proper thing is to way
// for the 'canplay' event to tell us data is ready.
useEffect(() => {
const videoRef = engineStreamState.context.videoRef.current
if (!videoRef) {
return
}
const play = () => {
videoRef.play().catch(console.error)
}
videoRef.addEventListener('canplay', play)
return () => {
videoRef.removeEventListener('canplay', play)
}
}, [engineStreamState.context.videoRef.current])
useEffect(() => {
if (engineStreamState.value === EngineStreamState.Reconfiguring) return
const video = engineStreamState.context.videoRef?.current
if (!video) return
const canvas = engineStreamState.context.canvasRef?.current
if (!canvas) return
new ResizeObserver(() => {
// Prevents:
// `Uncaught ResizeObserver loop completed with undelivered notifications`
window.requestAnimationFrame(() => {
if (Date.now() - last.current < REASONABLE_TIME_TO_REFRESH_STREAM_SIZE)
return
last.current = Date.now()
if (
Math.abs(video.width - window.innerWidth) > 4 ||
Math.abs(video.height - window.innerHeight) > 4
) {
timeoutStart.current = Date.now()
startOrReconfigureEngine()
}
})
}).observe(document.body)
}, [engineStreamState.value])
// When the video and canvas element references are set, start the engine.
useEffect(() => {
if (
engineStreamState.context.canvasRef.current &&
engineStreamState.context.videoRef.current
) {
startOrReconfigureEngine()
}
}, [
engineStreamState.context.canvasRef.current,
engineStreamState.context.videoRef.current,
])
// On settings change, reconfigure the engine. When paused this gets really tricky,
// and also requires onMediaStream to be set!
useEffect(() => {
startOrReconfigureEngine()
}, Object.values(settingsEngine))
/**
* Subscribe to execute code when the file changes
* but only if the scene is already ready.
* See onSceneReady for the initial scene setup.
*/
useEffect(() => {
if (engineCommandManager.engineConnection?.isReady() && file?.path) {
console.log('file changed, executing code')
kclManager
.executeCode()
.catch(trap)
.then(() =>
// It makes sense to also call zoom to fit here, when a new file is
// loaded for the first time, but not overtaking the work kevin did
// so the camera isn't moving all the time.
engineCommandManager.sendSceneCommand({
type: 'modeling_cmd_req',
cmd_id: uuidv4(),
cmd: {
type: 'zoom_to_fit',
object_ids: [], // leave empty to zoom to all objects
padding: 0.1, // padding around the objects
animated: false, // don't animate the zoom for now
},
})
)
.catch(trap)
}
}, [file?.path])
const IDLE_TIME_MS = Number(streamIdleMode)
// When streamIdleMode is changed, setup or teardown the timeouts
const timeoutStart = useRef<number | null>(null)
useEffect(() => {
timeoutStart.current = streamIdleMode ? Date.now() : null
}, [streamIdleMode])
useEffect(() => {
let frameId: ReturnType<typeof window.requestAnimationFrame> = 0
const frameLoop = () => {
// Do not pause if the user is in the middle of an operation
if (!modelingMachineState.matches('idle')) {
// In fact, stop the timeout, because we don't want to trigger the
// pause when we exit the operation.
timeoutStart.current = null
} else if (timeoutStart.current) {
const elapsed = Date.now() - timeoutStart.current
if (elapsed >= IDLE_TIME_MS) {
timeoutStart.current = null
engineStreamActor.send({ type: EngineStreamTransition.Pause })
}
}
frameId = window.requestAnimationFrame(frameLoop)
}
frameId = window.requestAnimationFrame(frameLoop)
return () => {
window.cancelAnimationFrame(frameId)
}
}, [modelingMachineState])
useEffect(() => {
if (!streamIdleMode) return
const onAnyInput = () => {
// Just in case it happens in the middle of the user turning off
// idle mode.
if (!streamIdleMode) {
timeoutStart.current = null
return
}
if (engineStreamState.value === EngineStreamState.Paused) {
engineStreamActor.send({
type: EngineStreamTransition.StartOrReconfigureEngine,
modelingMachineActorSend,
settings: settingsEngine,
setAppState,
onMediaStream(mediaStream: MediaStream) {
engineStreamActor.send({
type: EngineStreamTransition.SetMediaStream,
mediaStream,
})
},
})
}
timeoutStart.current = Date.now()
}
// It's possible after a reconnect, the user doesn't move their mouse at
// all, meaning the timer is not reset to run. We need to set it every
// time our effect dependencies change then.
timeoutStart.current = Date.now()
window.document.addEventListener('keydown', onAnyInput)
window.document.addEventListener('keyup', onAnyInput)
window.document.addEventListener('mousemove', onAnyInput)
window.document.addEventListener('mousedown', onAnyInput)
window.document.addEventListener('mouseup', onAnyInput)
window.document.addEventListener('scroll', onAnyInput)
window.document.addEventListener('touchstart', onAnyInput)
window.document.addEventListener('touchstop', onAnyInput)
return () => {
timeoutStart.current = null
window.document.removeEventListener('keydown', onAnyInput)
window.document.removeEventListener('keyup', onAnyInput)
window.document.removeEventListener('mousemove', onAnyInput)
window.document.removeEventListener('mousedown', onAnyInput)
window.document.removeEventListener('mouseup', onAnyInput)
window.document.removeEventListener('scroll', onAnyInput)
window.document.removeEventListener('touchstart', onAnyInput)
window.document.removeEventListener('touchstop', onAnyInput)
}
}, [streamIdleMode, engineStreamState.value])
const isNetworkOkay =
overallState === NetworkHealthState.Ok ||
overallState === NetworkHealthState.Weak
const handleMouseUp: MouseEventHandler<HTMLDivElement> = (e) => {
if (!isNetworkOkay) return
if (!engineStreamState.context.videoRef.current) return
// If we're in sketch mode, don't send a engine-side select event
if (modelingMachineState.matches('Sketch')) return
// Only respect default plane selection if we're on a selection command argument
if (
modelingMachineState.matches({ idle: 'showPlanes' }) &&
!(
commandBarState.matches('Gathering arguments') &&
commandBarState.context.currentArgument?.inputType === 'selection'
)
)
return
// If we're mousing up from a camera drag, don't send a select event
if (sceneInfra.camControls.wasDragging === true) return
if (btnName(e.nativeEvent).left) {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
sendSelectEventToEngine(e)
}
}
/**
* On double-click of sketch entities we automatically enter sketch mode with the selected sketch,
* allowing for quick editing of sketches. TODO: This should be moved to a more central place.
*/
const enterSketchModeIfSelectingSketch: MouseEventHandler<HTMLDivElement> = (
e
) => {
if (
!isNetworkOkay ||
!engineStreamState.context.videoRef.current ||
modelingMachineState.matches('Sketch') ||
modelingMachineState.matches({ idle: 'showPlanes' }) ||
sceneInfra.camControls.wasDragging === true ||
!btnName(e.nativeEvent).left
) {
return
}
sendSelectEventToEngine(e)
.then(({ entity_id }) => {
if (!entity_id) {
// No entity selected. This is benign
return
}
const path = getArtifactOfTypes(
{ key: entity_id, types: ['path', 'solid2d', 'segment', 'helix'] },
kclManager.artifactGraph
)
if (err(path)) {
return path
}
sceneInfra.modelingSend({ type: 'Enter sketch' })
})
.catch(reportRejection)
}
return (
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
<div
ref={videoWrapperRef}
className="absolute inset-0 z-0"
id="stream"
data-testid="stream"
onMouseUp={handleMouseUp}
onDoubleClick={enterSketchModeIfSelectingSketch}
onContextMenu={(e) => e.preventDefault()}
onContextMenuCapture={(e) => e.preventDefault()}
>
<video
autoPlay
muted
key={engineStreamActor.id + 'video'}
ref={engineStreamState.context.videoRef}
controls={false}
className="w-full cursor-pointer h-full"
disablePictureInPicture
id="video-stream"
/>
<canvas
key={engineStreamActor.id + 'canvas'}
ref={engineStreamState.context.canvasRef}
className="cursor-pointer"
id="freeze-frame"
>
No canvas support
</canvas>
<ClientSideScene
cameraControls={settings.modeling.mouseControls.current}
/>
<ViewControlContextMenu
event="mouseup"
guard={(e) =>
sceneInfra.camControls.wasDragging === false &&
btnName(e).right === true
}
menuTargetElement={videoWrapperRef}
/>
</div>
)
}

View File

@ -11,6 +11,8 @@ import type {
} from 'xstate'
import { fromPromise } from 'xstate'
import { useAbsoluteFilePath } from '@src/hooks/useAbsoluteFilePath'
import { useMenuListener } from '@src/hooks/useMenu'
import { newKclFile } from '@src/lang/project'
import { createNamedViewsCommand } from '@src/lib/commandBarConfigs/namedViewsConfig'
import { createRouteCommands } from '@src/lib/commandBarConfigs/routeCommandConfig'
@ -34,6 +36,7 @@ import { type IndexLoaderData } from '@src/lib/types'
import { useSettings, useToken } from '@src/machines/appMachine'
import { commandBarActor } from '@src/machines/commandBarMachine'
import { fileMachine } from '@src/machines/fileMachine'
import { modelingMenuCallbackMostActions } from '@src/menu/register'
type MachineContext<T extends AnyStateMachine> = {
state: StateFrom<T>
@ -60,6 +63,7 @@ export const FileMachineProvider = ({
[]
)
const filePath = useAbsoluteFilePath()
// Only create the native file menus on desktop
useEffect(() => {
if (isDesktop()) {
@ -414,6 +418,15 @@ export const FileMachineProvider = ({
}
)
const cb = modelingMenuCallbackMostActions(
settings,
navigate,
filePath,
project,
token
)
useMenuListener(cb)
const kclCommandMemo = useMemo(
() =>
kclCommands({

View File

@ -1,31 +1,45 @@
import { CustomIcon } from '@src/components/CustomIcon'
import { useEngineCommands } from '@src/components/EngineCommands'
import { Spinner } from '@src/components/Spinner'
import { engineStreamActor } from '@src/machines/appMachine'
import { EngineStreamState } from '@src/machines/engineStreamMachine'
import { useSelector } from '@xstate/react'
import { faPause, faPlay, faSpinner } from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
export const ModelStateIndicator = () => {
const [commands] = useEngineCommands()
const lastCommandType = commands[commands.length - 1]?.type
const engineStreamState = useSelector(engineStreamActor, (state) => state)
let className = 'w-6 h-6 '
let icon = <Spinner className={className} />
let icon = <div className={className}></div>
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'
if (engineStreamState.value === EngineStreamState.Paused) {
className += 'text-secondary'
icon = (
<CustomIcon
data-testid={dataTestId + '-receive-reliable'}
name="checkmark"
<FontAwesomeIcon
data-testid={dataTestId + '-paused'}
icon={faPause}
width="20"
height="20"
/>
)
} 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'
} else if (engineStreamState.value === EngineStreamState.Playing) {
className += 'text-secondary'
icon = (
<CustomIcon
data-testid={dataTestId + '-execution-done'}
name="checkmark"
<FontAwesomeIcon
data-testid={dataTestId + '-playing'}
icon={faPlay}
width="20"
height="20"
/>
)
} else {
className += 'text-secondary'
icon = (
<FontAwesomeIcon
data-testid={dataTestId + '-resuming'}
icon={faSpinner}
width="20"
height="20"
/>
)
}

View File

@ -8,7 +8,7 @@ import React, {
} from 'react'
import toast from 'react-hot-toast'
import { useHotkeys } from 'react-hotkeys-hook'
import { useLoaderData, useNavigate, useSearchParams } from 'react-router-dom'
import { useLoaderData, useNavigate } from 'react-router-dom'
import type { Actor, ContextFrom, Prop, SnapshotFrom, StateFrom } from 'xstate'
import { assign, fromPromise } from 'xstate'
@ -19,6 +19,7 @@ import type {
import type { Node } from '@rust/kcl-lib/bindings/Node'
import type { Plane } from '@rust/kcl-lib/bindings/Plane'
import { useAppState } from '@src/AppState'
import { letEngineAnimateAndSyncCamAfter } from '@src/clientSideScene/CameraControls'
import {
SEGMENT_BODIES,
@ -26,6 +27,7 @@ import {
} from '@src/clientSideScene/sceneConstants'
import type { MachineManager } from '@src/components/MachineManagerProvider'
import { MachineManagerContext } from '@src/components/MachineManagerProvider'
import type { SidebarType } from '@src/components/ModelingSidebar/ModelingPanes'
import { applyConstraintIntersect } from '@src/components/Toolbar/Intersect'
import { applyConstraintAbsDistance } from '@src/components/Toolbar/SetAbsDistance'
import {
@ -38,8 +40,13 @@ import {
applyConstraintLength,
} from '@src/components/Toolbar/setAngleLength'
import { useFileContext } from '@src/hooks/useFileContext'
import { useSetupEngineManager } from '@src/hooks/useSetupEngineManager'
import {
useMenuListener,
useSketchModeMenuEnableDisable,
} from '@src/hooks/useMenu'
import { useNetworkContext } from '@src/hooks/useNetworkContext'
import useStateMachineCommands from '@src/hooks/useStateMachineCommands'
import { useKclContext } from '@src/lang/KclProvider'
import { updateModelingState } from '@src/lang/modelingWorkflows'
import {
insertNamedConstant,
@ -111,6 +118,7 @@ import {
modelingMachine,
modelingMachineDefaultContext,
} from '@src/machines/modelingMachine'
import type { WebContentSendPayload } from '@src/menu/channels'
export const ModelingMachineContext = createContext(
{} as {
@ -131,14 +139,7 @@ export const ModelingMachineProvider = ({
}) => {
const {
app: { theme, allowOrbitInSketchMode },
modeling: {
defaultUnit,
cameraProjection,
highlightEdges,
showScaleGrid,
cameraOrbit,
enableSSAO,
},
modeling: { defaultUnit, cameraProjection, highlightEdges, cameraOrbit },
} = useSettings()
const navigate = useNavigate()
const { context, send: fileMachineSend } = useFileContext()
@ -147,13 +148,11 @@ export const ModelingMachineProvider = ({
const streamRef = useRef<HTMLDivElement>(null)
const persistedContext = useMemo(() => getPersistedContext(), [])
let [searchParams] = useSearchParams()
const pool = searchParams.get('pool')
const isCommandBarClosed = useSelector(
commandBarActor,
commandBarIsClosedSelector
)
// Settings machine setup
// const retrievedSettings = useRef(
// localStorage?.getItem(MODELING_PERSIST_KEY) || '{}'
@ -1756,6 +1755,142 @@ export const ModelingMachineProvider = ({
}
)
// Register file menu actions based off modeling send
const cb = (data: WebContentSendPayload) => {
const openPanes = modelingActor.getSnapshot().context.store.openPanes
if (data.menuLabel === 'View.Panes.Feature tree') {
const featureTree: SidebarType = 'feature-tree'
const alwaysAddFeatureTree: SidebarType[] = [
...new Set([...openPanes, featureTree]),
]
modelingSend({
type: 'Set context',
data: {
openPanes: alwaysAddFeatureTree,
},
})
} else if (data.menuLabel === 'View.Panes.KCL code') {
const code: SidebarType = 'code'
const alwaysAddCode: SidebarType[] = [...new Set([...openPanes, code])]
modelingSend({
type: 'Set context',
data: {
openPanes: alwaysAddCode,
},
})
} else if (data.menuLabel === 'View.Panes.Project files') {
const projectFiles: SidebarType = 'files'
const alwaysAddProjectFiles: SidebarType[] = [
...new Set([...openPanes, projectFiles]),
]
modelingSend({
type: 'Set context',
data: {
openPanes: alwaysAddProjectFiles,
},
})
} else if (data.menuLabel === 'View.Panes.Variables') {
const variables: SidebarType = 'variables'
const alwaysAddVariables: SidebarType[] = [
...new Set([...openPanes, variables]),
]
modelingSend({
type: 'Set context',
data: {
openPanes: alwaysAddVariables,
},
})
} else if (data.menuLabel === 'View.Panes.Logs') {
const logs: SidebarType = 'logs'
const alwaysAddLogs: SidebarType[] = [...new Set([...openPanes, logs])]
modelingSend({
type: 'Set context',
data: {
openPanes: alwaysAddLogs,
},
})
} else if (data.menuLabel === 'Design.Start sketch') {
modelingSend({
type: 'Enter sketch',
data: { forceNewSketch: true },
})
}
}
useMenuListener(cb)
const { overallState } = useNetworkContext()
const { isExecuting } = useKclContext()
const { isStreamReady } = useAppState()
// Assumes all commands are network commands
useSketchModeMenuEnableDisable(
modelingState.context.currentMode,
overallState,
isExecuting,
isStreamReady,
[
{ menuLabel: 'Edit.Modify with Zoo Text-To-CAD' },
{ menuLabel: 'View.Standard views' },
{ menuLabel: 'View.Named views' },
{ menuLabel: 'Design.Start sketch' },
{
menuLabel: 'Design.Create an offset plane',
commandName: 'Offset plane',
groupId: 'modeling',
},
{
menuLabel: 'Design.Create a helix',
commandName: 'Helix',
groupId: 'modeling',
},
{
menuLabel: 'Design.Create an additive feature.Extrude',
commandName: 'Extrude',
groupId: 'modeling',
},
{
menuLabel: 'Design.Create an additive feature.Revolve',
commandName: 'Revolve',
groupId: 'modeling',
},
{
menuLabel: 'Design.Create an additive feature.Sweep',
commandName: 'Sweep',
groupId: 'modeling',
},
{
menuLabel: 'Design.Create an additive feature.Loft',
commandName: 'Loft',
groupId: 'modeling',
},
{
menuLabel: 'Design.Apply modification feature.Fillet',
commandName: 'Fillet',
groupId: 'modeling',
},
{
menuLabel: 'Design.Apply modification feature.Chamfer',
commandName: 'Chamfer',
groupId: 'modeling',
},
{
menuLabel: 'Design.Apply modification feature.Shell',
commandName: 'Shell',
groupId: 'modeling',
},
{
menuLabel: 'Design.Create with Zoo Text-To-CAD',
commandName: 'Text-to-CAD',
groupId: 'modeling',
},
{
menuLabel: 'Design.Modify with Zoo Text-To-CAD',
commandName: 'Prompt-to-edit',
groupId: 'modeling',
},
]
)
// Add debug function to window object
useEffect(() => {
// @ts-ignore - we're intentionally adding this to window
@ -1768,22 +1903,6 @@ export const ModelingMachineProvider = ({
}
}, [modelingActor])
useSetupEngineManager(
streamRef,
modelingSend,
modelingState.context,
{
pool: pool,
theme: theme.current,
highlightEdges: highlightEdges.current,
enableSSAO: enableSSAO.current,
showScaleGrid: showScaleGrid.current,
cameraProjection: cameraProjection.current,
cameraOrbit: cameraOrbit.current,
},
token
)
useEffect(() => {
kclManager.registerExecuteCallback(() => {
modelingSend({ type: 'Re-execute' })

View File

@ -90,6 +90,7 @@ export const KclEditorPane = () => {
return () => {
kclEditorActor.send({ type: 'setKclEditorMounted', data: false })
kclEditorActor.send({ type: 'setLastSelectionEvent', data: undefined })
kclManager.diagnostics = []
}
}, [])

View File

@ -1,31 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { BrowserRouter } from 'react-router-dom'
import {
NETWORK_HEALTH_TEXT,
NetworkHealthIndicator,
} from '@src/components/NetworkHealthIndicator'
import { NetworkHealthState } from '@src/hooks/useNetworkStatus'
function TestWrap({ children }: { children: React.ReactNode }) {
// wrap in router and xState context
return <BrowserRouter>{children}</BrowserRouter>
}
// Our Playwright tests for this are much more comprehensive.
describe('NetworkHealthIndicator tests', () => {
test('Renders the network indicator', () => {
render(
<TestWrap>
<NetworkHealthIndicator />
</TestWrap>
)
fireEvent.click(screen.getByTestId('network-toggle'))
// Starts as disconnected
expect(screen.getByTestId('network')).toHaveTextContent(
NETWORK_HEALTH_TEXT[NetworkHealthState.Disconnected]
)
})
})

View File

@ -86,6 +86,7 @@ export const NetworkHealthIndicator = () => {
error,
setHasCopied,
hasCopied,
ping,
} = useNetworkContext()
return (
@ -129,6 +130,19 @@ export const NetworkHealthIndicator = () => {
{NETWORK_HEALTH_TEXT[overallState]}
</p>
</div>
<div className={`flex items-center justify-between p-2 rounded-t-sm`}>
<h2
className={`text-xs font-sans font-normal ${overallConnectionStateColor[overallState].icon}`}
>
Ping
</h2>
<p
data-testid="network"
className={`font-bold text-xs uppercase px-2 py-1 rounded-sm ${overallConnectionStateColor[overallState].icon}`}
>
{ping ?? 'N/A'}
</p>
</div>
<ul className="divide-y divide-chalkboard-20 dark:divide-chalkboard-80">
{Object.keys(steps).map((name) => (
<li

View File

@ -1,7 +1,6 @@
import { useSelector } from '@xstate/react'
import decamelize from 'decamelize'
import type { ForwardedRef } from 'react'
import { forwardRef, useEffect, useMemo } from 'react'
import { forwardRef, useMemo } from 'react'
import toast from 'react-hot-toast'
import { useLocation, useNavigate } from 'react-router-dom'
import { Fragment } from 'react/jsx-runtime'
@ -31,6 +30,7 @@ import { reportRejection } from '@src/lib/trap'
import { toSync } from '@src/lib/utils'
import { settingsActor, useSettings } from '@src/machines/appMachine'
import { APP_VERSION, IS_NIGHTLY, getReleaseUrl } from '@src/routes/utils'
import { waitFor } from 'xstate'
interface AllSettingsFieldsProps {
searchParamTab: SettingsLevel
@ -65,44 +65,25 @@ export const AllSettingsFields = forwardRef(
return projectPath
}, [location.pathname])
function restartOnboarding() {
async function restartOnboarding() {
settingsActor.send({
type: `set.app.onboardingStatus`,
data: { level: 'user', value: '' },
})
}
await waitFor(settingsActor, (s) => s.matches('idle'), {
timeout: 10_000,
}).catch(reportRejection)
/**
* A "listener" for the XState to return to "idle" state
* when the user resets the onboarding, using the callback above
*/
const isSettingsMachineIdle = useSelector(settingsActor, (s) =>
s.matches('idle')
)
useEffect(() => {
async function navigateToOnboardingStart() {
if (
context.app.onboardingStatus.current === '' &&
isSettingsMachineIdle
) {
if (isFileSettings) {
// If we're in a project, first navigate to the onboarding start here
// so we can trigger the warning screen if necessary
navigate(dotDotSlash(1) + PATHS.ONBOARDING.INDEX)
} else {
// If we're in the global settings, create a new project and navigate
// to the onboarding start in that project
await createAndOpenNewTutorialProject({ onProjectOpen, navigate })
}
}
if (isFileSettings) {
// If we're in a project, first navigate to the onboarding start here
// so we can trigger the warning screen if necessary
navigate(dotDotSlash(1) + PATHS.ONBOARDING.INDEX)
} else {
// If we're in the global settings, create a new project and navigate
// to the onboarding start in that project
await createAndOpenNewTutorialProject({ onProjectOpen, navigate })
}
navigateToOnboardingStart().catch(reportRejection)
}, [
isFileSettings,
navigate,
isSettingsMachineIdle,
context.app.onboardingStatus.current,
])
}
return (
<div className="relative overflow-y-auto">
@ -185,7 +166,9 @@ export const AllSettingsFields = forwardRef(
>
<ActionButton
Element="button"
onClick={restartOnboarding}
onClick={() => {
restartOnboarding().catch(reportRejection)
}}
iconStart={{
icon: 'refresh',
size: 'sm',

View File

@ -0,0 +1,21 @@
import toast from 'react-hot-toast'
interface SketchOnImportToastProps {
fileName: string
}
export function SketchOnImportToast({ fileName }: SketchOnImportToastProps) {
return (
<div className="flex flex-col gap-2">
<span>This face is from an import</span>
<span className="font-mono text-sm bg-gray-100 dark:bg-gray-800 px-2 py-1 rounded">
{fileName}
</span>
<span>Please select this from the files pane to edit</span>
</div>
)
}
export function showSketchOnImportToast(fileName: string) {
toast.error(<SketchOnImportToast fileName={fileName} />)
}

View File

@ -1,414 +0,0 @@
import { useAppStream } from '@src/AppState'
import type { MouseEventHandler } from 'react'
import { useEffect, useRef, useState } from 'react'
import { useRouteLoaderData } from 'react-router-dom'
import { ClientSideScene } from '@src/clientSideScene/ClientSideSceneComp'
import Loading from '@src/components/Loading'
import { ViewControlContextMenu } from '@src/components/ViewControlMenu'
import { useModelingContext } from '@src/hooks/useModelingContext'
import { useNetworkContext } from '@src/hooks/useNetworkContext'
import { NetworkHealthState } from '@src/hooks/useNetworkStatus'
import { getArtifactOfTypes } from '@src/lang/std/artifactGraph'
import {
DisconnectingType,
EngineCommandManagerEvents,
EngineConnectionStateType,
} from '@src/lang/std/engineConnection'
import { btnName } from '@src/lib/cameraControls'
import { PATHS } from '@src/lib/paths'
import { sendSelectEventToEngine } from '@src/lib/selections'
import {
engineCommandManager,
kclManager,
sceneInfra,
} from '@src/lib/singletons'
import { err, reportRejection } from '@src/lib/trap'
import type { IndexLoaderData } from '@src/lib/types'
import { uuidv4 } from '@src/lib/utils'
import { useSettings } from '@src/machines/appMachine'
import { useCommandBarState } from '@src/machines/commandBarMachine'
enum StreamState {
Playing = 'playing',
Paused = 'paused',
Resuming = 'resuming',
Unset = 'unset',
}
export const Stream = () => {
const [isLoading, setIsLoading] = useState(true)
const videoWrapperRef = useRef<HTMLDivElement>(null)
const videoRef = useRef<HTMLVideoElement>(null)
const settings = useSettings()
const { state, send } = useModelingContext()
const commandBarState = useCommandBarState()
const { mediaStream } = useAppStream()
const { overallState, immediateState } = useNetworkContext()
const [streamState, setStreamState] = useState(StreamState.Unset)
const { file } = useRouteLoaderData(PATHS.FILE) as IndexLoaderData
const IDLE = settings.app.streamIdleMode.current
const isNetworkOkay =
overallState === NetworkHealthState.Ok ||
overallState === NetworkHealthState.Weak
engineCommandManager.elVideo = videoRef.current
/**
* Execute code and show a "building scene message"
* in Stream.tsx in the meantime.
*
* I would like for this to live somewhere more central,
* but it seems to me that we need the video element ref
* to be able to play the video after the code has been
* executed. If we can find a way to do this from a more
* central place, we can move this code there.
*/
function executeCodeAndPlayStream() {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
kclManager.executeCode().then(async () => {
await videoRef.current?.play().catch((e) => {
console.warn('Video playing was prevented', e, videoRef.current)
})
setStreamState(StreamState.Playing)
// Only call zoom_to_fit once when the stream starts to center the scene.
await engineCommandManager.sendSceneCommand({
type: 'modeling_cmd_req',
cmd_id: uuidv4(),
cmd: {
type: 'zoom_to_fit',
object_ids: [], // leave empty to zoom to all objects
padding: 0.1, // padding around the objects
animated: false, // don't animate the zoom for now
},
})
})
}
/**
* Subscribe to execute code when the file changes
* but only if the scene is already ready.
* See onSceneReady for the initial scene setup.
*/
useEffect(() => {
if (engineCommandManager.engineConnection?.isReady() && file?.path) {
console.log('execute on file change')
executeCodeAndPlayStream()
}
}, [file?.path, engineCommandManager.engineConnection])
useEffect(() => {
if (
immediateState.type === EngineConnectionStateType.Disconnecting &&
immediateState.value.type === DisconnectingType.Pause
) {
setStreamState(StreamState.Paused)
}
}, [immediateState])
// Linux has a default behavior to paste text on middle mouse up
// This adds a listener to block that pasting if the click target
// is not a text input, so users can move in the 3D scene with
// middle mouse drag with a text input focused without pasting.
useEffect(() => {
const handlePaste = (e: ClipboardEvent) => {
const isHtmlElement = e.target && e.target instanceof HTMLElement
const isEditable =
(isHtmlElement && !('explicitOriginalTarget' in e)) ||
('explicitOriginalTarget' in e &&
((e.explicitOriginalTarget as HTMLElement).contentEditable ===
'true' ||
['INPUT', 'TEXTAREA'].some(
(tagName) =>
tagName === (e.explicitOriginalTarget as HTMLElement).tagName
)))
if (!isEditable) {
e.preventDefault()
e.stopPropagation()
e.stopImmediatePropagation()
}
}
globalThis?.window?.document?.addEventListener('paste', handlePaste, {
capture: true,
})
const IDLE_TIME_MS = 1000 * 60 * 2
let timeoutIdIdleA: ReturnType<typeof setTimeout> | undefined = undefined
const teardown = () => {
// Already paused
if (streamState === StreamState.Paused) return
videoRef.current?.pause()
setStreamState(StreamState.Paused)
sceneInfra.modelingSend({ type: 'Cancel' })
// Give video time to pause
window.requestAnimationFrame(() => {
engineCommandManager.tearDown({ idleMode: true })
})
}
const onVisibilityChange = () => {
if (globalThis.window.document.visibilityState === 'hidden') {
clearTimeout(timeoutIdIdleA)
timeoutIdIdleA = setTimeout(teardown, IDLE_TIME_MS)
} else if (!engineCommandManager.engineConnection?.isReady()) {
clearTimeout(timeoutIdIdleA)
setStreamState(StreamState.Resuming)
}
}
// Teardown everything if we go hidden or reconnect
if (IDLE) {
globalThis?.window?.document?.addEventListener(
'visibilitychange',
onVisibilityChange
)
}
let timeoutIdIdleB: ReturnType<typeof setTimeout> | undefined = undefined
const onAnyInput = () => {
if (streamState === StreamState.Playing) {
// Clear both timers
clearTimeout(timeoutIdIdleA)
clearTimeout(timeoutIdIdleB)
timeoutIdIdleB = setTimeout(teardown, IDLE_TIME_MS)
}
if (streamState === StreamState.Paused) {
setStreamState(StreamState.Resuming)
}
}
if (IDLE) {
globalThis?.window?.document?.addEventListener('keydown', onAnyInput)
globalThis?.window?.document?.addEventListener('mousemove', onAnyInput)
globalThis?.window?.document?.addEventListener('mousedown', onAnyInput)
globalThis?.window?.document?.addEventListener('scroll', onAnyInput)
globalThis?.window?.document?.addEventListener('touchstart', onAnyInput)
}
if (IDLE) {
timeoutIdIdleB = setTimeout(teardown, IDLE_TIME_MS)
}
/**
* Add a listener to execute code and play the stream
* on initial stream setup.
*/
engineCommandManager.addEventListener(
EngineCommandManagerEvents.SceneReady,
executeCodeAndPlayStream
)
return () => {
engineCommandManager.removeEventListener(
EngineCommandManagerEvents.SceneReady,
executeCodeAndPlayStream
)
globalThis?.window?.document?.removeEventListener('paste', handlePaste, {
capture: true,
})
if (IDLE) {
clearTimeout(timeoutIdIdleA)
clearTimeout(timeoutIdIdleB)
globalThis?.window?.document?.removeEventListener(
'visibilitychange',
onVisibilityChange
)
globalThis?.window?.document?.removeEventListener('keydown', onAnyInput)
globalThis?.window?.document?.removeEventListener(
'mousemove',
onAnyInput
)
globalThis?.window?.document?.removeEventListener(
'mousedown',
onAnyInput
)
globalThis?.window?.document?.removeEventListener('scroll', onAnyInput)
globalThis?.window?.document?.removeEventListener(
'touchstart',
onAnyInput
)
}
}
}, [IDLE, streamState])
useEffect(() => {
if (
typeof window === 'undefined' ||
typeof RTCPeerConnection === 'undefined'
)
return
if (!videoRef.current) return
if (!mediaStream) return
// The browser complains if we try to load a new stream without pausing first.
// Do not immediately play the stream!
// we instead use a setTimeout to play the stream in the next event loop
try {
videoRef.current.srcObject = mediaStream
videoRef.current.pause()
setTimeout(() => {
videoRef.current?.play().catch((e) => {
console.warn('Video playing was prevented', e, videoRef.current)
})
})
} catch (e) {
console.warn('Attempted to pause stream while play was still loading', e)
}
send({
type: 'Set context',
data: {
videoElement: videoRef.current,
},
})
setIsLoading(false)
}, [mediaStream])
const handleClick: MouseEventHandler<HTMLDivElement> = (e) => {
// If we've got no stream or connection, don't do anything
if (!isNetworkOkay) return
if (!videoRef.current) return
// If we're in sketch mode, don't send a engine-side select event
if (state.matches('Sketch')) return
// Only respect default plane selection if we're on a selection command argument
if (
state.matches({ idle: 'showPlanes' }) &&
!(
commandBarState.matches('Gathering arguments') &&
commandBarState.context.currentArgument?.inputType === 'selection'
)
)
return
// If we're mousing up from a camera drag, don't send a select event
if (sceneInfra.camControls.wasDragging === true) return
if (btnName(e.nativeEvent).left) {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
sendSelectEventToEngine(e)
}
}
/**
* On double-click of sketch entities we automatically enter sketch mode with the selected sketch,
* allowing for quick editing of sketches. TODO: This should be moved to a more central place.
*/
const enterSketchModeIfSelectingSketch: MouseEventHandler<HTMLDivElement> = (
e
) => {
if (
!isNetworkOkay ||
!videoRef.current ||
state.matches('Sketch') ||
state.matches({ idle: 'showPlanes' }) ||
sceneInfra.camControls.wasDragging === true ||
!btnName(e.nativeEvent).left
) {
return
}
sendSelectEventToEngine(e)
.then(({ entity_id }) => {
if (!entity_id) {
// No entity selected. This is benign
return
}
const path = getArtifactOfTypes(
{ key: entity_id, types: ['path', 'solid2d', 'segment', 'helix'] },
kclManager.artifactGraph
)
if (err(path)) {
return path
}
sceneInfra.modelingSend({ type: 'Enter sketch' })
})
.catch(reportRejection)
}
return (
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
<div
ref={videoWrapperRef}
className="absolute inset-0 z-0"
id="stream"
data-testid="stream"
onClick={handleClick}
onDoubleClick={enterSketchModeIfSelectingSketch}
onContextMenu={(e) => e.preventDefault()}
onContextMenuCapture={(e) => e.preventDefault()}
>
<video
ref={videoRef}
muted
autoPlay
controls={false}
onPlay={() => setIsLoading(false)}
className="w-full cursor-pointer h-full"
disablePictureInPicture
id="video-stream"
/>
<ClientSideScene
cameraControls={settings.modeling.mouseControls.current}
/>
{(streamState === StreamState.Paused ||
streamState === StreamState.Resuming) && (
<div className="text-center absolute inset-0">
<div
className="flex flex-col items-center justify-center h-screen"
data-testid="paused"
>
<div className="border-primary border p-2 rounded-sm">
<svg
width="8"
height="12"
viewBox="0 0 8 12"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M2 12V0H0V12H2ZM8 12V0H6V12H8Z"
fill="var(--primary)"
/>
</svg>
</div>
<p className="text-base mt-2 text-primary bold">
{streamState === StreamState.Paused && 'Paused'}
{streamState === StreamState.Resuming && 'Resuming'}
</p>
</div>
</div>
)}
{(!isNetworkOkay || isLoading) && (
<div className="text-center absolute inset-0">
<Loading>
{!isNetworkOkay && !isLoading ? (
<span data-testid="loading-stream">Stream disconnected...</span>
) : (
!isLoading && (
<span data-testid="loading-stream">Loading stream...</span>
)
)}
</Loading>
</div>
)}
<ViewControlContextMenu
event="mouseup"
guard={(e) =>
sceneInfra.camControls.wasDragging === false &&
btnName(e).right === true
}
menuTargetElement={videoWrapperRef}
/>
</div>
)
}

View File

@ -46,12 +46,5 @@ export function angleLengthInfo({
selectionRanges.graphSelections.length <= 1 &&
isAllTooltips &&
transforms.every(Boolean)
console.log(
'enabled',
enabled,
selectionRanges.graphSelections.length,
isAllTooltips,
transforms.every(Boolean)
)
return { enabled, transforms }
}

View File

@ -13,6 +13,7 @@ import {
} from '@src/editor/highlightextension'
import type { KclManager } from '@src/lang/KclSingleton'
import type { EngineCommandManager } from '@src/lang/std/engineConnection'
import { isTopLevelModule } from '@src/lang/util'
import { markOnce } from '@src/lib/performance'
import type { Selection, Selections } from '@src/lib/selections'
import { processCodeMirrorRanges } from '@src/lib/selections'
@ -46,7 +47,6 @@ export const setDiagnosticsEvent = setDiagnosticsAnnotation.of(true)
export default class EditorManager {
private _copilotEnabled: boolean = true
private engineCommandManager: EngineCommandManager
private kclManager: KclManager
private _isAllTextSelected: boolean = false
private _isShiftDown: boolean = false
@ -66,13 +66,10 @@ export default class EditorManager {
private _highlightRange: Array<[number, number]> = [[0, 0]]
public _editorView: EditorView | null = null
public kclManager?: KclManager
constructor(
engineCommandManager: EngineCommandManager,
kclManager: KclManager
) {
constructor(engineCommandManager: EngineCommandManager) {
this.engineCommandManager = engineCommandManager
this.kclManager = kclManager
}
setCopilotEnabled(enabled: boolean) {
@ -151,12 +148,12 @@ export default class EditorManager {
selection: Array<Selection['codeRef']['range']>
): Array<[number, number]> {
if (!this._editorView) {
return selection.map((s): [number, number] => {
return selection.filter(isTopLevelModule).map((s): [number, number] => {
return [s[0], s[1]]
})
}
return selection.map((s): [number, number] => {
return selection.filter(isTopLevelModule).map((s): [number, number] => {
const safeEnd = Math.min(s[1], this._editorView?.state.doc.length || s[1])
return [s[0], safeEnd]
})
@ -386,6 +383,11 @@ export default class EditorManager {
}
)
if (!this.kclManager) {
console.error('unreachable')
return
}
const eventInfo = processCodeMirrorRanges({
codeMirrorRanges: viewUpdate.state.selection.ranges,
selectionRanges: this._selectionRanges,

View File

@ -1,5 +1,6 @@
import { useEffect, useRef } from 'react'
import { showSketchOnImportToast } from '@src/components/SketchOnImportToast'
import { useModelingContext } from '@src/hooks/useModelingContext'
import { getNodeFromPath } from '@src/lang/queryAst'
import { getNodePathFromSourceRange } from '@src/lang/queryAstNodePathUtils'
@ -24,10 +25,12 @@ import {
sceneInfra,
} from '@src/lib/singletons'
import { err, reportRejection } from '@src/lib/trap'
import { getModuleId } from '@src/lib/utils'
import type {
EdgeCutInfo,
ExtrudeFacePlane,
} from '@src/machines/modelingMachine'
import toast from 'react-hot-toast'
export function useEngineConnectionSubscriptions() {
const { send, context, state } = useModelingContext()
@ -186,6 +189,29 @@ export function useEngineConnectionSubscriptions() {
faceId,
kclManager.artifactGraph
)
if (!err(extrusion)) {
const fileIndex = getModuleId(extrusion.codeRef.range)
if (fileIndex !== 0) {
const importDetails =
kclManager.execState.filenames[fileIndex]
if (!importDetails) {
toast.error("can't sketch on this face")
return
}
if (importDetails?.type === 'Local') {
const paths = importDetails.value.split('/')
const fileName = paths[paths.length - 1]
showSketchOnImportToast(fileName)
} else if (
importDetails?.type === 'Main' ||
importDetails?.type === 'Std'
) {
toast.error("can't sketch on this face")
} else {
const _exhaustiveCheck: never = importDetails
}
}
}
if (
artifact?.type !== 'cap' &&

View File

@ -1,7 +1,10 @@
import { useEffect } from 'react'
import { NetworkHealthState } from '@src/hooks/useNetworkStatus'
import { isDesktop } from '@src/lib/isDesktop'
import type { WebContentSendPayload } from '@src/menu/channels'
import type { ToolbarModeName } from '@src/lib/toolbar'
import { reportRejection } from '@src/lib/trap'
import { useCommandBarState } from '@src/machines/commandBarMachine'
import type { MenuLabels, WebContentSendPayload } from '@src/menu/channels'
import { useEffect } from 'react'
export function useMenuListener(
callback: (data: WebContentSendPayload) => void
@ -23,3 +26,70 @@ export function useMenuListener(
}
}, [])
}
// Enable disable menu actions specifically based on if you are in the modeling mode of sketching or modeling.
// This is a similar behavior of the command bar which disables action if you are in sketch mode
export function useSketchModeMenuEnableDisable(
currentMode: ToolbarModeName,
overallState: NetworkHealthState,
isExecuting: boolean,
isStreamReady: boolean,
menus: { menuLabel: MenuLabels; commandName?: string; groupId?: string }[]
) {
const commandBarState = useCommandBarState()
const commands = commandBarState.context.commands
useEffect(() => {
const onDesktop = isDesktop()
if (!onDesktop) {
// NO OP for web
return
}
// Same exact logic as the command bar
const disableAllButtons =
(overallState !== NetworkHealthState.Ok &&
overallState !== NetworkHealthState.Weak) ||
isExecuting ||
!isStreamReady
// Enable or disable each menu based on the state of the application.
menus.forEach(({ menuLabel, commandName, groupId }) => {
// If someone goes wrong, disable all the buttons! Engine cannot take this request
if (disableAllButtons) {
window.electron.disableMenu(menuLabel).catch(reportRejection)
return
}
if (commandName && groupId) {
// If your menu is tied to a command bar action, see if the command exists in the command bar
const foundCommand = commands.find((command) => {
return command.name === commandName && command.groupId === groupId
})
if (!foundCommand) {
window.electron.disableMenu(menuLabel).catch(reportRejection)
} else {
if (currentMode === 'sketching') {
window.electron.disableMenu(menuLabel).catch(reportRejection)
} else if (currentMode === 'modeling') {
window.electron.enableMenu(menuLabel).catch(reportRejection)
}
}
} else {
// menu is not tied to a command bar, do the sketch mode check
if (currentMode === 'sketching') {
window.electron.disableMenu(menuLabel).catch(reportRejection)
} else if (currentMode === 'modeling') {
window.electron.enableMenu(menuLabel).catch(reportRejection)
}
}
})
return () => {
if (!onDesktop) {
// NO OP for web
return
}
}
}, [currentMode, commands])
}

View File

@ -25,7 +25,7 @@ export const NetworkContext = createContext<NetworkStatus>({
error: undefined,
setHasCopied: (b: boolean) => {},
hasCopied: false,
pingPongHealth: undefined,
ping: undefined,
} as NetworkStatus)
export const useNetworkContext = () => {
return useContext(NetworkContext)

View File

@ -32,7 +32,7 @@ export interface NetworkStatus {
error: ErrorType | undefined
setHasCopied: (b: boolean) => void
hasCopied: boolean
pingPongHealth: undefined | 'OK' | 'TIMEOUT'
ping: undefined | number
}
// Must be called from one place in the application.
@ -48,9 +48,7 @@ export function useNetworkStatus() {
const [overallState, setOverallState] = useState<NetworkHealthState>(
NetworkHealthState.Disconnected
)
const [pingPongHealth, setPingPongHealth] = useState<
undefined | 'OK' | 'TIMEOUT'
>(undefined)
const [ping, setPing] = useState<undefined | number>(undefined)
const [hasCopied, setHasCopied] = useState<boolean>(false)
const [error, setError] = useState<ErrorType | undefined>(undefined)
@ -73,11 +71,11 @@ export function useNetworkStatus() {
? NetworkHealthState.Disconnected
: hasIssues || hasIssues === undefined
? NetworkHealthState.Issue
: pingPongHealth === 'TIMEOUT'
: (ping ?? 0) > 16.6 * 3 // we consider ping longer than 3 frames as weak
? NetworkHealthState.Weak
: NetworkHealthState.Ok
)
}, [hasIssues, internetConnected, pingPongHealth])
}, [hasIssues, internetConnected, ping])
useEffect(() => {
const onlineCallback = () => {
@ -128,7 +126,7 @@ export function useNetworkStatus() {
useEffect(() => {
const onPingPongChange = ({ detail: state }: CustomEvent) => {
setPingPongHealth(state)
setPing(state)
}
const onConnectionStateChange = ({
@ -233,6 +231,6 @@ export function useNetworkStatus() {
error,
setHasCopied,
hasCopied,
pingPongHealth,
ping,
}
}

View File

@ -1,183 +0,0 @@
import { useAppState, useAppStream } from '@src/AppState'
import { useEffect, useLayoutEffect, useRef } from 'react'
import type { useModelingContext } from '@src/hooks/useModelingContext'
import { useNetworkContext } from '@src/hooks/useNetworkContext'
import {
DisconnectingType,
EngineConnectionStateType,
} from '@src/lang/std/engineConnection'
import type { SettingsViaQueryString } from '@src/lib/settings/settingsTypes'
import { engineCommandManager } from '@src/lib/singletons'
import { Themes } from '@src/lib/theme'
import { deferExecution } from '@src/lib/utils'
export function useSetupEngineManager(
streamRef: React.RefObject<HTMLDivElement>,
modelingSend: ReturnType<typeof useModelingContext>['send'],
modelingContext: ReturnType<typeof useModelingContext>['context'],
settings: SettingsViaQueryString = {
pool: null,
theme: Themes.System,
highlightEdges: true,
enableSSAO: true,
showScaleGrid: false,
cameraProjection: 'perspective',
cameraOrbit: 'spherical',
},
token?: string
) {
const networkContext = useNetworkContext()
const { pingPongHealth, immediateState } = networkContext
const { setAppState } = useAppState()
const { setMediaStream } = useAppStream()
const hasSetNonZeroDimensions = useRef<boolean>(false)
if (settings.pool) {
// override the pool param (?pool=) to request a specific engine instance
// from a particular pool.
engineCommandManager.settings.pool = settings.pool
}
const startEngineInstance = () => {
// Load the engine command manager once with the initial width and height,
// then we do not want to reload it.
const { width: quadWidth, height: quadHeight } = getDimensions(
streamRef?.current?.offsetWidth ?? 0,
streamRef?.current?.offsetHeight ?? 0
)
engineCommandManager.start({
setMediaStream: (mediaStream) => setMediaStream(mediaStream),
setIsStreamReady: (isStreamReady) => setAppState({ isStreamReady }),
width: quadWidth,
height: quadHeight,
token,
settings,
})
hasSetNonZeroDimensions.current = true
}
useLayoutEffect(() => {
const { width: quadWidth, height: quadHeight } = getDimensions(
streamRef?.current?.offsetWidth ?? 0,
streamRef?.current?.offsetHeight ?? 0
)
if (!hasSetNonZeroDimensions.current && quadHeight && quadWidth) {
startEngineInstance()
}
}, [
streamRef?.current?.offsetWidth,
streamRef?.current?.offsetHeight,
modelingSend,
])
useEffect(() => {
if (pingPongHealth === 'TIMEOUT') {
engineCommandManager.tearDown()
}
}, [pingPongHealth])
useEffect(() => {
const intervalId = setInterval(() => {
if (immediateState.type === EngineConnectionStateType.Disconnected) {
engineCommandManager.engineConnection = undefined
startEngineInstance()
}
}, 3000)
return () => {
clearInterval(intervalId)
}
}, [immediateState])
useEffect(() => {
engineCommandManager.settings = settings
const handleResize = deferExecution(() => {
engineCommandManager.handleResize(
getDimensions(
streamRef?.current?.offsetWidth ?? 0,
streamRef?.current?.offsetHeight ?? 0
)
)
}, 500)
const onOnline = () => {
startEngineInstance()
}
const onVisibilityChange = () => {
if (window.document.visibilityState === 'visible') {
if (
!engineCommandManager.engineConnection?.isReady() &&
!engineCommandManager.engineConnection?.isConnecting()
) {
startEngineInstance()
}
}
}
window.document.addEventListener('visibilitychange', onVisibilityChange)
const onAnyInput = () => {
const isEngineNotReadyOrConnecting =
!engineCommandManager.engineConnection?.isReady() &&
!engineCommandManager.engineConnection?.isConnecting()
const conn = engineCommandManager.engineConnection
const isStreamPaused =
conn?.state.type === EngineConnectionStateType.Disconnecting &&
conn?.state.value.type === DisconnectingType.Pause
if (isEngineNotReadyOrConnecting || isStreamPaused) {
engineCommandManager.engineConnection = undefined
startEngineInstance()
}
}
window.document.addEventListener('keydown', onAnyInput)
window.document.addEventListener('mousemove', onAnyInput)
window.document.addEventListener('mousedown', onAnyInput)
window.document.addEventListener('scroll', onAnyInput)
window.document.addEventListener('touchstart', onAnyInput)
const onOffline = () => {
engineCommandManager.tearDown()
}
window.addEventListener('online', onOnline)
window.addEventListener('offline', onOffline)
window.addEventListener('resize', handleResize)
return () => {
window.document.removeEventListener(
'visibilitychange',
onVisibilityChange
)
window.document.removeEventListener('keydown', onAnyInput)
window.document.removeEventListener('mousemove', onAnyInput)
window.document.removeEventListener('mousedown', onAnyInput)
window.document.removeEventListener('scroll', onAnyInput)
window.document.removeEventListener('touchstart', onAnyInput)
window.removeEventListener('online', onOnline)
window.removeEventListener('offline', onOffline)
window.removeEventListener('resize', handleResize)
}
// Engine relies on many settings so we should rebind events when it changes
// We have to list out the ones we care about because the settings object holds
// non-settings too...
}, [...Object.values(settings)])
}
function getDimensions(streamWidth?: number, streamHeight?: number) {
const factorOf = 4
const maxResolution = 2000
const width = streamWidth ? streamWidth : 0
const height = streamHeight ? streamHeight : 0
const ratio = Math.min(
Math.min(maxResolution / width, maxResolution / height),
1.0
)
const quadWidth = Math.round((width * ratio) / factorOf) * factorOf
const quadHeight = Math.round((height * ratio) / factorOf) * factorOf
return { width: quadWidth, height: quadHeight }
}

View File

@ -3,6 +3,10 @@ import type {
EntityType_type,
ModelingCmdReq_type,
} from '@kittycad/lib/dist/types/src/models'
import type { SceneInfra } from '@src/clientSideScene/sceneInfra'
import type EditorManager from '@src/editor/manager'
import type CodeManager from '@src/lang/codeManager'
import type RustContext from '@src/lib/rustContext'
import type { KclValue } from '@rust/kcl-lib/bindings/KclValue'
import type { Node } from '@rust/kcl-lib/bindings/Node'
@ -16,6 +20,7 @@ import {
import { executeAst, executeAstMock, lintAst } from '@src/lang/langHelpers'
import { getNodeFromPath, getSettingsAnnotation } from '@src/lang/queryAst'
import type { EngineCommandManager } from '@src/lang/std/engineConnection'
import { CommandLogType } from '@src/lang/std/engineConnection'
import { topLevelRange } from '@src/lang/util'
import type {
ArtifactGraph,
@ -46,12 +51,7 @@ import type {
KclSettingsAnnotation,
} from '@src/lib/settings/settingsTypes'
import { jsAppSettings } from '@src/lib/settings/settingsUtils'
import {
codeManager,
editorManager,
rustContext,
sceneInfra,
} from '@src/lib/singletons'
import { err, reportRejection } from '@src/lib/trap'
import { deferExecution, isOverlap, uuidv4 } from '@src/lib/utils'
@ -60,6 +60,15 @@ interface ExecuteArgs {
executionId?: number
}
// Each of our singletons has dependencies on _other_ singletons, so importing
// can easily become cyclic. Each will have its own Singletons type.
interface Singletons {
rustContext: RustContext
codeManager: CodeManager
editorManager: EditorManager
sceneInfra: SceneInfra
}
export class KclManager {
/**
* The artifactGraph is a client-side representation of the commands that have been sent
@ -98,6 +107,7 @@ export class KclManager {
private _switchedFiles = false
private _fileSettings: KclSettingsAnnotation = {}
private _kclVersion: string | undefined = undefined
private singletons: Singletons
engineCommandManager: EngineCommandManager
@ -188,7 +198,7 @@ export class KclManager {
}
setDiagnosticsForCurrentErrors() {
editorManager?.setDiagnostics(this.diagnostics)
this.singletons.editorManager?.setDiagnostics(this.diagnostics)
this._diagnosticsCallback(this.diagnostics)
}
@ -225,12 +235,16 @@ export class KclManager {
this._wasmInitFailedCallback(wasmInitFailed)
}
constructor(engineCommandManager: EngineCommandManager) {
constructor(
engineCommandManager: EngineCommandManager,
singletons: Singletons
) {
this.engineCommandManager = engineCommandManager
this.singletons = singletons
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.ensureWasmInit().then(async () => {
await this.safeParse(codeManager.code).then((ast) => {
await this.safeParse(this.singletons.codeManager.code).then((ast) => {
if (ast) {
this.ast = ast
}
@ -296,9 +310,9 @@ export class KclManager {
// If we were switching files and we hit an error on parse we need to bust
// the cache and clear the scene.
if (this._astParseFailed && this._switchedFiles) {
await rustContext.clearSceneAndBustCache(
await this.singletons.rustContext.clearSceneAndBustCache(
{ settings: await jsAppSettings() },
codeManager.currentFilePath || undefined
this.singletons.codeManager.currentFilePath || undefined
)
} else if (this._switchedFiles) {
// Reset the switched files boolean.
@ -421,8 +435,8 @@ export class KclManager {
await this.ensureWasmInit()
const { logs, errors, execState, isInterrupted } = await executeAst({
ast,
path: codeManager.currentFilePath || undefined,
rustContext,
path: this.singletons.codeManager.currentFilePath || undefined,
rustContext: this.singletons.rustContext,
})
// Program was not interrupted, setup the scene
@ -470,10 +484,12 @@ export class KclManager {
await this.updateArtifactGraph(execState.artifactGraph)
this._executeCallback()
if (!isInterrupted) {
sceneInfra.modelingSend({ type: 'code edit during sketch' })
this.singletons.sceneInfra.modelingSend({
type: 'code edit during sketch',
})
}
this.engineCommandManager.addCommandLog({
type: 'execution-done',
type: CommandLogType.ExecutionDone,
data: null,
})
@ -492,7 +508,7 @@ export class KclManager {
this.isExecuting = false
this.executeIsStale = null
this.engineCommandManager.addCommandLog({
type: 'execution-done',
type: CommandLogType.ExecutionDone,
data: null,
})
markOnce('code/endExecuteAst')
@ -518,7 +534,7 @@ export class KclManager {
const { logs, errors, execState } = await executeAstMock({
ast: newAst,
rustContext,
rustContext: this.singletons.rustContext,
})
this._logs = logs
@ -535,7 +551,7 @@ export class KclManager {
})
}
async executeCode(): Promise<void> {
const ast = await this.safeParse(codeManager.code)
const ast = await this.safeParse(this.singletons.codeManager.code)
if (!ast) {
// By clearing the AST we indicate to our callers that there was an issue with execution and
@ -549,7 +565,7 @@ export class KclManager {
}
async format() {
const originalCode = codeManager.code
const originalCode = this.singletons.codeManager.code
const ast = await this.safeParse(originalCode)
if (!ast) {
this.clearAst()
@ -563,10 +579,10 @@ export class KclManager {
if (originalCode === code) return
// Update the code state and the editor.
codeManager.updateCodeStateEditor(code)
this.singletons.codeManager.updateCodeStateEditor(code)
// Write back to the file system.
void codeManager
void this.singletons.codeManager
.writeToFile()
.then(() => this.executeCode())
.catch(reportRejection)
@ -642,7 +658,7 @@ export class KclManager {
}
get defaultPlanes() {
return rustContext.defaultPlanes
return this.singletons.rustContext.defaultPlanes
}
showPlanes(all = false) {

View File

@ -1030,19 +1030,25 @@ sketch003 = startSketchOn(XZ)
describe('Testing splitPipedProfile', () => {
it('should split the pipe expression correctly', () => {
const codeBefore = `part001 = startSketchOn(XZ)
const codeBefore = `// comment 1
part001 = startSketchOn(XZ)
|> startProfileAt([1, 2], %)
// comment 2
|> line([3, 4], %)
|> line([5, 6], %)
|> close(%)
// comment 3
extrude001 = extrude(5, part001)
`
const expectedCodeAfter = `sketch001 = startSketchOn(XZ)
const expectedCodeAfter = `// comment 1
sketch001 = startSketchOn(XZ)
part001 = startProfileAt([1, 2], sketch001)
// comment 2
|> line([3, 4], %)
|> line([5, 6], %)
|> close(%)
// comment 3
extrude001 = extrude(5, part001)
`

View File

@ -3,10 +3,10 @@ import type { Models } from '@kittycad/lib'
import type { BodyItem } from '@rust/kcl-lib/bindings/BodyItem'
import type { Name } from '@rust/kcl-lib/bindings/Name'
import type { Node } from '@rust/kcl-lib/bindings/Node'
import type { NonCodeMeta } from '@rust/kcl-lib/bindings/NonCodeMeta'
import {
createArrayExpression,
createCallExpression,
createCallExpressionStdLib,
createCallExpressionStdLibKw,
createIdentifier,
@ -1642,7 +1642,7 @@ export function splitPipedProfile(
}
| Error {
const _ast = structuredClone(ast)
const varDec = getNodeFromPath<VariableDeclaration>(
const varDec = getNodeFromPath<Node<VariableDeclaration>>(
_ast,
pathToPipe,
'VariableDeclaration'
@ -1666,26 +1666,53 @@ export function splitPipedProfile(
const newVarName = findUniqueName(_ast, 'sketch')
const secondCallArgs = structuredClone(secondCall.arguments)
secondCallArgs[1] = createLocalName(newVarName)
const firstCallOfNewPipe = createCallExpression(
'startProfileAt',
secondCallArgs
)
const newSketch = createVariableDeclaration(
newVarName,
varDec.node.declaration.init.body[0]
)
const newProfile = createVariableDeclaration(
varName,
varDec.node.declaration.init.body.length <= 2
? firstCallOfNewPipe
: createPipeExpression([
firstCallOfNewPipe,
...varDec.node.declaration.init.body.slice(2),
])
)
const startSketchOnBrokenIntoNewVarDec = structuredClone(varDec.node)
const profileBrokenIntoItsOwnVar = structuredClone(varDec.node)
if (
startSketchOnBrokenIntoNewVarDec.declaration.init.type !== 'PipeExpression'
) {
return new Error('clonedVarDec1 is not a PipeExpression')
}
varDec.node.declaration.init =
startSketchOnBrokenIntoNewVarDec.declaration.init.body[0]
varDec.node.declaration.id.name = newVarName
if (profileBrokenIntoItsOwnVar.declaration.init.type !== 'PipeExpression') {
return new Error('clonedVarDec2 is not a PipeExpression')
}
profileBrokenIntoItsOwnVar.declaration.init.body =
profileBrokenIntoItsOwnVar.declaration.init.body.slice(1)
if (
!(
profileBrokenIntoItsOwnVar.declaration.init.body[0].type ===
'CallExpression' &&
profileBrokenIntoItsOwnVar.declaration.init.body[0].callee.name.name ===
'startProfileAt'
)
) {
return new Error('problem breaking pipe, expect startProfileAt to be first')
}
profileBrokenIntoItsOwnVar.declaration.init.body[0].arguments[1] =
createLocalName(newVarName)
profileBrokenIntoItsOwnVar.declaration.id.name = varName
profileBrokenIntoItsOwnVar.preComments = [] // we'll duplicate the comments since the new variable will have it to
// new pipe has one less from the start, so need to decrement comments for them to remain in the same place
if (profileBrokenIntoItsOwnVar.declaration.init?.nonCodeMeta?.nonCodeNodes) {
let decrementedNonCodeMeta: NonCodeMeta['nonCodeNodes'] = {}
decrementedNonCodeMeta =
Object.entries(
profileBrokenIntoItsOwnVar.declaration.init?.nonCodeMeta?.nonCodeNodes
).reduce((acc, [key, value]) => {
acc[Number(key) - 1] = value
return acc
}, decrementedNonCodeMeta) || {}
profileBrokenIntoItsOwnVar.declaration.init.nonCodeMeta.nonCodeNodes =
decrementedNonCodeMeta
}
const index = getBodyIndex(pathToPipe)
if (err(index)) return index
_ast.body.splice(index, 1, newSketch, newProfile)
_ast.body.splice(index + 1, 0, profileBrokenIntoItsOwnVar)
const pathToPlane = structuredClone(pathToPipe)
const pathToProfile = structuredClone(pathToPipe)
pathToProfile[1][0] = index + 1

View File

@ -20,8 +20,7 @@ import {
import { reportRejection } from '@src/lib/trap'
import { binaryToUuid, uuidv4 } from '@src/lib/utils'
// TODO(paultag): This ought to be tweakable.
const pingIntervalMs = 5_000
const pingIntervalMs = 1_000
function isHighlightSetEntity_type(
data: any
@ -191,8 +190,6 @@ export type EngineConnectionState =
| State<EngineConnectionStateType.Disconnecting, DisconnectingValue>
| State<EngineConnectionStateType.Disconnected, void>
export type PingPongState = 'OK' | 'TIMEOUT'
export enum EngineConnectionEvents {
// Fires for each ping-pong success or failure.
PingPongChanged = 'ping-pong-changed', // (state: PingPongState) => void
@ -301,13 +298,18 @@ class EngineConnection extends EventTarget {
public webrtcStatsCollector?: () => Promise<ClientMetrics>
private engineCommandManager: EngineCommandManager
private pingPongSpan: { ping?: Date; pong?: Date }
private pingPongSpan: { ping?: number; pong?: number }
private pingIntervalId: ReturnType<typeof setInterval> = setInterval(
() => {},
60_000
)
isUsingConnectionLite: boolean = false
timeoutToForceConnectId: ReturnType<typeof setTimeout> = setTimeout(
() => {},
3000
)
constructor({
engineCommandManager,
url,
@ -333,74 +335,26 @@ class EngineConnection extends EventTarget {
return
}
// Without an interval ping, our connection will timeout.
// If this.idleMode is true we skip this logic so only reconnect
// happens on mouse move
this.pingIntervalId = setInterval(() => {
if (this.idleMode) return
// Only start a new ping when the other is fulfilled.
if (this.pingPongSpan.ping) {
return
}
switch (this.state.type as EngineConnectionStateType) {
case EngineConnectionStateType.ConnectionEstablished:
// If there was no reply to the last ping, report a timeout and
// teardown the connection.
if (this.pingPongSpan.ping && !this.pingPongSpan.pong) {
this.dispatchEvent(
new CustomEvent(EngineConnectionEvents.PingPongChanged, {
detail: 'TIMEOUT',
})
)
this.state = {
type: EngineConnectionStateType.Disconnecting,
value: {
type: DisconnectingType.Timeout,
},
}
this.disconnectAll()
// Don't start pinging until we're connected.
if (this.state.type !== EngineConnectionStateType.ConnectionEstablished) {
return
}
// Otherwise check the time between was >= pingIntervalMs,
// and if it was, then it's bad network health.
} else if (this.pingPongSpan.ping && this.pingPongSpan.pong) {
if (
Math.abs(
this.pingPongSpan.pong.valueOf() -
this.pingPongSpan.ping.valueOf()
) >= pingIntervalMs
) {
this.dispatchEvent(
new CustomEvent(EngineConnectionEvents.PingPongChanged, {
detail: 'TIMEOUT',
})
)
} else {
this.dispatchEvent(
new CustomEvent(EngineConnectionEvents.PingPongChanged, {
detail: 'OK',
})
)
}
}
this.send({ type: 'ping' })
this.pingPongSpan.ping = new Date()
this.pingPongSpan.pong = undefined
break
case EngineConnectionStateType.Disconnecting:
case EngineConnectionStateType.Disconnected:
// We will do reconnection elsewhere, because we basically need
// to destroy this EngineConnection, and this setInterval loop
// lives inside it. (lee) I might change this in the future so it's
// outside this class.
break
default:
if (this.isConnecting()) break
// Means we never could do an initial connection. Reconnect everything.
if (!this.pingPongSpan.ping) this.connect().catch(reportRejection)
break
this.send({ type: 'ping' })
this.pingPongSpan = {
ping: Date.now(),
pong: undefined,
}
}, pingIntervalMs)
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.connect()
this.connect({ reconnect: false })
}
// SHOULD ONLY BE USED FOR VITESTS
@ -511,7 +465,9 @@ class EngineConnection extends EventTarget {
this.idleMode = opts?.idleMode ?? false
clearInterval(this.pingIntervalId)
if (opts?.idleMode) {
this.disconnectAll()
if (this.idleMode) {
this.state = {
type: EngineConnectionStateType.Disconnecting,
value: {
@ -530,8 +486,6 @@ class EngineConnection extends EventTarget {
type: DisconnectingType.Quit,
},
}
this.disconnectAll()
}
initiateConnectionExclusive(): boolean {
@ -585,8 +539,8 @@ class EngineConnection extends EventTarget {
* This will attempt the full handshake, and retry if the connection
* did not establish.
*/
connect(reconnecting?: boolean): Promise<void> {
// eslint-disable-next-line @typescript-eslint/no-this-alias
connect(args: { reconnect: boolean }): Promise<void> {
// eslint-disable-next-line
const that = this
return new Promise((resolve) => {
if (this.isConnecting() || this.isReady()) {
@ -647,7 +601,7 @@ class EngineConnection extends EventTarget {
// Sometimes the remote end doesn't report the end of candidates.
// They have 3 seconds to.
setTimeout(() => {
this.timeoutToForceConnectId = setTimeout(() => {
if (that.initiateConnectionExclusive()) {
console.warn('connected after 3 second delay')
}
@ -958,7 +912,7 @@ class EngineConnection extends EventTarget {
// Send an initial ping
this.send({ type: 'ping' })
this.pingPongSpan.ping = new Date()
this.pingPongSpan.ping = Date.now()
}
this.websocket.addEventListener('open', this.onWebSocketOpen)
@ -1053,7 +1007,20 @@ class EngineConnection extends EventTarget {
switch (resp.type) {
case 'pong':
this.pingPongSpan.pong = new Date()
this.pingPongSpan.pong = Date.now()
this.dispatchEvent(
new CustomEvent(EngineConnectionEvents.PingPongChanged, {
detail: Math.min(
999,
Math.floor(
this.pingPongSpan.pong - (this.pingPongSpan.ping ?? 0)
)
),
})
)
// Clear the initial ping so our interval ping loop can fire again
// But only after using it above!
this.pingPongSpan.ping = undefined
break
case 'modeling_session_data':
@ -1197,7 +1164,7 @@ class EngineConnection extends EventTarget {
this.websocket.addEventListener('message', this.onWebSocketMessage)
}
if (reconnecting) {
if (args.reconnect) {
createWebSocketConnection()
} else {
this.onNetworkStatusReady = () => {
@ -1210,9 +1177,12 @@ class EngineConnection extends EventTarget {
}
})
}
// Do not change this back to an object or any, we should only be sending the
// WebSocketRequest type!
unreliableSend(message: Models['WebSocketRequest_type']) {
if (this.unreliableDataChannel?.readyState !== 'open') return
// TODO(paultag): Add in logic to determine the connection state and
// take actions if needed?
this.unreliableDataChannel?.send(
@ -1223,7 +1193,7 @@ class EngineConnection extends EventTarget {
// WebSocketRequest type!
send(message: Models['WebSocketRequest_type']) {
// Not connected, don't send anything
if (this.websocket?.readyState === 3) return
if (this.websocket?.readyState !== 1) return
// TODO(paultag): Add in logic to determine the connection state and
// take actions if needed?
@ -1232,6 +1202,8 @@ class EngineConnection extends EventTarget {
)
}
disconnectAll() {
clearTimeout(this.timeoutToForceConnectId)
if (this.websocket?.readyState === 1) {
this.websocket?.close()
}
@ -1261,8 +1233,17 @@ class EngineConnection extends EventTarget {
this.websocket?.readyState === 3
if (closedPc && closedUDC && closedWS) {
// Do not notify the rest of the program that we have cut off anything.
this.state = { type: EngineConnectionStateType.Disconnected }
if (!this.idleMode) {
// Do not notify the rest of the program that we have cut off anything.
this.state = { type: EngineConnectionStateType.Disconnected }
} else {
this.state = {
type: EngineConnectionStateType.Disconnecting,
value: {
type: DisconnectingType.Pause,
},
}
}
this.triggeredStart = false
}
}
@ -1288,23 +1269,32 @@ export interface Subscription<T extends ModelTypes> {
) => void
}
export enum CommandLogType {
SendModeling = 'send-modeling',
SendScene = 'send-scene',
ReceiveReliable = 'receive-reliable',
ExecutionDone = 'execution-done',
ExportDone = 'export-done',
SetDefaultSystemProperties = 'set_default_system_properties',
}
export type CommandLog =
| {
type: 'send-modeling'
type: CommandLogType.SendModeling
data: EngineCommand
}
| {
type: 'send-scene'
type: CommandLogType.SendScene
data: EngineCommand
}
| {
type: 'receive-reliable'
type: CommandLogType.ReceiveReliable
data: OkWebSocketResponseData
id: string
cmd_type?: string
}
| {
type: 'execution-done'
type: CommandLogType.ExecutionDone
data: null
}
@ -1371,8 +1361,6 @@ export class EngineCommandManager extends EventTarget {
height: 1337,
}
elVideo: HTMLVideoElement | null = null
_commandLogCallBack: (command: CommandLog[]) => void = () => {}
subscriptions: {
@ -1525,6 +1513,7 @@ export class EngineCommandManager extends EventTarget {
})
this._camControlsCameraChange()
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.sendSceneCommand({
// CameraControls subscribes to default_camera_get_settings response events
@ -1535,6 +1524,7 @@ export class EngineCommandManager extends EventTarget {
type: 'default_camera_get_settings',
},
})
setIsStreamReady(true)
// Other parts of the application should use this to react on scene ready.
@ -1646,7 +1636,7 @@ export class EngineCommandManager extends EventTarget {
message.request_id
) {
this.addCommandLog({
type: 'receive-reliable',
type: CommandLogType.ReceiveReliable,
data: message.resp,
id: message?.request_id || '',
cmd_type: pending?.command?.cmd?.type,
@ -1680,7 +1670,7 @@ export class EngineCommandManager extends EventTarget {
if (!command) return
if (command.type === 'modeling_cmd_req')
this.addCommandLog({
type: 'receive-reliable',
type: CommandLogType.ReceiveReliable,
data: {
type: 'modeling',
data: {
@ -1722,7 +1712,7 @@ export class EngineCommandManager extends EventTarget {
)
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.engineConnection?.connect()
this.engineConnection?.connect({ reconnect: false })
}
this.engineConnection.addEventListener(
EngineConnectionEvents.ConnectionStarted,
@ -1748,7 +1738,7 @@ export class EngineCommandManager extends EventTarget {
cmd: {
type: 'reconfigure_stream',
...this.streamDimensions,
fps: 60,
fps: 60, // This is required but it does next to nothing
},
}
this.engineConnection?.send(resizeCmd)
@ -1789,7 +1779,10 @@ export class EngineCommandManager extends EventTarget {
} else if (this.engineCommandManager?.engineConnection) {
// @ts-ignore
this.engineCommandManager?.engineConnection?.tearDown(opts)
// @ts-ignore
this.engineCommandManager.engineConnection = null
}
this.engineConnection = undefined
}
async startNewSession() {
this.responseMap = {}
@ -1864,7 +1857,7 @@ export class EngineCommandManager extends EventTarget {
) {
// highlight_set_entity, mouse_move and camera_drag_move are sent over the unreliable channel and are too noisy
this.addCommandLog({
type: 'send-scene',
type: CommandLogType.SendScene,
data: command,
})
}

View File

@ -73,9 +73,6 @@ export const KCL_DEFAULT_DEGREE = `360`
/** The default KCL color expression */
export const KCL_DEFAULT_COLOR = `#3c73ff`
/** localStorage key for the playwright test-specific app settings file */
export const TEST_SETTINGS_FILE_KEY = 'playwright-test-settings'
export const SETTINGS_FILE_NAME = 'settings.toml'
export const TOKEN_FILE_NAME = 'token.txt'
export const PROJECT_SETTINGS_FILE_NAME = 'project.toml'

View File

@ -459,12 +459,13 @@ export const getAppSettingsFilePath = async () => {
const testSettingsPath = await window.electron.getAppTestProperty(
'TEST_SETTINGS_FILE_KEY'
)
if (isTestEnv && !testSettingsPath) return SETTINGS_FILE_NAME
const appConfig = await window.electron.getPath('appData')
const fullPath = isTestEnv
? testSettingsPath
: window.electron.path.join(appConfig, getAppFolderName())
? window.electron.path.resolve(testSettingsPath, '..')
: window.electron.path.resolve(appConfig, getAppFolderName())
try {
await window.electron.stat(fullPath)
} catch (e) {
@ -480,9 +481,10 @@ const getTokenFilePath = async () => {
const testSettingsPath = await window.electron.getAppTestProperty(
'TEST_SETTINGS_FILE_KEY'
)
const appConfig = await window.electron.getPath('appData')
const fullPath = isTestEnv
? testSettingsPath
? window.electron.path.resolve(testSettingsPath, '..')
: window.electron.path.join(appConfig, getAppFolderName())
try {
await window.electron.stat(fullPath)
@ -496,8 +498,15 @@ const getTokenFilePath = async () => {
}
const getTelemetryFilePath = async () => {
const isTestEnv = window.electron.process.env.IS_PLAYWRIGHT === 'true'
const testSettingsPath = await window.electron.getAppTestProperty(
'TEST_SETTINGS_FILE_KEY'
)
const appConfig = await window.electron.getPath('appData')
const fullPath = window.electron.path.join(appConfig, getAppFolderName())
const fullPath = isTestEnv
? window.electron.path.resolve(testSettingsPath, '..')
: window.electron.path.join(appConfig, getAppFolderName())
try {
await window.electron.stat(fullPath)
} catch (e) {
@ -510,8 +519,15 @@ const getTelemetryFilePath = async () => {
}
const getRawTelemetryFilePath = async () => {
const isTestEnv = window.electron.process.env.IS_PLAYWRIGHT === 'true'
const testSettingsPath = await window.electron.getAppTestProperty(
'TEST_SETTINGS_FILE_KEY'
)
const appConfig = await window.electron.getPath('appData')
const fullPath = window.electron.path.join(appConfig, getAppFolderName())
const fullPath = isTestEnv
? window.electron.path.resolve(testSettingsPath, '..')
: window.electron.path.join(appConfig, getAppFolderName())
try {
await window.electron.stat(fullPath)
} catch (e) {
@ -535,9 +551,17 @@ const getProjectSettingsFilePath = async (projectPath: string) => {
}
export const getInitialDefaultDir = async () => {
const isTestEnv = window.electron.process.env.IS_PLAYWRIGHT === 'true'
const testSettingsPath = await window.electron.getAppTestProperty(
'TEST_SETTINGS_FILE_KEY'
)
if (!window.electron) {
return ''
}
if (isTestEnv) {
return testSettingsPath
}
const dir = await window.electron.getPath('documents')
return window.electron.path.join(dir, PROJECT_FOLDER)
}

View File

@ -43,6 +43,7 @@ import {
isOverlap,
uuidv4,
} from '@src/lib/utils'
import { engineStreamActor } from '@src/machines/appMachine'
import type { ModelingMachineEvent } from '@src/machines/modelingMachine'
export const X_AXIS_UUID = 'ad792545-7fd3-482a-a602-a93924e3055b'
@ -649,12 +650,13 @@ export async function sendSelectEventToEngine(
e: React.MouseEvent<HTMLDivElement, MouseEvent>
) {
// No video stream to normalise against, return immediately
if (!engineCommandManager.elVideo)
const engineStreamState = engineStreamActor.getSnapshot().context
if (!engineStreamState.videoRef.current)
return Promise.reject('video element not ready')
const { x, y } = getNormalisedCoordinates(
e,
engineCommandManager.elVideo,
engineStreamState.videoRef.current,
engineCommandManager.streamDimensions
)
const res = await engineCommandManager.sendSceneCommand({

View File

@ -1,4 +1,4 @@
import { useRef } from 'react'
import { useRef, useState } from 'react'
import type { CameraOrbitType } from '@rust/kcl-lib/bindings/CameraOrbitType'
import type { CameraProjectionType } from '@rust/kcl-lib/bindings/CameraProjectionType'
@ -6,6 +6,7 @@ import type { NamedView } from '@rust/kcl-lib/bindings/NamedView'
import type { OnboardingStatus } from '@rust/kcl-lib/bindings/OnboardingStatus'
import { CustomIcon } from '@src/components/CustomIcon'
import { Toggle } from '@src/components/Toggle/Toggle'
import Tooltip from '@src/components/Tooltip'
import type { CameraSystem } from '@src/lib/cameraControls'
import { cameraMouseDragGuards, cameraSystems } from '@src/lib/cameraControls'
@ -123,6 +124,8 @@ export class Setting<T = unknown> {
}
}
const MS_IN_MINUTE = 1000 * 60
export function createSettings() {
return {
/** Settings that affect the behavior of the entire app,
@ -208,12 +211,109 @@ export function createSettings() {
/**
* Stream resource saving behavior toggle
*/
streamIdleMode: new Setting<boolean>({
defaultValue: false,
description: 'Toggle stream idling, saving bandwidth and battery',
validate: (v) => typeof v === 'boolean',
commandConfig: {
inputType: 'boolean',
streamIdleMode: new Setting<number | undefined>({
defaultValue: undefined,
hideOnLevel: 'project',
description: 'Save bandwidth & battery',
validate: (v) =>
v === undefined ||
(typeof v === 'number' &&
v >= 1 * MS_IN_MINUTE &&
v <= 60 * MS_IN_MINUTE),
Component: ({
value: settingValueInStorage,
updateValue: writeSettingValueToStorage,
}) => {
const [timeoutId, setTimeoutId] = useState<
ReturnType<typeof setTimeout> | undefined
>(undefined)
const [preview, setPreview] = useState(
settingValueInStorage === undefined
? settingValueInStorage
: settingValueInStorage / MS_IN_MINUTE
)
const onChangeRange = (e: React.SyntheticEvent) => {
if (
!(
e.isTrusted &&
'value' in e.currentTarget &&
e.currentTarget.value
)
)
return
setPreview(Number(e.currentTarget.value))
}
const onSaveRange = (e: React.SyntheticEvent) => {
if (preview === undefined) return
if (
!(
e.isTrusted &&
'value' in e.currentTarget &&
e.currentTarget.value
)
)
return
writeSettingValueToStorage(
Number(e.currentTarget.value) * MS_IN_MINUTE
)
}
return (
<div className="flex item-center gap-4 m-0 py-0">
<Toggle
name="streamIdleModeToggle"
offLabel="Off"
onLabel="On"
checked={settingValueInStorage !== undefined}
onChange={(event: React.SyntheticEvent<HTMLInputElement>) => {
if (timeoutId) {
return
}
const isChecked = event.currentTarget.checked
clearTimeout(timeoutId)
setTimeoutId(
setTimeout(() => {
const requested = !isChecked ? undefined : 5
setPreview(requested)
writeSettingValueToStorage(
requested === undefined
? undefined
: Number(requested) * MS_IN_MINUTE
)
setTimeoutId(undefined)
}, 100)
)
}}
className="block w-4 h-4"
/>
<div className="flex flex-col grow">
<input
type="range"
onChange={onChangeRange}
onMouseUp={onSaveRange}
onKeyUp={onSaveRange}
onPointerUp={onSaveRange}
disabled={preview === undefined}
value={
preview !== null && preview !== undefined ? preview : 5
}
min={1}
max={60}
step={1}
className="block flex-1"
/>
{preview !== undefined && preview !== null && (
<div>
{preview / MS_IN_MINUTE === 60
? '1 hour'
: preview / MS_IN_MINUTE === 1
? '1 minute'
: preview + ' minutes'}
</div>
)}
</div>
</div>
)
},
}),
allowOrbitInSketchMode: new Setting<boolean>({

View File

@ -1,4 +1,5 @@
import type { Configuration } from '@rust/kcl-lib/bindings/Configuration'
import type { ProjectConfiguration } from '@rust/kcl-lib/bindings/ProjectConfiguration'
import { createSettings } from '@src/lib/settings/initialSettings'
import {
@ -43,11 +44,10 @@ describe(`testing settings initialization`, () => {
},
},
}
const projectConfiguration: DeepPartial<Configuration> = {
const projectConfiguration: DeepPartial<ProjectConfiguration> = {
settings: {
app: {
appearance: {
theme: 'light',
color: 200,
},
},
@ -82,11 +82,10 @@ describe(`testing getAllCurrentSettings`, () => {
},
},
}
const projectConfiguration: DeepPartial<Configuration> = {
const projectConfiguration: DeepPartial<ProjectConfiguration> = {
settings: {
app: {
appearance: {
theme: 'light',
color: 200,
},
},

View File

@ -33,6 +33,10 @@ import { appThemeToTheme } from '@src/lib/theme'
import { err } from '@src/lib/trap'
import type { DeepPartial } from '@src/lib/types'
type OmitNull<T> = T extends null ? undefined : T
const toUndefinedIfNull = (a: any): OmitNull<any> =>
a === null ? undefined : a
/**
* Convert from a rust settings struct into the JS settings struct.
* We do this because the JS settings type has all the fancy shit
@ -49,7 +53,9 @@ export function configurationToSettingsPayload(
: undefined,
onboardingStatus: configuration?.settings?.app?.onboarding_status,
dismissWebBanner: configuration?.settings?.app?.dismiss_web_banner,
streamIdleMode: configuration?.settings?.app?.stream_idle_mode,
streamIdleMode: toUndefinedIfNull(
configuration?.settings?.app?.stream_idle_mode
),
allowOrbitInSketchMode:
configuration?.settings?.app?.allow_orbit_in_sketch_mode,
projectDirectory: configuration?.settings?.project?.directory,
@ -128,7 +134,6 @@ export function projectConfigurationToSettingsPayload(
: undefined,
onboardingStatus: configuration?.settings?.app?.onboarding_status,
dismissWebBanner: configuration?.settings?.app?.dismiss_web_banner,
streamIdleMode: configuration?.settings?.app?.stream_idle_mode,
allowOrbitInSketchMode:
configuration?.settings?.app?.allow_orbit_in_sketch_mode,
namedViews: deepPartialNamedViewsToNamedViews(

View File

@ -10,8 +10,8 @@ import { SceneInfra } from '@src/clientSideScene/sceneInfra'
import type { BaseUnit } from '@src/lib/settings/settingsTypes'
export const codeManager = new CodeManager()
export const engineCommandManager = new EngineCommandManager()
export const rustContext = new RustContext(engineCommandManager)
declare global {
interface Window {
@ -23,21 +23,32 @@ declare global {
// Accessible for tests mostly
window.engineCommandManager = engineCommandManager
// This needs to be after codeManager is created.
export const kclManager = new KclManager(engineCommandManager)
engineCommandManager.kclManager = kclManager
export const sceneInfra = new SceneInfra(engineCommandManager)
engineCommandManager.camControlsCameraChange = sceneInfra.onCameraChange
// This needs to be after sceneInfra and engineCommandManager are is created.
export const editorManager = new EditorManager(engineCommandManager)
// This needs to be after codeManager is created.
// (lee: what??? why?)
export const kclManager = new KclManager(engineCommandManager, {
rustContext,
codeManager,
editorManager,
sceneInfra,
})
// The most obvious of cyclic dependencies.
// This is because the handleOnViewUpdate(viewUpdate: ViewUpdate): void {
// method requires it for the current ast.
// CYCLIC REF
editorManager.kclManager = kclManager
engineCommandManager.kclManager = kclManager
kclManager.sceneInfraBaseUnitMultiplierSetter = (unit: BaseUnit) => {
sceneInfra.baseUnit = unit
}
// This needs to be after sceneInfra and engineCommandManager are is created.
export const editorManager = new EditorManager(engineCommandManager, kclManager)
export const rustContext = new RustContext(engineCommandManager)
export const sceneEntitiesManager = new SceneEntities(
engineCommandManager,
sceneInfra,

3
src/lib/timings.ts Normal file
View File

@ -0,0 +1,3 @@
// 0.25s is the average visual reaction time for humans so we'll go a bit less
// so those exception people don't see.
export const REASONABLE_TIME_TO_REFRESH_STREAM_SIZE = 100

View File

@ -469,3 +469,7 @@ export function binaryToUuid(
hexValues.slice(10, 16).join(''),
].join('-')
}
export function getModuleId(sourceRange: SourceRange) {
return sourceRange[2]
}

View File

@ -1,22 +1,32 @@
import { useSelector } from '@xstate/react'
import type { ActorRefFrom } from 'xstate'
import { createActor, setup, spawnChild } from 'xstate'
import { createSettings } from '@src/lib/settings/initialSettings'
import { authMachine } from '@src/machines/authMachine'
import type { EngineStreamActor } from '@src/machines/engineStreamMachine'
import {
engineStreamContextCreate,
engineStreamMachine,
} from '@src/machines/engineStreamMachine'
import { ACTOR_IDS } from '@src/machines/machineConstants'
import { settingsMachine } from '@src/machines/settingsMachine'
const { AUTH, SETTINGS } = ACTOR_IDS
const { AUTH, SETTINGS, ENGINE_STREAM } = ACTOR_IDS
const appMachineActors = {
[AUTH]: authMachine,
[SETTINGS]: settingsMachine,
[ENGINE_STREAM]: engineStreamMachine,
} as const
const appMachine = setup({
types: {} as {
children: {
auth: typeof AUTH
settings: typeof SETTINGS
}
},
actors: appMachineActors,
}).createMachine({
/** @xstate-layout N4IgpgJg5mDOIC5gF8A0IB2B7CdGgAoBbAQwGMALASwzAEp8QAHLWKgFyqw0YA9EAjACZ0AT0FDkU5EA */
id: 'modeling-app',
entry: [
spawnChild(AUTH, { id: AUTH, systemId: AUTH }),
@ -25,22 +35,33 @@ const appMachine = setup({
systemId: SETTINGS,
input: createSettings(),
}),
spawnChild(ENGINE_STREAM, {
id: ENGINE_STREAM,
systemId: ENGINE_STREAM,
input: engineStreamContextCreate(),
}),
],
})
export const appActor = createActor(appMachine)
export const authActor = appActor.system.get(AUTH) as ActorRefFrom<
typeof authMachine
>
/**
* GOTCHA: the type coercion of this actor works because it is spawned for
* the lifetime of {appActor}, but would not work if it were invoked
* or if it were destroyed under any conditions during {appActor}'s life
*/
export const authActor = appActor.getSnapshot().children.auth!
export const useAuthState = () => useSelector(authActor, (state) => state)
export const useToken = () =>
useSelector(authActor, (state) => state.context.token)
export const useUser = () =>
useSelector(authActor, (state) => state.context.user)
export const settingsActor = appActor.system.get(SETTINGS) as ActorRefFrom<
typeof settingsMachine
>
/**
* GOTCHA: the type coercion of this actor works because it is spawned for
* the lifetime of {appActor}, but would not work if it were invoked
* or if it were destroyed under any conditions during {appActor}'s life
*/
export const settingsActor = appActor.getSnapshot().children.settings!
export const getSettings = () => {
const { currentProject: _, ...settings } = settingsActor.getSnapshot().context
return settings
@ -51,3 +72,7 @@ export const useSettings = () =>
const { currentProject, ...settings } = state.context
return settings
})
export const engineStreamActor = appActor.system.get(
ENGINE_STREAM
) as EngineStreamActor

View File

@ -0,0 +1,320 @@
import { jsAppSettings } from '@src/lib/settings/settingsUtils'
import {
codeManager,
engineCommandManager,
rustContext,
sceneInfra,
} from '@src/lib/singletons'
import type { MutableRefObject } from 'react'
import type { ActorRefFrom } from 'xstate'
import { assign, fromPromise, setup } from 'xstate'
export enum EngineStreamState {
Off = 'off',
On = 'on',
WaitForMediaStream = 'wait-for-media-stream',
Playing = 'playing',
Reconfiguring = 'reconfiguring',
Paused = 'paused',
// The is the state in-between Paused and Playing *specifically that order*.
Resuming = 'resuming',
}
export enum EngineStreamTransition {
SetMediaStream = 'set-media-stream',
SetPool = 'set-pool',
SetAuthToken = 'set-auth-token',
Play = 'play',
Resume = 'resume',
Pause = 'pause',
StartOrReconfigureEngine = 'start-or-reconfigure-engine',
}
export interface EngineStreamContext {
pool: string | null
authToken: string | undefined
mediaStream: MediaStream | null
videoRef: MutableRefObject<HTMLVideoElement | null>
canvasRef: MutableRefObject<HTMLCanvasElement | null>
zoomToFit: boolean
}
export const engineStreamContextCreate = (): EngineStreamContext => ({
pool: null,
authToken: undefined,
mediaStream: null,
videoRef: { current: null },
canvasRef: { current: null },
zoomToFit: true,
})
export function getDimensions(streamWidth: number, streamHeight: number) {
const factorOf = 4
const maxResolution = 2160
const ratio = Math.min(
Math.min(maxResolution / streamWidth, maxResolution / streamHeight),
1.0
)
const quadWidth = Math.round((streamWidth * ratio) / factorOf) * factorOf
const quadHeight = Math.round((streamHeight * ratio) / factorOf) * factorOf
return { width: quadWidth, height: quadHeight }
}
export async function holdOntoVideoFrameInCanvas(
video: HTMLVideoElement,
canvas: HTMLCanvasElement
) {
await video.pause()
canvas.width = video.videoWidth
canvas.height = video.videoHeight
canvas.style.width = video.videoWidth + 'px'
canvas.style.height = video.videoHeight + 'px'
canvas.style.display = 'block'
const ctx = canvas.getContext('2d')
if (!ctx) return
ctx.drawImage(video, 0, 0, canvas.width, canvas.height)
}
export const engineStreamMachine = setup({
types: {
context: {} as EngineStreamContext,
input: {} as EngineStreamContext,
},
actors: {
[EngineStreamTransition.Play]: fromPromise(
async ({
input: { context, params },
}: {
input: { context: EngineStreamContext; params: { zoomToFit: boolean } }
}) => {
const canvas = context.canvasRef.current
if (!canvas) return false
const video = context.videoRef.current
if (!video) return false
const mediaStream = context.mediaStream
if (!mediaStream) return false
// If the video is already playing it means we're doing a reconfigure.
// We don't want to re-run the KCL or touch the video element at all.
if (!video.paused) {
return
}
await sceneInfra.camControls.restoreRemoteCameraStateAndTriggerSync()
video.style.display = 'block'
canvas.style.display = 'none'
video.srcObject = mediaStream
}
),
[EngineStreamTransition.Pause]: fromPromise(
async ({
input: { context },
}: {
input: { context: EngineStreamContext }
}) => {
const video = context.videoRef.current
if (!video) return
await video.pause()
const canvas = context.canvasRef.current
if (!canvas) return
await holdOntoVideoFrameInCanvas(video, canvas)
video.style.display = 'none'
// Before doing anything else clear the cache
// Originally I (lee) had this on the reconnect but it was interfering
// with kclManager.executeCode()?
await rustContext.clearSceneAndBustCache(
{ settings: await jsAppSettings() },
codeManager.currentFilePath || undefined
)
await sceneInfra.camControls.saveRemoteCameraState()
// Make sure we're on the next frame for no flickering between canvas
// and the video elements.
window.requestAnimationFrame(
() =>
void (async () => {
// Destroy the media stream. We will re-establish it. We could
// leave everything at pausing, preventing video decoders from running
// but we can do even better by significantly reducing network
// cards also.
context.mediaStream?.getVideoTracks()[0].stop()
context.mediaStream = null
video.srcObject = null
engineCommandManager.tearDown({ idleMode: true })
})()
)
}
),
[EngineStreamTransition.StartOrReconfigureEngine]: fromPromise(
async ({
input: { context, event },
}: {
input: { context: EngineStreamContext; event: any }
}) => {
if (!context.authToken) return
const video = context.videoRef.current
if (!video) return
const canvas = context.canvasRef.current
if (!canvas) return
const { width, height } = getDimensions(
window.innerWidth,
window.innerHeight
)
video.width = width
video.height = height
const settingsNext = {
// override the pool param (?pool=) to request a specific engine instance
// from a particular pool.
pool: context.pool,
...event.settings,
}
engineCommandManager.settings = settingsNext
window.requestAnimationFrame(() => {
engineCommandManager.start({
setMediaStream: event.onMediaStream,
setIsStreamReady: (isStreamReady: boolean) => {
event.setAppState({ isStreamReady })
},
width,
height,
token: context.authToken,
settings: settingsNext,
})
event.modelingMachineActorSend({
type: 'Set context',
data: {
streamDimensions: {
streamWidth: width,
streamHeight: height,
},
},
})
})
}
),
},
}).createMachine({
initial: EngineStreamState.Off,
context: (initial) => initial.input,
states: {
[EngineStreamState.Off]: {
reenter: true,
on: {
[EngineStreamTransition.SetPool]: {
target: EngineStreamState.Off,
actions: [assign({ pool: ({ context, event }) => event.data.pool })],
},
[EngineStreamTransition.SetAuthToken]: {
target: EngineStreamState.Off,
actions: [
assign({ authToken: ({ context, event }) => event.data.authToken }),
],
},
[EngineStreamTransition.StartOrReconfigureEngine]: {
target: EngineStreamState.On,
},
},
},
[EngineStreamState.On]: {
reenter: true,
invoke: {
src: EngineStreamTransition.StartOrReconfigureEngine,
input: (args) => args,
},
on: {
// Transition requested by engineConnection
[EngineStreamTransition.SetMediaStream]: {
target: EngineStreamState.On,
actions: [
assign({ mediaStream: ({ context, event }) => event.mediaStream }),
],
},
[EngineStreamTransition.Play]: {
target: EngineStreamState.Playing,
actions: [assign({ zoomToFit: () => true })],
},
},
},
[EngineStreamState.Playing]: {
invoke: {
src: EngineStreamTransition.Play,
input: (args) => ({
context: args.context,
params: { zoomToFit: args.context.zoomToFit },
}),
},
on: {
[EngineStreamTransition.StartOrReconfigureEngine]: {
target: EngineStreamState.Reconfiguring,
},
[EngineStreamTransition.Pause]: {
target: EngineStreamState.Paused,
},
},
},
[EngineStreamState.Reconfiguring]: {
invoke: {
src: EngineStreamTransition.StartOrReconfigureEngine,
input: (args) => args,
onDone: {
target: EngineStreamState.Playing,
},
},
},
[EngineStreamState.Paused]: {
invoke: {
src: EngineStreamTransition.Pause,
input: (args) => args,
},
on: {
[EngineStreamTransition.StartOrReconfigureEngine]: {
target: EngineStreamState.Resuming,
},
},
},
[EngineStreamState.Resuming]: {
reenter: true,
invoke: {
src: EngineStreamTransition.StartOrReconfigureEngine,
input: (args) => args,
},
on: {
// The stream can be paused as it's resuming.
[EngineStreamTransition.Pause]: {
target: EngineStreamState.Paused,
},
[EngineStreamTransition.SetMediaStream]: {
actions: [
assign({ mediaStream: ({ context, event }) => event.mediaStream }),
],
},
[EngineStreamTransition.Play]: {
target: EngineStreamState.Playing,
actions: [assign({ zoomToFit: () => false })],
},
},
},
},
})
export type EngineStreamActor = ActorRefFrom<typeof engineStreamMachine>

View File

@ -1,4 +1,5 @@
export const ACTOR_IDS = {
AUTH: 'auth',
SETTINGS: 'settings',
ENGINE_STREAM: 'engine_stream',
} as const

File diff suppressed because one or more lines are too long

View File

@ -103,6 +103,7 @@ const createWindow = (pathToOpen?: string, reuse?: boolean): BrowserWindow => {
if (reuse) {
newWindow = mainWindow
Menu.setApplicationMenu(null)
}
if (!newWindow) {
const primaryDisplay = screen.getPrimaryDisplay()

View File

@ -1,15 +1,27 @@
import { modelingDesignRole } from '@src/menu/designRole'
import { modelingEditRole, projectEditRole } from '@src/menu/editRole'
import { modelingFileRole, projectFileRole } from '@src/menu/fileRole'
import { helpRole } from '@src/menu/helpRole'
import type { ZooMenuItemConstructorOptions } from '@src/menu/roles'
import { modelingViewRole, projectViewRole } from '@src/menu/viewRole'
import type { BrowserWindow } from 'electron'
import { Menu, app } from 'electron'
import os from 'node:os'
import { projectEditRole } from '@src/menu/editRole'
import { projectFileRole } from '@src/menu/fileRole'
import { helpRole } from '@src/menu/helpRole'
import type { ZooMenuItemConstructorOptions } from '@src/menu/roles'
import { projectViewRole } from '@src/menu/viewRole'
const isMac = os.platform() === 'darwin'
/**
* Gotcha
* If you call Menu.setApplicationMenu([<file>,<edit>,<view>,<help>]) on Mac, it will turn <file> into <ApplicationName>
* you need to create a new menu in the 0th index for the <ApplicationName> aka
* Menu.setApplicationMenu([<ApplicationName>,<file>,<edit>,<view>,<help>])
* If you do not do this, <file> will not show up as file. It will be the <ApplicationName> and it contents live under that Menu
* The .setApplicationMenu does not tell you that the 0th index forces it to <ApplicationName> on Mac.
*/
function zooSetApplicationMenu(menu: Electron.Menu) {
Menu.setApplicationMenu(menu)
}
// Default electron menu.
export function buildAndSetMenuForFallback(mainWindow: BrowserWindow) {
const templateMac: ZooMenuItemConstructorOptions[] = [
@ -121,22 +133,60 @@ export function buildAndSetMenuForFallback(mainWindow: BrowserWindow) {
if (isMac) {
const menu = Menu.buildFromTemplate(templateMac)
Menu.setApplicationMenu(menu)
zooSetApplicationMenu(menu)
} else {
const menu = Menu.buildFromTemplate(templateNotMac)
Menu.setApplicationMenu(menu)
zooSetApplicationMenu(menu)
}
}
function appMenuMacOnly() {
let extraBits: ZooMenuItemConstructorOptions[] = []
if (isMac) {
extraBits = [
{
// @ts-ignore This is required for Mac's it will show the app name first. This is safe to ts-ignore, it is a string.
label: app.name,
submenu: [
{ role: 'about' },
{ type: 'separator' },
{ role: 'services' },
{ type: 'separator' },
{ role: 'hide' },
{ role: 'hideOthers' },
{ role: 'unhide' },
{ type: 'separator' },
{ role: 'quit' },
],
},
]
}
return extraBits
}
// This will generate a new menu from the initial state
// All state management from the previous menu is going to be lost.
export function buildAndSetMenuForModelingPage(mainWindow: BrowserWindow) {
return buildAndSetMenuForFallback(mainWindow)
const template = [
// Expand empty elements for environments that are not Mac
...appMenuMacOnly(),
modelingFileRole(mainWindow),
modelingEditRole(mainWindow),
modelingViewRole(mainWindow),
modelingDesignRole(mainWindow),
// Help role is the same for all pages
helpRole(mainWindow),
]
const menu = Menu.buildFromTemplate(template)
zooSetApplicationMenu(menu)
}
// This will generate a new menu from the initial state
// All state management from the previous menu is going to be lost.
export function buildAndSetMenuForProjectPage(mainWindow: BrowserWindow) {
const template = [
// Expand empty elements for environments that are not Mac
...appMenuMacOnly(),
projectFileRole(mainWindow),
projectEditRole(mainWindow),
projectViewRole(mainWindow),
@ -144,7 +194,7 @@ export function buildAndSetMenuForProjectPage(mainWindow: BrowserWindow) {
helpRole(mainWindow),
]
const menu = Menu.buildFromTemplate(template)
Menu.setApplicationMenu(menu)
zooSetApplicationMenu(menu)
}
// Try to enable the menu based on the application menu

View File

@ -7,6 +7,12 @@ export type MenuLabels =
| 'Help.Command Palette...'
| 'Help.Refresh and report a bug'
| 'Help.Reset onboarding'
| 'Edit.Rename project'
| 'Edit.Delete project'
| 'Edit.Change project directory'
| 'Edit.Modify with Zoo Text-To-CAD'
| 'Edit.Edit parameter'
| 'Edit.Format code'
| 'File.New project'
| 'File.Open project'
| 'File.Import file from URL'
@ -16,10 +22,50 @@ export type MenuLabels =
| 'File.Preferences.Theme'
| 'File.Preferences.Theme color'
| 'File.Sign out'
| 'Edit.Rename project'
| 'Edit.Delete project'
| 'Edit.Change project directory'
| 'File.Create new file'
| 'File.Create new folder'
| 'File.Load a sample model'
| 'File.Export current part'
| 'File.Share current part (via Zoo link)'
| 'File.Preferences.Project settings'
| 'Design.Start sketch'
| 'Design.Create an offset plane'
| 'Design.Create a helix'
| 'Design.Create a parameter'
| 'Design.Create an additive feature.Extrude'
| 'Design.Create an additive feature.Revolve'
| 'Design.Create an additive feature.Sweep'
| 'Design.Create an additive feature.Loft'
| 'Design.Apply modification feature.Fillet'
| 'Design.Apply modification feature.Chamfer'
| 'Design.Apply modification feature.Shell'
| 'Design.Create with Zoo Text-To-CAD'
| 'Design.Modify with Zoo Text-To-CAD'
| 'View.Command Palette...'
| 'View.Orthographic view'
| 'View.Perspective view'
| 'View.Standard views.Right view'
| 'View.Standard views.Back view'
| 'View.Standard views.Top view'
| 'View.Standard views.Left view'
| 'View.Standard views.Front view'
| 'View.Standard views.Bottom view'
| 'View.Standard views.Reset view'
| 'View.Standard views.Center view on selection'
| 'View.Standard views.Refresh'
| 'View.Named views.Create named view'
| 'View.Named views.Load named view'
| 'View.Named views.Delete named view'
| 'View.Panes.Feature tree'
| 'View.Panes.KCL code'
| 'View.Panes.Project files'
| 'View.Panes.Variables'
| 'View.Panes.Logs'
| 'View.Panes.Debug'
| 'View.Standard views'
| 'View.Named views'
| 'Design.Create an additive feature'
| 'Design.Apply modification feature'
export type WebContentSendPayload = {
menuLabel: MenuLabels

145
src/menu/designRole.ts Normal file
View File

@ -0,0 +1,145 @@
import { typeSafeWebContentsSend } from '@src/menu/channels'
import type { ZooMenuItemConstructorOptions } from '@src/menu/roles'
import type { BrowserWindow } from 'electron'
export const modelingDesignRole = (
mainWindow: BrowserWindow
): ZooMenuItemConstructorOptions => {
return {
label: 'Design',
submenu: [
{
label: 'Start sketch',
id: 'Design.Start sketch',
click: () => {
typeSafeWebContentsSend(mainWindow, 'menu-action-clicked', {
menuLabel: 'Design.Start sketch',
})
},
},
{ type: 'separator' },
{
label: 'Create an offset plane',
id: 'Design.Create an offset plane',
click: () => {
typeSafeWebContentsSend(mainWindow, 'menu-action-clicked', {
menuLabel: 'Design.Create an offset plane',
})
},
},
{
label: 'Create a helix',
id: 'Design.Create a helix',
click: () => {
typeSafeWebContentsSend(mainWindow, 'menu-action-clicked', {
menuLabel: 'Design.Create a helix',
})
},
},
{
label: 'Create a parameter',
id: 'Design.Create a parameter',
click: () => {
typeSafeWebContentsSend(mainWindow, 'menu-action-clicked', {
menuLabel: 'Design.Create a parameter',
})
},
},
{ type: 'separator' },
{
label: 'Create an additive feature',
id: 'Design.Create an additive feature',
submenu: [
{
label: 'Extrude',
id: 'Design.Create an additive feature.Extrude',
click: () => {
typeSafeWebContentsSend(mainWindow, 'menu-action-clicked', {
menuLabel: 'Design.Create an additive feature.Extrude',
})
},
},
{
label: 'Revolve',
id: 'Design.Create an additive feature.Revolve',
click: () => {
typeSafeWebContentsSend(mainWindow, 'menu-action-clicked', {
menuLabel: 'Design.Create an additive feature.Revolve',
})
},
},
{
label: 'Sweep',
id: 'Design.Create an additive feature.Sweep',
click: () => {
typeSafeWebContentsSend(mainWindow, 'menu-action-clicked', {
menuLabel: 'Design.Create an additive feature.Sweep',
})
},
},
{
label: 'Loft',
id: 'Design.Create an additive feature.Loft',
click: () => {
typeSafeWebContentsSend(mainWindow, 'menu-action-clicked', {
menuLabel: 'Design.Create an additive feature.Loft',
})
},
},
],
},
{
label: 'Apply modification feature',
id: 'Design.Apply modification feature',
submenu: [
{
label: 'Fillet',
id: 'Design.Apply modification feature.Fillet',
click: () => {
typeSafeWebContentsSend(mainWindow, 'menu-action-clicked', {
menuLabel: 'Design.Apply modification feature.Fillet',
})
},
},
{
label: 'Chamfer',
id: 'Design.Apply modification feature.Chamfer',
click: () => {
typeSafeWebContentsSend(mainWindow, 'menu-action-clicked', {
menuLabel: 'Design.Apply modification feature.Chamfer',
})
},
},
{
label: 'Shell',
id: 'Design.Apply modification feature.Shell',
click: () => {
typeSafeWebContentsSend(mainWindow, 'menu-action-clicked', {
menuLabel: 'Design.Apply modification feature.Shell',
})
},
},
],
},
{ type: 'separator' },
{
label: 'Create with Zoo Text-To-CAD',
id: 'Design.Create with Zoo Text-To-CAD',
click: () => {
typeSafeWebContentsSend(mainWindow, 'menu-action-clicked', {
menuLabel: 'Design.Create with Zoo Text-To-CAD',
})
},
},
{
label: 'Modify with Zoo Text-To-CAD',
id: 'Design.Modify with Zoo Text-To-CAD',
click: () => {
typeSafeWebContentsSend(mainWindow, 'menu-action-clicked', {
menuLabel: 'Design.Modify with Zoo Text-To-CAD',
})
},
},
],
}
}

View File

@ -68,3 +68,94 @@ export const projectEditRole = (
],
}
}
export const modelingEditRole = (
mainWindow: BrowserWindow
): ZooMenuItemConstructorOptions => {
let extraBits: ZooMenuItemConstructorOptions[] = [
{ role: 'delete' },
{ type: 'separator' },
{ role: 'selectAll' },
]
if (isMac) {
extraBits = [
{ role: 'pasteAndMatchStyle' },
{ role: 'delete' },
{ role: 'selectAll' },
{ type: 'separator' },
{
label: 'Speech',
submenu: [{ role: 'startSpeaking' }, { role: 'stopSpeaking' }],
},
]
}
return {
label: 'Edit',
submenu: [
{
label: 'Modify with Zoo Text-To-CAD',
id: 'Edit.Modify with Zoo Text-To-CAD',
click: () => {
typeSafeWebContentsSend(mainWindow, 'menu-action-clicked', {
menuLabel: 'Edit.Modify with Zoo Text-To-CAD',
})
},
},
{
label: 'Edit parameter',
id: 'Edit.Edit parameter',
click: () => {
typeSafeWebContentsSend(mainWindow, 'menu-action-clicked', {
menuLabel: 'Edit.Edit parameter',
})
},
},
{
label: 'Format code',
id: 'Edit.Format code',
click: () => {
typeSafeWebContentsSend(mainWindow, 'menu-action-clicked', {
menuLabel: 'Edit.Format code',
})
},
},
{ type: 'separator' },
{
label: 'Rename project',
id: 'Edit.Rename project',
click: () => {
typeSafeWebContentsSend(mainWindow, 'menu-action-clicked', {
menuLabel: 'Edit.Rename project',
})
},
},
{
label: 'Delete project',
id: 'Edit.Delete project',
click: () => {
typeSafeWebContentsSend(mainWindow, 'menu-action-clicked', {
menuLabel: 'Edit.Delete project',
})
},
},
{ type: 'separator' },
{
label: 'Change project directory',
id: 'Edit.Change project directory',
click: () => {
typeSafeWebContentsSend(mainWindow, 'menu-action-clicked', {
menuLabel: 'Edit.Change project directory',
})
},
},
{ type: 'separator' },
{ role: 'undo' },
{ role: 'redo' },
{ type: 'separator' },
{ role: 'cut' },
{ role: 'copy' },
{ role: 'paste' },
...extraBits,
],
}
}

View File

@ -100,3 +100,153 @@ export const projectFileRole = (
],
}
}
export const modelingFileRole = (
mainWindow: BrowserWindow
): ZooMenuItemConstructorOptions => {
return {
label: 'File',
submenu: [
// TODO: Once a safe command bar create new file and folder is implemented we can turn these on
// {
// label: 'Create new file',
// click: () => {
// typeSafeWebContentsSend(mainWindow, 'menu-action-clicked', {
// menuLabel: 'File.Create new file',
// })
// },
// },
// {
// label: 'Create new folder',
// click: () => {
// typeSafeWebContentsSend(mainWindow, 'menu-action-clicked', {
// menuLabel: 'File.Create new folder',
// })
// },
// },
{
label: 'New project',
id: 'File.New project',
accelerator: 'CommandOrControl+N',
click: () => {
typeSafeWebContentsSend(mainWindow, 'menu-action-clicked', {
menuLabel: 'File.New project',
})
},
},
{
label: 'Open project',
id: 'File.Open project',
accelerator: 'CommandOrControl+P',
click: () => {
typeSafeWebContentsSend(mainWindow, 'menu-action-clicked', {
menuLabel: 'File.Open project',
})
},
},
// TODO https://www.electronjs.org/docs/latest/tutorial/recent-documents
// Appears to be only Windows and Mac OS specific. Linux does not have support
{ type: 'separator' },
{
label: 'Load a sample model',
id: 'File.Load a sample model',
click: () => {
typeSafeWebContentsSend(mainWindow, 'menu-action-clicked', {
menuLabel: 'File.Load a sample model',
})
},
},
{ type: 'separator' },
{
label: 'Export current part',
id: 'File.Export current part',
click: () => {
typeSafeWebContentsSend(mainWindow, 'menu-action-clicked', {
menuLabel: 'File.Export current part',
})
},
},
{
label: 'Share current part (via Zoo link)',
id: 'File.Share current part (via Zoo link)',
click: () => {
typeSafeWebContentsSend(mainWindow, 'menu-action-clicked', {
menuLabel: 'File.Share current part (via Zoo link)',
})
},
},
{ type: 'separator' },
{
label: 'Preferences',
submenu: [
{
label: 'Project settings',
id: 'File.Preferences.Project settings',
click: () => {
typeSafeWebContentsSend(mainWindow, 'menu-action-clicked', {
menuLabel: 'File.Preferences.Project settings',
})
},
},
{
label: 'User settings',
id: 'File.Preferences.User settings',
click: () => {
typeSafeWebContentsSend(mainWindow, 'menu-action-clicked', {
menuLabel: 'File.Preferences.User settings',
})
},
},
{
label: 'Keybindings',
id: 'File.Preferences.Keybindings',
click: () => {
typeSafeWebContentsSend(mainWindow, 'menu-action-clicked', {
menuLabel: 'File.Preferences.Keybindings',
})
},
},
{
label: 'User default units',
id: 'File.Preferences.User default units',
click: () => {
typeSafeWebContentsSend(mainWindow, 'menu-action-clicked', {
menuLabel: 'File.Preferences.User default units',
})
},
},
{
label: 'Theme',
id: 'File.Preferences.Theme',
click: () => {
typeSafeWebContentsSend(mainWindow, 'menu-action-clicked', {
menuLabel: 'File.Preferences.Theme',
})
},
},
{
label: 'Theme color',
id: 'File.Preferences.Theme color',
click: () => {
typeSafeWebContentsSend(mainWindow, 'menu-action-clicked', {
menuLabel: 'File.Preferences.Theme color',
})
},
},
],
},
{ type: 'separator' },
// Last in list
{
label: 'Sign out',
id: 'File.Sign out',
click: () => {
typeSafeWebContentsSend(mainWindow, 'menu-action-clicked', {
menuLabel: 'File.Sign out',
})
},
},
isMac ? { role: 'close' } : { role: 'quit' },
],
}
}

264
src/menu/register.ts Normal file
View File

@ -0,0 +1,264 @@
import { AxisNames } from '@src/lib/constants'
import { copyFileShareLink } from '@src/lib/links'
import { PATHS } from '@src/lib/paths'
import type { Project } from '@src/lib/project'
import type { SettingsType } from '@src/lib/settings/initialSettings'
import {
codeManager,
engineCommandManager,
sceneInfra,
} from '@src/lib/singletons'
import { reportRejection } from '@src/lib/trap'
import { uuidv4 } from '@src/lib/utils'
import { authActor, settingsActor } from '@src/machines/appMachine'
import { commandBarActor } from '@src/machines/commandBarMachine'
import type { WebContentSendPayload } from '@src/menu/channels'
import type { NavigateFunction } from 'react-router-dom'
export function modelingMenuCallbackMostActions(
settings: SettingsType,
navigate: NavigateFunction,
filePath: string,
project: Project | undefined,
token: string | undefined
) {
// Menu listeners
const cb = (data: WebContentSendPayload) => {
if (data.menuLabel === 'File.New project') {
commandBarActor.send({
type: 'Find and select command',
data: {
groupId: 'projects',
name: 'Create project',
argDefaultValues: {
name: settings.projects.defaultProjectName.current,
},
},
})
} else if (data.menuLabel === 'File.Open project') {
commandBarActor.send({
type: 'Find and select command',
data: {
groupId: 'projects',
name: 'Open project',
},
})
} else if (data.menuLabel === 'Edit.Rename project') {
commandBarActor.send({
type: 'Find and select command',
data: {
groupId: 'projects',
name: 'Rename project',
},
})
} else if (data.menuLabel === 'Edit.Delete project') {
commandBarActor.send({
type: 'Find and select command',
data: {
groupId: 'projects',
name: 'Delete project',
},
})
} else if (data.menuLabel === 'File.Preferences.User settings') {
navigate(filePath + PATHS.SETTINGS_USER)
} else if (data.menuLabel === 'File.Preferences.Keybindings') {
navigate(filePath + PATHS.SETTINGS_KEYBINDINGS)
} else if (data.menuLabel === 'Edit.Change project directory') {
navigate(filePath + PATHS.SETTINGS_USER + '#projectDirectory')
} else if (data.menuLabel === 'File.Preferences.Project settings') {
navigate(filePath + PATHS.SETTINGS_PROJECT)
} else if (data.menuLabel === 'File.Sign out') {
authActor.send({ type: 'Log out' })
} else if (
data.menuLabel === 'View.Command Palette...' ||
data.menuLabel === 'Help.Command Palette...'
) {
commandBarActor.send({ type: 'Open' })
} else if (data.menuLabel === 'File.Preferences.Theme') {
commandBarActor.send({
type: 'Find and select command',
data: {
groupId: 'settings',
name: 'app.theme',
},
})
} else if (data.menuLabel === 'File.Preferences.Theme color') {
navigate(filePath + PATHS.SETTINGS_USER + '#themeColor')
} else if (data.menuLabel === 'File.Share current part (via Zoo link)') {
copyFileShareLink({
token: token ?? '',
code: codeManager.code,
name: project?.name || '',
}).catch(reportRejection)
} else if (data.menuLabel === 'File.Preferences.User default units') {
navigate(filePath + PATHS.SETTINGS_USER + '#defaultUnit')
} else if (data.menuLabel === 'File.Export current part') {
commandBarActor.send({
type: 'Find and select command',
data: {
groupId: 'modeling',
name: 'Export',
},
})
} else if (data.menuLabel === 'File.Load a sample model') {
commandBarActor.send({
type: 'Find and select command',
data: {
groupId: 'code',
name: 'open-kcl-example',
},
})
} else if (data.menuLabel === 'File.Create new file') {
// NO OP. A safe command bar create new file is not implemented yet.
} else if (data.menuLabel === 'Edit.Modify with Zoo Text-To-CAD') {
commandBarActor.send({
type: 'Find and select command',
data: { name: 'Prompt-to-edit', groupId: 'modeling' },
})
} else if (data.menuLabel === 'Edit.Edit parameter') {
commandBarActor.send({
type: 'Find and select command',
data: { name: 'event.parameter.edit', groupId: 'modeling' },
})
} else if (data.menuLabel === 'Edit.Format code') {
commandBarActor.send({
type: 'Find and select command',
data: { name: 'format-code', groupId: 'code' },
})
} else if (data.menuLabel === 'View.Orthographic view') {
settingsActor.send({
type: 'set.modeling.cameraProjection',
data: {
level: 'user',
value: 'orthographic',
},
})
} else if (data.menuLabel === 'View.Perspective view') {
settingsActor.send({
type: 'set.modeling.cameraProjection',
data: {
level: 'user',
value: 'perspective',
},
})
} else if (data.menuLabel === 'View.Standard views.Right view') {
sceneInfra.camControls
.updateCameraToAxis(AxisNames.X)
.catch(reportRejection)
} else if (data.menuLabel === 'View.Standard views.Back view') {
sceneInfra.camControls
.updateCameraToAxis(AxisNames.Y)
.catch(reportRejection)
} else if (data.menuLabel === 'View.Standard views.Top view') {
sceneInfra.camControls
.updateCameraToAxis(AxisNames.Z)
.catch(reportRejection)
} else if (data.menuLabel === 'View.Standard views.Left view') {
sceneInfra.camControls
.updateCameraToAxis(AxisNames.NEG_X)
.catch(reportRejection)
} else if (data.menuLabel === 'View.Standard views.Front view') {
sceneInfra.camControls
.updateCameraToAxis(AxisNames.NEG_Y)
.catch(reportRejection)
} else if (data.menuLabel === 'View.Standard views.Bottom view') {
sceneInfra.camControls
.updateCameraToAxis(AxisNames.NEG_Z)
.catch(reportRejection)
} else if (data.menuLabel === 'View.Standard views.Reset view') {
sceneInfra.camControls.resetCameraPosition().catch(reportRejection)
} else if (
data.menuLabel === 'View.Standard views.Center view on selection'
) {
// Gotcha: out of band from modelingMachineProvider, has no state or extra workflows. I am taking the function's logic and porting it here.
engineCommandManager
.sendSceneCommand({
type: 'modeling_cmd_req',
cmd_id: uuidv4(),
cmd: {
type: 'default_camera_center_to_selection',
camera_movement: 'vantage',
},
})
.catch(reportRejection)
} else if (data.menuLabel === 'View.Standard views.Refresh') {
globalThis?.window?.location.reload()
} else if (data.menuLabel === 'View.Named views.Create named view') {
commandBarActor.send({
type: 'Find and select command',
data: { name: 'Create named view', groupId: 'namedViews' },
})
} else if (data.menuLabel === 'View.Named views.Load named view') {
commandBarActor.send({
type: 'Find and select command',
data: { name: 'Load named view', groupId: 'namedViews' },
})
} else if (data.menuLabel === 'View.Named views.Delete named view') {
commandBarActor.send({
type: 'Find and select command',
data: { name: 'Delete named view', groupId: 'namedViews' },
})
} else if (data.menuLabel === 'Design.Create an offset plane') {
commandBarActor.send({
type: 'Find and select command',
data: { name: 'Offset plane', groupId: 'modeling' },
})
} else if (data.menuLabel === 'Design.Create a helix') {
commandBarActor.send({
type: 'Find and select command',
data: { name: 'Helix', groupId: 'modeling' },
})
} else if (data.menuLabel === 'Design.Create a parameter') {
commandBarActor.send({
type: 'Find and select command',
data: { name: 'event.parameter.create', groupId: 'modeling' },
})
} else if (data.menuLabel === 'Design.Create an additive feature.Extrude') {
commandBarActor.send({
type: 'Find and select command',
data: { name: 'Extrude', groupId: 'modeling' },
})
} else if (data.menuLabel === 'Design.Create an additive feature.Revolve') {
commandBarActor.send({
type: 'Find and select command',
data: { name: 'Revolve', groupId: 'modeling' },
})
} else if (data.menuLabel === 'Design.Create an additive feature.Sweep') {
commandBarActor.send({
type: 'Find and select command',
data: { name: 'Sweep', groupId: 'modeling' },
})
} else if (data.menuLabel === 'Design.Create an additive feature.Loft') {
commandBarActor.send({
type: 'Find and select command',
data: { name: 'Loft', groupId: 'modeling' },
})
} else if (data.menuLabel === 'Design.Apply modification feature.Fillet') {
commandBarActor.send({
type: 'Find and select command',
data: { name: 'Fillet', groupId: 'modeling' },
})
} else if (data.menuLabel === 'Design.Apply modification feature.Chamfer') {
commandBarActor.send({
type: 'Find and select command',
data: { name: 'Chamfer', groupId: 'modeling' },
})
} else if (data.menuLabel === 'Design.Apply modification feature.Shell') {
commandBarActor.send({
type: 'Find and select command',
data: { name: 'Shell', groupId: 'modeling' },
})
} else if (data.menuLabel === 'Design.Create with Zoo Text-To-CAD') {
commandBarActor.send({
type: 'Find and select command',
data: { name: 'Text-to-CAD', groupId: 'modeling' },
})
} else if (data.menuLabel === 'Design.Modify with Zoo Text-To-CAD') {
commandBarActor.send({
type: 'Find and select command',
data: { name: 'Prompt-to-edit', groupId: 'modeling' },
})
}
}
return cb
}

View File

@ -21,6 +21,12 @@ type FileRoleLabel =
| 'Sign out'
| 'Theme'
| 'Theme color'
| 'Export current part'
| 'Create new file'
| 'Create new folder'
| 'Share current part (via Zoo link)'
| 'Project settings'
| 'Load a sample model'
| 'User default units'
type EditRoleLabel =
@ -28,6 +34,9 @@ type EditRoleLabel =
| 'Delete project'
| 'Change project directory'
| 'Speech'
| 'Edit parameter'
| 'Modify with Zoo Text-To-CAD'
| 'Format code'
type HelpRoleLabel =
| 'Refresh and report a bug'
@ -42,7 +51,49 @@ type HelpRoleLabel =
| 'Get started with Text-to-CAD'
| 'Show all commands'
type ViewRoleLabel = 'Command Palette...' | 'Appearance'
type ViewRoleLabel =
| 'Command Palette...'
| 'Appearance'
| 'Panes'
| 'Feature tree'
| 'KCL code'
| 'Project files'
| 'Variables'
| 'Logs'
| 'Debug'
| 'Standard views'
| 'Orthographic view'
| 'Perspective view'
| 'Right view'
| 'Back view'
| 'Top view'
| 'Left view'
| 'Front view'
| 'Bottom view'
| 'Reset view'
| 'Center view on selection'
| 'Refresh'
| 'Named views'
| 'Create named view'
| 'Load named view'
| 'Delete named view'
type DesignRoleLabel =
| 'Design'
| 'Create a parameter'
| 'Create with Zoo Text-To-CAD'
| 'Start sketch'
| 'Create an offset plane'
| 'Create a helix'
| 'Create an additive feature'
| 'Extrude'
| 'Revolve'
| 'Sweep'
| 'Loft'
| 'Apply modification feature'
| 'Fillet'
| 'Chamfer'
| 'Shell'
// Only export the union of all the internal types since they are all labels
// The internal types are only for readability within the file
@ -52,6 +103,7 @@ export type ZooLabel =
| EditRoleLabel
| HelpRoleLabel
| ViewRoleLabel
| DesignRoleLabel
// Extend the interface with additional custom properties
export interface ZooMenuItemConstructorOptions

View File

@ -47,3 +47,244 @@ export const projectViewRole = (
],
}
}
export const modelingViewRole = (
mainWindow: BrowserWindow
): ZooMenuItemConstructorOptions => {
let extraBits: ZooMenuItemConstructorOptions[] = [{ role: 'close' }]
if (isMac) {
extraBits = [
{ type: 'separator' },
{ role: 'front' },
{ type: 'separator' },
{ role: 'window' },
]
}
return {
label: 'View',
submenu: [
{
label: 'Command Palette...',
id: 'View.Command Palette...',
click: () => {
typeSafeWebContentsSend(mainWindow, 'menu-action-clicked', {
menuLabel: 'View.Command Palette...',
})
},
},
{ type: 'separator' },
{
label: 'Orthographic view',
id: 'View.Orthographic view',
click: () => {
typeSafeWebContentsSend(mainWindow, 'menu-action-clicked', {
menuLabel: 'View.Orthographic view',
})
},
},
{
label: 'Perspective view',
id: 'View.Perspective view',
click: () => {
typeSafeWebContentsSend(mainWindow, 'menu-action-clicked', {
menuLabel: 'View.Perspective view',
})
},
},
{ type: 'separator' },
{
label: 'Standard views',
id: 'View.Standard views',
submenu: [
{
label: 'Right view',
id: 'View.Standard views.Right view',
click: () => {
typeSafeWebContentsSend(mainWindow, 'menu-action-clicked', {
menuLabel: 'View.Standard views.Right view',
})
},
},
{
label: 'Back view',
id: 'View.Standard views.Back view',
click: () => {
typeSafeWebContentsSend(mainWindow, 'menu-action-clicked', {
menuLabel: 'View.Standard views.Back view',
})
},
},
{
label: 'Top view',
id: 'View.Standard views.Top view',
click: () => {
typeSafeWebContentsSend(mainWindow, 'menu-action-clicked', {
menuLabel: 'View.Standard views.Top view',
})
},
},
{
label: 'Left view',
id: 'View.Standard views.Left view',
click: () => {
typeSafeWebContentsSend(mainWindow, 'menu-action-clicked', {
menuLabel: 'View.Standard views.Left view',
})
},
},
{
label: 'Front view',
id: 'View.Standard views.Front view',
click: () => {
typeSafeWebContentsSend(mainWindow, 'menu-action-clicked', {
menuLabel: 'View.Standard views.Front view',
})
},
},
{
label: 'Bottom view',
id: 'View.Standard views.Bottom view',
click: () => {
typeSafeWebContentsSend(mainWindow, 'menu-action-clicked', {
menuLabel: 'View.Standard views.Bottom view',
})
},
},
{ type: 'separator' },
{
label: 'Reset view',
id: 'View.Standard views.Reset view',
click: () => {
typeSafeWebContentsSend(mainWindow, 'menu-action-clicked', {
menuLabel: 'View.Standard views.Reset view',
})
},
},
{
label: 'Center view on selection',
id: 'View.Standard views.Center view on selection',
click: () => {
typeSafeWebContentsSend(mainWindow, 'menu-action-clicked', {
menuLabel: 'View.Standard views.Center view on selection',
})
},
},
{
label: 'Refresh',
id: 'View.Standard views.Refresh',
click: () => {
typeSafeWebContentsSend(mainWindow, 'menu-action-clicked', {
menuLabel: 'View.Standard views.Refresh',
})
},
},
],
},
{
label: 'Named views',
id: 'View.Named views',
submenu: [
{
label: 'Create named view',
id: 'View.Named views.Create named view',
click: () => {
typeSafeWebContentsSend(mainWindow, 'menu-action-clicked', {
menuLabel: 'View.Named views.Create named view',
})
},
},
{
label: 'Load named view',
id: 'View.Named views.Load named view',
click: () => {
typeSafeWebContentsSend(mainWindow, 'menu-action-clicked', {
menuLabel: 'View.Named views.Load named view',
})
},
},
{
label: 'Delete named view',
id: 'View.Named views.Delete named view',
click: () => {
typeSafeWebContentsSend(mainWindow, 'menu-action-clicked', {
menuLabel: 'View.Named views.Delete named view',
})
},
},
],
},
{ type: 'separator' },
{
label: 'Panes',
submenu: [
{
label: 'Feature tree',
id: 'View.Panes.Feature tree',
click: () => {
typeSafeWebContentsSend(mainWindow, 'menu-action-clicked', {
menuLabel: 'View.Panes.Feature tree',
})
},
},
{
label: 'KCL code',
id: 'View.Panes.KCL code',
click: () => {
typeSafeWebContentsSend(mainWindow, 'menu-action-clicked', {
menuLabel: 'View.Panes.KCL code',
})
},
},
{
label: 'Project files',
id: 'View.Panes.Project files',
click: () => {
typeSafeWebContentsSend(mainWindow, 'menu-action-clicked', {
menuLabel: 'View.Panes.Project files',
})
},
},
{
label: 'Variables',
id: 'View.Panes.Variables',
click: () => {
typeSafeWebContentsSend(mainWindow, 'menu-action-clicked', {
menuLabel: 'View.Panes.Variables',
})
},
},
{
label: 'Logs',
id: 'View.Panes.Logs',
click: () => {
typeSafeWebContentsSend(mainWindow, 'menu-action-clicked', {
menuLabel: 'View.Panes.Logs',
})
},
},
],
},
{
label: 'Appearance',
submenu: [
{ role: 'togglefullscreen' },
{ type: 'separator' },
{ role: 'zoomIn' },
{ role: 'zoomOut' },
{ role: 'resetZoom' },
],
},
{ type: 'separator' },
{ role: 'minimize' },
{ role: 'zoom' },
...extraBits,
],
}
}

View File

@ -278,6 +278,7 @@ contextBridge.exposeInMainWorld('electron', {
'VITE_KC_SKIP_AUTH',
'VITE_KC_CONNECTION_TIMEOUT_MS',
'VITE_KC_DEV_TOKEN',
'IS_PLAYWRIGHT',
// Really we shouldn't use these and our code should use NODE_ENV