[Feature]: Enable Text-to-CAD at the application level (#6501)

* chore: saving off skeleton

* fix: saving skeleton

* chore: skeleton for loading projects from project directory path

* chore: cleaning up useless state transition to be an on event direct to action state

* fix: new structure for web vs desktop vs react machine provider code

* chore: saving off skeleton

* fix: skeleton logic for react? going to move it from a string to obj.string

* fix: trying to prevent error element unmount on global react components. This is bricking JS state

* fix: we are so back

* chore: implemented navigating to specfic KCL file

* chore: implementing renaming project

* chore: deleting project

* fix: auto fixes

* fix: old debug/testing file oops

* chore: generic create new file

* chore: skeleton for web create file provide

* chore: basic machine vitest... need to figure out how to get window.electron implemented in vitest?

* chore: save off progress before deleting other project implementation, a few missing features still

* chore: trying a different init skeleton? most likely will migrate

* chore: first attempt of purging projects context provider

* chore: enabling toast for some machine state

* chore: enabling more toast success and error

* chore: writing read write state to the system io based on the project path

* fix: tsc fixes

* fix: use file system watcher, navigate to project after creation via the requestProjectName

* chore: open project command, hooks vs snapshot context helpers

* chore: implemented open and create project for e2e testing. They are hard coded in poor spot for now.

* fix: codespell fixes

* chore: implementing more project commands

* chore: PR improvements for root.tsx

* chore: leaving comment about new Router.tsx layout

* fix: removing debugging code

* fix: rewriting component for readability

* fix: improving web initialization

* chore: implementing import file from url which is not actually that?

* fix: clearing search params on import file from url

* fix: fixed two e2e tests, forgot needsReview when making new command

* fix: fixing some import from url business logic to pass e2e tests

* chore: script for diffing circular deps +/-

* fix: formatting

* fix: massive fix for circular depsga!

* fix: trying to fix some errors and auto fmt

* fix: updating deps

* fix: removing debugging code

* fix: big clean up

* fix: more deletion

* fix: tsc cleanup

* fix: TSC TSC TSC TSC!

* fix: typo fix

* fix: clear query params on web only, desktop not required

* fix: removing unused code

* fmt

* Bring back `trap` removed in merge

* Use explicit types instead of `any`s on arg configs

* Add project commands directly to command palette

* fix: deleting debugging code, from PR review

* fix: this got added back(?)

* fix: using referred type

* fix: more PR clean up

* fix: big block comment for xstate architecture decision

* fix: more pr comment fixes

* fix: saving off logic, need a big cleanup because I hacked it together to get a POC

* fix: extra business?

* fix: merge conflict just added them back why dude

* fix: more PR comments

* fix: big ciruclar deps fix, commandBarActor in appActor

* chore: writing e2e test, still need to fix 3 bugs

* chore: adding more scenarios

* fix: formatting

* fix: fixing tsc errors

* chore: deleting the old text to cad and using the new application level one, almost there

* fix: prompt to edit works

* fix: large push to get 1 text to cad command... the usage is a little buggy with delete and navigate within /file

* fix: settings for highlight edges now works

* chore: adding another e2e test

* fix: cleaning up e2e tests and writing more of them

* fix: tsc type

* chore: more e2e improvements, unique project name  in text to cad

* chore: e2e tests should be good to go

* fix: gotcha comment

* fix: enabled web t2c, codespell fixes

* fix: fixing merge conflcits??

* fix: t2c is back

* Remove spaces in command bar test

* fmt

---------

Co-authored-by: Frank Noirot <frankjohnson1993@gmail.com>
Co-authored-by: lee-at-zoo-corp <lee@zoo.dev>
This commit is contained in:
Kevin Nadro
2025-04-25 18:04:47 -05:00
committed by GitHub
parent f8e53d941d
commit 5a4f8bd522
20 changed files with 1218 additions and 242 deletions

View File

@ -83,7 +83,7 @@ test.describe('Command bar tests', () => {
await page.keyboard.press('Enter') // submit
await page.waitForTimeout(100)
await expect(page.locator('.cm-activeLine')).toContainText(
`fillet( radius = ${KCL_DEFAULT_LENGTH}, tags = [seg01] )`
`fillet(radius = ${KCL_DEFAULT_LENGTH}, tags = [seg01])`
)
})

View File

@ -411,6 +411,7 @@ export async function getUtils(page: Page, test_?: typeof test) {
closeFilePanel: () => closeFilePanel(page),
openVariablesPane: () => openVariablesPane(page),
openLogsPane: () => openLogsPane(page),
goToHomePageFromModeling: () => goToHomePageFromModeling(page),
openAndClearDebugPanel: () => openAndClearDebugPanel(page),
clearAndCloseDebugPanel: async () => {
await clearCommandLogs(page)
@ -1011,6 +1012,11 @@ export async function createProject({
})
}
async function goToHomePageFromModeling(page: Page) {
await page.getByTestId('app-logo').click()
await expect(page.getByText('Your Projects')).toBeVisible()
}
export function executorInputPath(fileName: string): string {
return path.join('rust', 'kcl-lib', 'e2e', 'executor', 'inputs', fileName)
}

View File

@ -15,7 +15,7 @@ test.describe('Text-to-CAD tests', () => {
await u.waitForPageLoad()
})
await sendPromptFromCommandBar(page, 'a 2x4 lego')
await sendPromptFromCommandBarTriggeredByButton(page, 'a 2x4 lego')
// Find the toast.
// Look out for the toast message
@ -64,7 +64,7 @@ test.describe('Text-to-CAD tests', () => {
await homePage.goToModelingScene()
await u.waitForPageLoad()
await sendPromptFromCommandBar(page, 'a 2x6 lego')
await sendPromptFromCommandBarTriggeredByButton(page, 'a 2x6 lego')
// Find the toast.
// Look out for the toast message
@ -82,7 +82,7 @@ test.describe('Text-to-CAD tests', () => {
await expect(successToastMessage).toBeVisible({ timeout: 15000 })
// Can send a new prompt from the command bar.
await sendPromptFromCommandBar(page, 'a 2x4 lego')
await sendPromptFromCommandBarTriggeredByButton(page, 'a 2x4 lego')
// Find the toast.
// Look out for the toast message
@ -108,7 +108,7 @@ test.describe('Text-to-CAD tests', () => {
await homePage.goToModelingScene()
await u.waitForPageLoad()
await sendPromptFromCommandBar(page, 'a 2x4 lego')
await sendPromptFromCommandBarTriggeredByButton(page, 'a 2x4 lego')
// Find the toast.
// Look out for the toast message
@ -149,29 +149,8 @@ test.describe('Text-to-CAD tests', () => {
await homePage.goToModelingScene()
await u.waitForPageLoad()
const commandBarButton = page.getByRole('button', { name: 'Commands' })
await expect(commandBarButton).toBeVisible()
// Click the command bar button
await commandBarButton.click()
// Wait for the command bar to appear
const cmdSearchBar = page.getByPlaceholder('Search commands')
await expect(cmdSearchBar).toBeVisible()
const textToCadCommand = page.getByRole('option', { name: 'Text-to-CAD' })
await expect(textToCadCommand.first()).toBeVisible()
// Click the Text-to-CAD command
await textToCadCommand.first().click()
// Enter the prompt.
const prompt = page.getByRole('textbox', { name: 'Prompt' })
await expect(prompt.first()).toBeVisible()
// Type the prompt.
const randomPrompt = `aslkdfja;` + Date.now() + `FFFFEIWJF`
await page.keyboard.type(randomPrompt)
await page.waitForTimeout(1000)
await page.keyboard.press('Enter')
await sendPromptFromCommandBarTriggeredByButton(page, randomPrompt)
// Find the toast.
// Look out for the toast message
@ -217,30 +196,8 @@ test.describe('Text-to-CAD tests', () => {
await homePage.goToModelingScene()
await u.waitForPageLoad()
const commandBarButton = page.getByRole('button', { name: 'Commands' })
await expect(commandBarButton).toBeVisible()
// Click the command bar button
await commandBarButton.click()
// Wait for the command bar to appear
const cmdSearchBar = page.getByPlaceholder('Search commands')
await expect(cmdSearchBar).toBeVisible()
const textToCadCommand = page.getByRole('option', { name: 'Text-to-CAD' })
await expect(textToCadCommand.first()).toBeVisible()
// Click the Text-to-CAD command
await textToCadCommand.first().click()
// Enter the prompt.
const prompt = page.getByRole('textbox', { name: 'Prompt' })
await expect(prompt.first()).toBeVisible()
const badPrompt = 'akjsndladf lajbhflauweyfaaaljhr472iouafyvsssssss'
// Type the prompt.
await page.keyboard.type(badPrompt)
await page.waitForTimeout(1000)
await page.keyboard.press('Enter')
await sendPromptFromCommandBarTriggeredByButton(page, badPrompt)
// Find the toast.
// Look out for the toast message
@ -307,30 +264,8 @@ test.describe('Text-to-CAD tests', () => {
await homePage.goToModelingScene()
await u.waitForPageLoad()
const commandBarButton = page.getByRole('button', { name: 'Commands' })
await expect(commandBarButton).toBeVisible()
// Click the command bar button
await commandBarButton.click()
// Wait for the command bar to appear
const cmdSearchBar = page.getByPlaceholder('Search commands')
await expect(cmdSearchBar).toBeVisible()
const textToCadCommand = page.getByRole('option', { name: 'Text-to-CAD' })
await expect(textToCadCommand.first()).toBeVisible()
// Click the Text-to-CAD command
await textToCadCommand.first().click()
// Enter the prompt.
const prompt = page.getByRole('textbox', { name: 'Prompt' })
await expect(prompt.first()).toBeVisible()
const badPrompt = 'akjsndladflajbhflauweyf15;'
// Type the prompt.
await page.keyboard.type(badPrompt)
await page.waitForTimeout(1000)
await page.keyboard.press('Enter')
await sendPromptFromCommandBarTriggeredByButton(page, badPrompt)
// Find the toast.
// Look out for the toast message
@ -357,7 +292,7 @@ test.describe('Text-to-CAD tests', () => {
await expect(page.getByText(`Text-to-CAD failed`)).toBeVisible()
// They should be able to try again from the command bar.
await sendPromptFromCommandBar(page, 'a 2x4 lego')
await sendPromptFromCommandBarTriggeredByButton(page, 'a 2x4 lego')
// Find the toast.
// Look out for the toast message
@ -385,23 +320,7 @@ test.describe('Text-to-CAD tests', () => {
const promptWithNewline = `a 2x4\nlego`
const commandBarButton = page.getByRole('button', { name: 'Commands' })
await expect(commandBarButton).toBeVisible()
// Click the command bar button
await commandBarButton.click()
// Wait for the command bar to appear
const cmdSearchBar = page.getByPlaceholder('Search commands')
await expect(cmdSearchBar).toBeVisible()
const textToCadCommand = page.getByRole('option', { name: 'Text-to-CAD' })
await expect(textToCadCommand.first()).toBeVisible()
// Click the Text-to-CAD command
await textToCadCommand.first().click()
// Enter the prompt.
const prompt = page.getByRole('textbox', { name: 'Prompt' })
await expect(prompt.first()).toBeVisible()
await page.getByTestId('text-to-cad').click()
// Type the prompt.
await page.keyboard.type('a 2x4')
@ -446,11 +365,11 @@ test.describe('Text-to-CAD tests', () => {
await homePage.goToModelingScene()
await u.waitForPageLoad()
await sendPromptFromCommandBar(page, 'a 2x4 lego')
await sendPromptFromCommandBarTriggeredByButton(page, 'a 2x4 lego')
await sendPromptFromCommandBar(page, 'a 2x8 lego')
await sendPromptFromCommandBarTriggeredByButton(page, 'a 2x8 lego')
await sendPromptFromCommandBar(page, 'a 2x10 lego')
await sendPromptFromCommandBarTriggeredByButton(page, 'a 2x10 lego')
// Find the toast.
// Look out for the toast message
@ -529,9 +448,12 @@ test.describe('Text-to-CAD tests', () => {
await homePage.goToModelingScene()
await u.waitForPageLoad()
await sendPromptFromCommandBar(page, 'a 2x4 lego')
await sendPromptFromCommandBarTriggeredByButton(page, 'a 2x4 lego')
await sendPromptFromCommandBar(page, 'alkjsdnlajshdbfjlhsbdf a;askjdnf')
await sendPromptFromCommandBarTriggeredByButton(
page,
'alkjsdnlajshdbfjlhsbdf a;askjdnf'
)
// Find the toast.
// Look out for the toast message
@ -543,7 +465,9 @@ test.describe('Text-to-CAD tests', () => {
const generatingToastMessage = page.getByText(
`Generating parametric model...`
)
await expect(generatingToastMessage.first()).toBeVisible({ timeout: 10000 })
await expect(generatingToastMessage.first()).toBeVisible({
timeout: 10000,
})
const successToastMessage = page.getByText(`Text-to-CAD successful`)
// We should have three success toasts.
@ -587,7 +511,8 @@ test.describe('Text-to-CAD tests', () => {
})
})
async function sendPromptFromCommandBar(page: Page, promptStr: string) {
// Added underscore if we need this for later.
async function _sendPromptFromCommandBar(page: Page, promptStr: string) {
await page.waitForTimeout(1000)
await test.step(`Send prompt from command bar: ${promptStr}`, async () => {
const commandBarButton = page.getByRole('button', { name: 'Commands' })
@ -619,6 +544,25 @@ async function sendPromptFromCommandBar(page: Page, promptStr: string) {
})
}
async function sendPromptFromCommandBarTriggeredByButton(
page: Page,
promptStr: string
) {
await page.waitForTimeout(1000)
await test.step(`Send prompt from command bar: ${promptStr}`, async () => {
await page.getByTestId('text-to-cad').click()
// Enter the prompt.
const prompt = page.getByRole('textbox', { name: 'Prompt' })
await expect(prompt.first()).toBeVisible()
// Type the prompt.
await page.keyboard.type(promptStr)
await page.waitForTimeout(200)
await page.keyboard.press('Enter')
})
}
test(
'Text-to-CAD functionality',
{ tag: '@electron' },
@ -659,7 +603,7 @@ test(
await openKclCodePanel()
await test.step(`Test file creation`, async () => {
await sendPromptFromCommandBar(page, prompt)
await sendPromptFromCommandBarTriggeredByButton(page, prompt)
// File is considered created if it shows up in the Project Files pane
await expect(textToCadFileButton).toBeVisible({ timeout: 20_000 })
expect(fileExists()).toBeTruthy()
@ -689,3 +633,749 @@ test(
})
}
)
/**
* Below there are twelve (12) tests for testing the navigation and file creation
* logic around text to cad. The Text to CAD command is now globally available
* within the application and is the same command for all parts of the application.
* There are many new user scenarios to test because we can navigate to any project
* you can accept and reject the creation and everything needs to be updated properly.
*
*
* Gotcha: The API requests for text to CAD are mocked! The return values are
* from real API requests which are copied and pasted below
*
* Gotcha: The exports OBJ etc... are not in the output they are massive.
*
* Gotcha: Yes, the 3D render preview will be broken because the exported models
* are not included. These tests do not care about this.
*
*/
test.describe('Mocked Text-to-CAD API tests', { tag: ['@skipWin'] }, () => {
async function mockPageTextToCAD(page: Page) {
await page.route(
'https://api.dev.zoo.dev/ai/text-to-cad/glb?kcl=true',
async (route) => {
const json = {
id: 'bfc0ffc0-46c6-48bc-841e-1dc08f3428ce',
created_at: '2025-04-24T16:50:48.857892376Z',
user_id: '85de7740-3e38-4e86-abb5-e5afbb8a2183',
status: 'queued',
updated_at: '2025-04-24T16:50:48.857892376Z',
prompt: '1x1x1 cube',
output_format: 'glb',
model_version: '',
kcl_version: '0.2.63',
model: 'kcl',
feedback: null,
mocked: true,
}
await route.fulfill({ json })
}
)
await page.route(
'https://api.dev.zoo.dev/user/text-to-cad/*',
async (route) => {
const json = {
mocked: true,
id: '6ecbb863-2766-47f0-95cb-7122ad7560ce',
created_at: '2025-04-24T16:52:00.360Z',
started_at: '2025-04-24T16:52:00.360Z',
completed_at: '2025-04-24T16:52:00.363Z',
user_id: '85de7740-3e38-4e86-abb5-e5afbb8a2183',
status: 'completed',
updated_at: '2025-04-24T16:52:00.360Z',
prompt: '2x2x2 cube',
outputs: {},
output_format: 'step',
model_version: '',
kcl_version: '0.2.63',
model: 'kcl',
feedback: null,
code: '/*\nGenerated by Text-to-CAD:\n2x2x2 cube\n*/\n@settings(defaultLengthUnit = mm)\n\n// Define the dimensions of the cube\ncubeSide = 2\n\n// Start a sketch on the XY plane\ncubeSketch = startSketchOn(XY)\n |> startProfileAt([0, 0], %)\n |> line(end = [cubeSide, 0])\n |> line(end = [0, cubeSide])\n |> line(end = [-cubeSide, 0])\n |> close()\n\n// Extrude the sketch to create a 3D cube\ncube = extrude(cubeSketch, length = cubeSide)',
}
await route.fulfill({ json })
}
)
}
test(
'Home Page -> Text To CAD -> New Project -> Stay in home page -> Reject -> Project should be deleted',
{ tag: '@electron' },
async ({ context, page }, testInfo) => {
const projectName = 'my-project-name'
const prompt = '2x2x2 cube'
await mockPageTextToCAD(page)
// open commands
await page.getByTestId('command-bar-open-button').click()
// search Text To CAD
await page.keyboard.type('Text To CAD')
await page.keyboard.press('Enter')
// new project
await page.keyboard.type('New project')
await page.keyboard.press('Enter')
// write name
await page.keyboard.type(projectName)
await page.keyboard.press('Enter')
// prompt
await page.keyboard.type(prompt)
await page.keyboard.press('Enter')
await page.getByRole('button', { name: 'Reject' }).click()
// Expect the entire project to be deleted
await expect(
page.getByText('Successfully deleted "my-project-name"')
).toBeVisible()
// Project DOM card has this test id
await expect(page.getByTestId(projectName)).not.toBeVisible()
}
)
test(
'Home Page -> Text To CAD -> New Project -> Stay in home page -> Accept -> should navigate to file',
{ tag: '@electron' },
async ({ context, page }, testInfo) => {
const u = await getUtils(page)
const projectName = 'my-project-name'
const prompt = '2x2x2 cube'
await mockPageTextToCAD(page)
// open commands
await page.getByTestId('command-bar-open-button').click()
// search Text To CAD
await page.keyboard.type('Text To CAD')
await page.keyboard.press('Enter')
// new project
await page.keyboard.type('New project')
await page.keyboard.press('Enter')
// write name
await page.keyboard.type(projectName)
await page.keyboard.press('Enter')
// prompt
await page.keyboard.type(prompt)
await page.keyboard.press('Enter')
await page.getByRole('button', { name: 'Accept' }).click()
await expect(page.getByTestId('app-header-project-name')).toBeVisible()
await expect(page.getByTestId('app-header-project-name')).toContainText(
projectName
)
await expect(page.getByTestId('app-header-file-name')).toBeVisible()
await expect(page.getByTestId('app-header-file-name')).toContainText(
'2x2x2-cube.kcl'
)
await u.openFilePanel()
await expect(
page.getByTestId('file-tree-item').getByText('2x2x2-cube.kcl')
).toBeVisible()
}
)
test(
'Home Page -> Text To CAD -> Existing Project -> Stay in home page -> Reject -> should delete single file',
{ tag: '@electron' },
async ({ context, page }, testInfo) => {
const projectName = 'my-project-name'
const prompt = '2x2x2 cube'
await mockPageTextToCAD(page)
// Create and navigate to the project then come home
await createProject({ name: projectName, page, returnHome: true })
await expect(page.getByText('Your Projects')).toBeVisible()
await expect(page.getByText('1 file')).toBeVisible()
// open commands
await page.getByTestId('command-bar-open-button').click()
// search Text To CAD
await page.keyboard.type('Text To CAD')
await page.keyboard.press('Enter')
// new project
await page.keyboard.type('Existing project')
await page.keyboard.press('Enter')
// write name
await page.keyboard.type(projectName)
await page.keyboard.press('Enter')
// prompt
await page.keyboard.type(prompt)
await page.keyboard.press('Enter')
await expect(page.getByRole('button', { name: 'Reject' })).toBeVisible()
await expect(page.getByText('2 file')).toBeVisible()
await page.getByRole('button', { name: 'Reject' }).click()
await expect(page.getByText('1 file')).toBeVisible()
}
)
test(
'Home Page -> Text To CAD -> Existing Project -> Stay in home page -> Accept -> should navigate to file',
{ tag: '@electron' },
async ({ context, page }, testInfo) => {
const u = await getUtils(page)
const projectName = 'my-project-name'
const prompt = '2x2x2 cube'
await mockPageTextToCAD(page)
// Create and navigate to the project then come home
await createProject({ name: projectName, page, returnHome: true })
await expect(page.getByText('Your Projects')).toBeVisible()
// open commands
await page.getByTestId('command-bar-open-button').click()
// search Text To CAD
await page.keyboard.type('Text To CAD')
await page.keyboard.press('Enter')
// new project
await page.keyboard.type('Existing project')
await page.keyboard.press('Enter')
// write name
await page.keyboard.type(projectName)
await page.keyboard.press('Enter')
// prompt
await page.keyboard.type(prompt)
await page.keyboard.press('Enter')
await page.getByRole('button', { name: 'Accept' }).click()
await expect(page.getByTestId('app-header-project-name')).toBeVisible()
await expect(page.getByTestId('app-header-project-name')).toContainText(
projectName
)
await expect(page.getByTestId('app-header-file-name')).toBeVisible()
await expect(page.getByTestId('app-header-file-name')).toContainText(
'2x2x2-cube.kcl'
)
await u.openFilePanel()
await expect(
page.getByTestId('file-tree-item').getByText('2x2x2-cube.kcl')
).toBeVisible()
}
)
test(
'Home Page -> Text To CAD -> New Project -> Navigate to the project -> Reject -> should go to home page',
{ tag: '@electron' },
async ({ context, page }, testInfo) => {
const projectName = 'my-project-name'
const prompt = '2x2x2 cube'
await mockPageTextToCAD(page)
// open commands
await page.getByTestId('command-bar-open-button').click()
// search Text To CAD
await page.keyboard.type('Text To CAD')
await page.keyboard.press('Enter')
// new project
await page.keyboard.type('New project')
await page.keyboard.press('Enter')
// write name
await page.keyboard.type(projectName)
await page.keyboard.press('Enter')
// prompt
await page.keyboard.type(prompt)
await page.keyboard.press('Enter')
// Go into the project that was created from Text to CAD
await page.getByText(projectName).click()
await expect(page.getByTestId('app-header-project-name')).toBeVisible()
await expect(page.getByTestId('app-header-project-name')).toContainText(
projectName
)
await expect(page.getByTestId('app-header-file-name')).toBeVisible()
await expect(page.getByTestId('app-header-file-name')).toContainText(
'2x2x2-cube.kcl'
)
await page.getByRole('button', { name: 'Reject' }).click()
// Make sure we went back home
await expect(
page.getByText('No Projects found, ready to make your first one?')
).toBeVisible()
}
)
test(
'Home Page -> Text To CAD -> New Project -> Navigate to the project -> Accept -> should stay in same file',
{ tag: '@electron' },
async ({ context, page }, testInfo) => {
const projectName = 'my-project-name'
const prompt = '2x2x2 cube'
await mockPageTextToCAD(page)
// open commands
await page.getByTestId('command-bar-open-button').click()
// search Text To CAD
await page.keyboard.type('Text To CAD')
await page.keyboard.press('Enter')
// new project
await page.keyboard.type('New project')
await page.keyboard.press('Enter')
// write name
await page.keyboard.type(projectName)
await page.keyboard.press('Enter')
// prompt
await page.keyboard.type(prompt)
await page.keyboard.press('Enter')
// Go into the project that was created from Text to CAD
await page.getByText(projectName).click()
await page.getByRole('button', { name: 'Accept' }).click()
await expect(page.getByTestId('app-header-project-name')).toBeVisible()
await expect(page.getByTestId('app-header-project-name')).toContainText(
projectName
)
await expect(page.getByTestId('app-header-file-name')).toBeVisible()
await expect(page.getByTestId('app-header-file-name')).toContainText(
'2x2x2-cube.kcl'
)
}
)
test(
'Home Page -> Text To CAD -> Existing Project -> Navigate to the project -> Reject -> should load main.kcl',
{ tag: '@electron' },
async ({ context, page }, testInfo) => {
const u = await getUtils(page)
const projectName = 'my-project-name'
const prompt = '2x2x2 cube'
await mockPageTextToCAD(page)
// Create and navigate to the project then come home
await createProject({ name: projectName, page, returnHome: true })
await expect(page.getByText('Your Projects')).toBeVisible()
// open commands
await page.getByTestId('command-bar-open-button').click()
// search Text To CAD
await page.keyboard.type('Text To CAD')
await page.keyboard.press('Enter')
// new project
await page.keyboard.type('Existing project')
await page.keyboard.press('Enter')
// write name
await page.keyboard.type(projectName)
await page.keyboard.press('Enter')
// prompt
await page.keyboard.type(prompt)
await page.keyboard.press('Enter')
// Go into the project that was created from Text to CAD
// This only works because there is only 1 project. Each project has the same value of `data-test-id='project-title'`
await page.getByTestId('project-title').click()
await page.getByRole('button', { name: 'Reject' }).click()
// Check header is populated with the project and file name
await expect(page.getByTestId('app-header-project-name')).toBeVisible()
await expect(page.getByTestId('app-header-project-name')).toContainText(
projectName
)
await expect(page.getByTestId('app-header-file-name')).toBeVisible()
await expect(page.getByTestId('app-header-file-name')).toContainText(
'main.kcl'
)
// Check file is deleted
await u.openFilePanel()
await expect(page.getByText('2x2x2-cube.kcl')).not.toBeVisible()
}
)
test(
'Home Page -> Text To CAD -> Existing Project -> Navigate to the project -> Accept -> should load 2x2x2-cube.kcl',
{ tag: '@electron' },
async ({ context, page }, testInfo) => {
const u = await getUtils(page)
const projectName = 'my-project-name'
const prompt = '2x2x2 cube'
await mockPageTextToCAD(page)
// Create and navigate to the project then come home
await createProject({ name: projectName, page, returnHome: true })
await expect(page.getByText('Your Projects')).toBeVisible()
// open commands
await page.getByTestId('command-bar-open-button').click()
// search Text To CAD
await page.keyboard.type('Text To CAD')
await page.keyboard.press('Enter')
// new project
await page.keyboard.type('Existing project')
await page.keyboard.press('Enter')
// write name
await page.keyboard.type(projectName)
await page.keyboard.press('Enter')
// prompt
await page.keyboard.type(prompt)
await page.keyboard.press('Enter')
// Go into the project that was created from Text to CAD
// This only works because there is only 1 project. Each project has the same value of `data-test-id='project-title'`
await page.getByTestId('project-title').click()
await page.getByRole('button', { name: 'Accept' }).click()
// Check header is populated with the project and file name
await expect(page.getByTestId('app-header-project-name')).toBeVisible()
await expect(page.getByTestId('app-header-project-name')).toContainText(
projectName
)
await expect(page.getByTestId('app-header-file-name')).toBeVisible()
await expect(page.getByTestId('app-header-file-name')).toContainText(
'2x2x2-cube.kcl'
)
// Check file is created
await u.openFilePanel()
await expect(
page.getByTestId('file-tree-item').getByText('2x2x2-cube.kcl')
).toBeVisible()
}
)
test(
'Home Page -> Text To CAD -> New Project -> Navigate to different project -> Reject -> should stay in project',
{ tag: '@electron' },
async ({ context, page, homePage }, testInfo) => {
const u = await getUtils(page)
const projectName = 'my-project-name'
const unrelatedProjectName = 'unrelated-project'
const prompt = '2x2x2 cube'
await mockPageTextToCAD(page)
// Create and navigate to the project then come home
await createProject({
name: unrelatedProjectName,
page,
returnHome: true,
})
await expect(page.getByText('Your Projects')).toBeVisible()
// open commands
await page.getByTestId('command-bar-open-button').click()
// search Text To CAD
await page.keyboard.type('Text To CAD')
await page.keyboard.press('Enter')
// new project
await page.keyboard.type('New project')
await page.keyboard.press('Enter')
// write name
await page.keyboard.type(projectName)
await page.keyboard.press('Enter')
// prompt
await page.keyboard.type(prompt)
await page.keyboard.press('Enter')
await homePage.openProject(unrelatedProjectName)
// Check that we opened the unrelated project
await expect(page.getByTestId('app-header-project-name')).toBeVisible()
await expect(page.getByTestId('app-header-project-name')).toContainText(
unrelatedProjectName
)
await expect(page.getByTestId('app-header-file-name')).toBeVisible()
await expect(page.getByTestId('app-header-file-name')).toContainText(
'main.kcl'
)
await page.getByRole('button', { name: 'Reject' }).click()
// Check header is populated with the project and file name
await expect(page.getByTestId('app-header-project-name')).toBeVisible()
await expect(page.getByTestId('app-header-project-name')).toContainText(
unrelatedProjectName
)
await expect(page.getByTestId('app-header-file-name')).toBeVisible()
await expect(page.getByTestId('app-header-file-name')).toContainText(
'main.kcl'
)
// Check file is created
await u.openFilePanel()
// File should be deleted
await expect(
page.getByTestId('file-tree-item').getByText('2x2x2-cube.kcl')
).not.toBeVisible()
await u.goToHomePageFromModeling()
// Project should be deleted
await expect(
page.getByTestId('home-section').getByText(projectName)
).not.toBeVisible()
}
)
test(
'Home Page -> Text To CAD -> New Project -> Navigate to different project -> Accept -> should go to new project',
{ tag: '@electron' },
async ({ context, page, homePage }, testInfo) => {
const u = await getUtils(page)
const projectName = 'my-project-name'
const unrelatedProjectName = 'unrelated-project'
const prompt = '2x2x2 cube'
await mockPageTextToCAD(page)
// Create and navigate to the project then come home
await createProject({
name: unrelatedProjectName,
page,
returnHome: true,
})
await expect(page.getByText('Your Projects')).toBeVisible()
// open commands
await page.getByTestId('command-bar-open-button').click()
// search Text To CAD
await page.keyboard.type('Text To CAD')
await page.keyboard.press('Enter')
// new project
await page.keyboard.type('New project')
await page.keyboard.press('Enter')
// write name
await page.keyboard.type(projectName)
await page.keyboard.press('Enter')
// prompt
await page.keyboard.type(prompt)
await page.keyboard.press('Enter')
await homePage.openProject(unrelatedProjectName)
// Check that we opened the unrelated project
await expect(page.getByTestId('app-header-project-name')).toBeVisible()
await expect(page.getByTestId('app-header-project-name')).toContainText(
unrelatedProjectName
)
await expect(page.getByTestId('app-header-file-name')).toBeVisible()
await expect(page.getByTestId('app-header-file-name')).toContainText(
'main.kcl'
)
await page.getByRole('button', { name: 'Accept' }).click()
// Check header is populated with the project and file name
await expect(page.getByTestId('app-header-project-name')).toBeVisible()
await expect(page.getByTestId('app-header-project-name')).toContainText(
projectName
)
await expect(page.getByTestId('app-header-file-name')).toBeVisible()
await expect(page.getByTestId('app-header-file-name')).toContainText(
'2x2x2-cube.kcl'
)
// Check file is created
await u.openFilePanel()
await expect(
page.getByTestId('file-tree-item').getByText('2x2x2-cube.kcl')
).toBeVisible()
await expect(
page.getByTestId('file-tree-item').getByText('main.kcl')
).not.toBeVisible()
}
)
test(
'Home Page -> Text To CAD -> Existing Project -> Navigate to different project -> Reject -> should stay in same project',
{ tag: '@electron' },
async ({ context, page, homePage }, testInfo) => {
const u = await getUtils(page)
const projectName = 'my-project-name'
const unrelatedProjectName = 'unrelated-project'
const prompt = '2x2x2 cube'
await mockPageTextToCAD(page)
// Create and navigate to the project then come home
await createProject({
name: unrelatedProjectName,
page,
returnHome: true,
})
await expect(page.getByText('Your Projects')).toBeVisible()
await createProject({ name: projectName, page, returnHome: true })
await expect(page.getByText('Your Projects')).toBeVisible()
// open commands
await page.getByTestId('command-bar-open-button').click()
// search Text To CAD
await page.keyboard.type('Text To CAD')
await page.keyboard.press('Enter')
// new project
await page.keyboard.type('Existing project')
await page.keyboard.press('Enter')
// write name
await page.keyboard.type(projectName)
await page.keyboard.press('Enter')
// prompt
await page.keyboard.type(prompt)
await page.keyboard.press('Enter')
await homePage.openProject(unrelatedProjectName)
// Check that we opened the unrelated project
await expect(page.getByTestId('app-header-project-name')).toBeVisible()
await expect(page.getByTestId('app-header-project-name')).toContainText(
unrelatedProjectName
)
await expect(page.getByTestId('app-header-file-name')).toBeVisible()
await expect(page.getByTestId('app-header-file-name')).toContainText(
'main.kcl'
)
await page.getByRole('button', { name: 'Reject' }).click()
// Check header is populated with the project and file name
await expect(page.getByTestId('app-header-project-name')).toBeVisible()
await expect(page.getByTestId('app-header-project-name')).toContainText(
unrelatedProjectName
)
await expect(page.getByTestId('app-header-file-name')).toBeVisible()
await expect(page.getByTestId('app-header-file-name')).toContainText(
'main.kcl'
)
await u.goToHomePageFromModeling()
await expect(
page.getByTestId('home-section').getByText(projectName)
).toBeVisible()
await expect(
page.getByTestId('home-section').getByText(unrelatedProjectName)
).toBeVisible()
}
)
test(
'Home Page -> Text To CAD -> Existing Project -> Navigate to different project -> Accept -> should navigate to new project',
{ tag: '@electron' },
async ({ context, page, homePage }, testInfo) => {
const u = await getUtils(page)
const projectName = 'my-project-name'
const unrelatedProjectName = 'unrelated-project'
const prompt = '2x2x2 cube'
await mockPageTextToCAD(page)
// Create and navigate to the project then come home
await createProject({
name: unrelatedProjectName,
page,
returnHome: true,
})
await expect(page.getByText('Your Projects')).toBeVisible()
await createProject({ name: projectName, page, returnHome: true })
await expect(page.getByText('Your Projects')).toBeVisible()
// open commands
await page.getByTestId('command-bar-open-button').click()
// search Text To CAD
await page.keyboard.type('Text To CAD')
await page.keyboard.press('Enter')
// new project
await page.keyboard.type('Existing project')
await page.keyboard.press('Enter')
// write name
await page.keyboard.type(projectName)
await page.keyboard.press('Enter')
// prompt
await page.keyboard.type(prompt)
await page.keyboard.press('Enter')
await homePage.openProject(unrelatedProjectName)
// Check that we opened the unrelated project
await expect(page.getByTestId('app-header-project-name')).toBeVisible()
await expect(page.getByTestId('app-header-project-name')).toContainText(
unrelatedProjectName
)
await expect(page.getByTestId('app-header-file-name')).toBeVisible()
await expect(page.getByTestId('app-header-file-name')).toContainText(
'main.kcl'
)
await page.getByRole('button', { name: 'Accept' }).click()
// Check header is populated with the project and file name
await expect(page.getByTestId('app-header-project-name')).toBeVisible()
await expect(page.getByTestId('app-header-project-name')).toContainText(
projectName
)
await expect(page.getByTestId('app-header-file-name')).toBeVisible()
await expect(page.getByTestId('app-header-file-name')).toContainText(
'2x2x2-cube.kcl'
)
// Check file is created
await u.openFilePanel()
await expect(
page.getByTestId('file-tree-item').getByText('2x2x2-cube.kcl')
).toBeVisible()
await expect(
page.getByTestId('file-tree-item').getByText('main.kcl')
).toBeVisible()
}
)
})

View File

@ -100,42 +100,6 @@ export const FileMachineProvider = ({
}
}, [])
// Due to the route provider, i've moved this to the FileMachineProvider instead of CommandBarProvider
// This will register the commands to route to Telemetry, Home, and Settings.
useEffect(() => {
const filePath =
PATHS.FILE + '/' + encodeURIComponent(file?.path || BROWSER_PATH)
const { RouteTelemetryCommand, RouteHomeCommand, RouteSettingsCommand } =
createRouteCommands(navigate, location, filePath)
commandBarActor.send({
type: 'Remove commands',
data: {
commands: [
RouteTelemetryCommand,
RouteHomeCommand,
RouteSettingsCommand,
],
},
})
if (location.pathname === PATHS.HOME) {
commandBarActor.send({
type: 'Add commands',
data: { commands: [RouteTelemetryCommand, RouteSettingsCommand] },
})
} else if (location.pathname.includes(PATHS.FILE)) {
commandBarActor.send({
type: 'Add commands',
data: {
commands: [
RouteTelemetryCommand,
RouteSettingsCommand,
RouteHomeCommand,
],
},
})
}
}, [location])
useEffect(() => {
markOnce('code/didLoadFile')
async function fetchKclSamples() {
@ -440,6 +404,51 @@ export const FileMachineProvider = ({
}
)
// Due to the route provider, i've moved this to the FileMachineProvider instead of CommandBarProvider
// This will register the commands to route to Telemetry, Home, and Settings.
useEffect(() => {
const filePath =
PATHS.FILE + '/' + encodeURIComponent(file?.path || BROWSER_PATH)
const { RouteTelemetryCommand, RouteHomeCommand, RouteSettingsCommand } =
createRouteCommands(navigate, location, filePath)
commandBarActor.send({
type: 'Remove commands',
data: {
commands: [
RouteTelemetryCommand,
RouteHomeCommand,
RouteSettingsCommand,
],
},
})
if (location.pathname === PATHS.HOME) {
commandBarActor.send({
type: 'Add commands',
data: { commands: [RouteTelemetryCommand, RouteSettingsCommand] },
})
} else if (location.pathname.includes(PATHS.FILE)) {
commandBarActor.send({
type: 'Add commands',
data: {
commands: [
RouteTelemetryCommand,
RouteSettingsCommand,
RouteHomeCommand,
],
},
})
}
// GOTCHA: If we call navigate() while in the /file route the fileMachineProvider
// has a context.project of the original one that was loaded. It does not update
// Watch when the navigation changes, if it changes set a new Project within the fileMachine
// to load the latest state of the project you are in.
if (project) {
// TODO: Clean this up with global application state when fileMachine gets merged into SystemIOMachine
send({ type: 'Refresh with new project', data: { project } })
}
}, [location])
const cb = modelingMenuCallbackMostActions(
settings,
navigate,

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 } from 'react-router-dom'
import { useLoaderData } from 'react-router-dom'
import type { Actor, ContextFrom, Prop, SnapshotFrom, StateFrom } from 'xstate'
import { assign, fromPromise } from 'xstate'
@ -107,7 +107,6 @@ import {
sceneEntitiesManager,
sceneInfra,
} from '@src/lib/singletons'
import { submitAndAwaitTextToKcl } from '@src/lib/textToCad'
import { err, reject, reportRejection, trap } from '@src/lib/trap'
import type { IndexLoaderData } from '@src/lib/types'
import { platform, uuidv4 } from '@src/lib/utils'
@ -139,11 +138,10 @@ export const ModelingMachineProvider = ({
children: React.ReactNode
}) => {
const {
app: { theme, allowOrbitInSketchMode },
modeling: { defaultUnit, cameraProjection, highlightEdges, cameraOrbit },
app: { allowOrbitInSketchMode },
modeling: { defaultUnit, cameraProjection, cameraOrbit },
} = useSettings()
const navigate = useNavigate()
const { context, send: fileMachineSend } = useFileContext()
const { context } = useFileContext()
const { file } = useLoaderData() as IndexLoaderData
const token = useToken()
const streamRef = useRef<HTMLDivElement>(null)
@ -533,23 +531,6 @@ export const ModelingMachineProvider = ({
return {}
}
),
'Submit to Text-to-CAD API': ({ event }) => {
if (event.type !== 'Text-to-CAD') return
const trimmedPrompt = event.data.prompt.trim()
if (!trimmedPrompt) return
submitAndAwaitTextToKcl({
trimmedPrompt,
fileMachineSend,
navigate,
context,
token,
settings: {
theme: theme.current,
highlightEdges: highlightEdges.current,
},
}).catch(reportRejection)
},
},
guards: {
'has valid selection for deletion': ({

View File

@ -263,7 +263,10 @@ function ProjectMenuPopover({
data-testid="project-sidebar-toggle"
>
<div className="flex flex-col items-start py-0.5">
<span className="hidden text-sm text-chalkboard-110 dark:text-chalkboard-20 whitespace-nowrap lg:block">
<span
className="hidden text-sm text-chalkboard-110 dark:text-chalkboard-20 whitespace-nowrap lg:block"
data-testid="app-header-file-name"
>
{isDesktop() && file?.name
? file.name.slice(
file.name.lastIndexOf(window.electron.path.sep) + 1
@ -271,7 +274,10 @@ function ProjectMenuPopover({
: APP_NAME}
</span>
{isDesktop() && project?.name && (
<span className="hidden text-xs text-chalkboard-70 dark:text-chalkboard-40 whitespace-nowrap lg:block">
<span
className="hidden text-xs text-chalkboard-70 dark:text-chalkboard-40 whitespace-nowrap lg:block"
data-testid="app-header-project-name"
>
{project.name}
</span>
)}

View File

@ -1,15 +1,20 @@
import { useFileSystemWatcher } from '@src/hooks/useFileSystemWatcher'
import { PATHS } from '@src/lib/paths'
import { systemIOActor, useSettings } from '@src/lib/singletons'
import { systemIOActor, useSettings, useToken } from '@src/lib/singletons'
import {
useHasListedProjects,
useProjectDirectoryPath,
useRequestedFileName,
useRequestedProjectName,
useRequestedTextToCadGeneration,
useFolders,
} from '@src/machines/systemIO/hooks'
import { SystemIOMachineEvents } from '@src/machines/systemIO/utils'
import { useNavigate } from 'react-router-dom'
import { useEffect } from 'react'
import { submitAndAwaitTextToKclSystemIO } from '@src/lib/textToCad'
import { reportRejection } from '@src/lib/trap'
import { getUniqueProjectName } from '@src/lib/desktopFS'
export function SystemIOMachineLogicListenerDesktop() {
const requestedProjectName = useRequestedProjectName()
@ -18,6 +23,9 @@ export function SystemIOMachineLogicListenerDesktop() {
const hasListedProjects = useHasListedProjects()
const navigate = useNavigate()
const settings = useSettings()
const requestedTextToCadGeneration = useRequestedTextToCadGeneration()
const token = useToken()
const folders = useFolders()
const useGlobalProjectNavigation = () => {
useEffect(() => {
@ -95,6 +103,27 @@ export function SystemIOMachineLogicListenerDesktop() {
? [settings.app.projectDirectory.current]
: []
)
// TODO: Move this generateTextToCAD to another machine in the future and make a whole machine out of it.
useEffect(() => {
const requestedPromptTrimmed =
requestedTextToCadGeneration.requestedPrompt.trim()
const requestedProjectName =
requestedTextToCadGeneration.requestedProjectName
const isProjectNew = requestedTextToCadGeneration.isProjectNew
if (!requestedPromptTrimmed || !requestedProjectName) return
const uniqueNameIfNeeded = isProjectNew
? getUniqueProjectName(requestedProjectName, folders)
: requestedProjectName
submitAndAwaitTextToKclSystemIO({
trimmedPrompt: requestedPromptTrimmed,
projectName: uniqueNameIfNeeded,
navigate,
token,
isProjectNew,
settings: { highlightEdges: settings.modeling.highlightEdges.current },
}).catch(reportRejection)
}, [requestedTextToCadGeneration])
}
useGlobalProjectNavigation()

View File

@ -1,10 +1,21 @@
import { useEffect, useCallback } from 'react'
import { useClearURLParams } from '@src/machines/systemIO/hooks'
import {
useClearURLParams,
useRequestedTextToCadGeneration,
} from '@src/machines/systemIO/hooks'
import { useSearchParams } from 'react-router-dom'
import { CREATE_FILE_URL_PARAM } from '@src/lib/constants'
import { submitAndAwaitTextToKclSystemIO } from '@src/lib/textToCad'
import { reportRejection } from '@src/lib/trap'
import { useNavigate } from 'react-router-dom'
import { useSettings, useToken } from '@src/lib/singletons'
export function SystemIOMachineLogicListenerWeb() {
const clearURLParams = useClearURLParams()
const navigate = useNavigate()
const settings = useSettings()
const requestedTextToCadGeneration = useRequestedTextToCadGeneration()
const token = useToken()
const [searchParams, setSearchParams] = useSearchParams()
const clearImportSearchParams = useCallback(() => {
// Clear the search parameters related to the "Import file from URL" command
@ -24,6 +35,26 @@ export function SystemIOMachineLogicListenerWeb() {
}, [clearURLParams])
}
// TODO: Move this generateTextToCAD to another machine in the future and make a whole machine out of it.
useEffect(() => {
const requestedPromptTrimmed =
requestedTextToCadGeneration.requestedPrompt.trim()
const requestedProjectName =
requestedTextToCadGeneration.requestedProjectName
const isProjectNew = requestedTextToCadGeneration.isProjectNew
if (!requestedPromptTrimmed || !requestedProjectName) return
// Gotcha: web has no project name.
const uniqueNameIfNeeded = requestedProjectName
submitAndAwaitTextToKclSystemIO({
trimmedPrompt: requestedPromptTrimmed,
projectName: uniqueNameIfNeeded,
navigate,
token,
isProjectNew,
settings: { highlightEdges: settings.modeling.highlightEdges.current },
}).catch(reportRejection)
}, [requestedTextToCadGeneration])
useClearQueryParams()
return null
}

View File

@ -21,20 +21,17 @@ import {
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'
import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader'
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader'
import type { EventFrom } from 'xstate'
import { ActionButton } from '@src/components/ActionButton'
import type { useFileContext } from '@src/hooks/useFileContext'
import { base64Decode } from '@src/lang/wasm'
import { isDesktop } from '@src/lib/isDesktop'
import { openExternalBrowserIfDesktop } from '@src/lib/openWindow'
import { PATHS } from '@src/lib/paths'
import { codeManager, kclManager } from '@src/lib/singletons'
import { codeManager, kclManager, systemIOActor } from '@src/lib/singletons'
import { sendTelemetry } from '@src/lib/textToCadTelemetry'
import type { Themes } from '@src/lib/theme'
import { reportRejection } from '@src/lib/trap'
import { commandBarActor } from '@src/lib/singletons'
import type { fileMachine } from '@src/machines/fileMachine'
import { SystemIOMachineEvents } from '@src/machines/systemIO/utils'
import { useProjectDirectoryPath } from '@src/machines/systemIO/hooks'
const CANVAS_SIZE = 128
const PROMPT_TRUNCATE_LENGTH = 128
@ -82,10 +79,16 @@ export function ToastTextToCadError({
toastId,
message,
prompt,
method,
projectName,
newProjectName,
}: {
toastId: string
message: string
prompt: string
method: string
projectName: string
newProjectName: string
}) {
return (
<div className="flex flex-col justify-between gap-6">
@ -118,10 +121,13 @@ export function ToastTextToCadError({
commandBarActor.send({
type: 'Find and select command',
data: {
groupId: 'modeling',
groupId: 'application',
name: 'Text-to-CAD',
argDefaultValues: {
prompt,
method,
projectName,
newProjectName,
},
},
})
@ -139,24 +145,22 @@ export function ToastTextToCadSuccess({
toastId,
data,
navigate,
context,
token,
fileMachineSend,
settings,
projectName,
fileName,
isProjectNew,
}: {
toastId: string
data: TextToCad_type & { fileName: string }
navigate: (to: string) => void
context: ReturnType<typeof useFileContext>['context']
token?: string
fileMachineSend: (
event: EventFrom<typeof fileMachine>,
data?: unknown
) => void
settings: {
theme: Themes
settings?: {
highlightEdges: boolean
}
projectName: string
fileName: string
isProjectNew: boolean
}) {
const wrapperRef = useRef<HTMLDivElement | null>(null)
const canvasRef = useRef<HTMLCanvasElement | null>(null)
@ -164,6 +168,7 @@ export function ToastTextToCadSuccess({
const [hasCopied, setHasCopied] = useState(false)
const [showCopiedUi, setShowCopiedUi] = useState(false)
const modelId = data.id
const projectDirectoryPath = useProjectDirectoryPath()
const animate = useCallback(
({
@ -198,7 +203,11 @@ export function ToastTextToCadSuccess({
if (!canvasRef.current) return
const canvas = canvasRef.current
const renderer = new WebGLRenderer({ canvas, antialias: true, alpha: true })
const renderer = new WebGLRenderer({
canvas,
antialias: true,
alpha: true,
})
renderer.setSize(CANVAS_SIZE, CANVAS_SIZE)
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
@ -344,15 +353,30 @@ export function ToastTextToCadSuccess({
}
if (isDesktop()) {
// Delete the file from the project
fileMachineSend({
type: 'Delete file',
if (projectName && fileName) {
// You are in the new workflow for text to cad at the global application level
if (isProjectNew) {
// Delete the entire project if it was newly created from text to CAD
systemIOActor.send({
type: SystemIOMachineEvents.deleteProject,
data: {
name: data.fileName,
path: `${context.project.path}${window.electron.sep}${data.fileName}`,
children: null,
requestedProjectName: projectName,
},
})
} else {
// Only delete the file if the project was preexisting
systemIOActor.send({
type: SystemIOMachineEvents.deleteKCLFile,
data: {
requestedProjectName: projectName,
requestedFileName: fileName,
},
})
}
}
}
toast.dismiss(toastId)
}}
>
@ -368,11 +392,9 @@ export function ToastTextToCadSuccess({
onClick={() => {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
sendTelemetry(modelId, 'accepted', token)
navigate(
`${PATHS.FILE}/${encodeURIComponent(
`${context.project.path}${window.electron.sep}${data.fileName}`
)}`
)
const path = `${projectDirectoryPath}${window.electron.path.sep}${projectName}${window.electron.sep}${fileName}`
navigate(`${PATHS.FILE}/${encodeURIComponent(path)}`)
toast.dismiss(toastId)
}}
>
@ -426,7 +448,6 @@ function traverseSceneToStyleObjects({
}: {
scene: Scene
color?: number
theme: Themes
highlightEdges?: boolean
}) {
scene.traverse((child) => {

View File

@ -0,0 +1,83 @@
import type { systemIOMachine } from '@src/machines/systemIO/systemIOMachine'
import type { ActorRefFrom } from 'xstate'
import type { Command, CommandArgumentOption } from '@src/lib/commandTypes'
import { SystemIOMachineEvents } from '@src/machines/systemIO/utils'
import { isDesktop } from '@src/lib/isDesktop'
export function createApplicationCommands({
systemIOActor,
}: {
systemIOActor: ActorRefFrom<typeof systemIOMachine>
}) {
const textToCADCommand: Command = {
name: 'Text-to-CAD',
description: 'Use the Zoo Text-to-CAD API to generate part starters.',
displayName: `Text to CAD`,
groupId: 'application',
needsReview: false,
icon: 'sparkles',
onSubmit: (record) => {
if (record) {
const requestedProjectName = record.newProjectName || record.projectName
const requestedPrompt = record.prompt
const isProjectNew = !!record.newProjectName
systemIOActor.send({
type: SystemIOMachineEvents.generateTextToCAD,
data: { requestedPrompt, requestedProjectName, isProjectNew },
})
}
},
args: {
method: {
inputType: 'options',
required: true,
skip: true,
options: isDesktop()
? [
{ name: 'New project', value: 'newProject' },
{ name: 'Existing project', value: 'existingProject' },
]
: [{ name: 'Overwrite', value: 'existingProject' }],
valueSummary(value) {
return isDesktop()
? value === 'newProject'
? 'New project'
: 'Existing project'
: 'Overwrite'
},
},
projectName: {
inputType: 'options',
required: (commandsContext) =>
isDesktop() &&
commandsContext.argumentsToSubmit.method === 'existingProject',
skip: true,
options: (_, context) => {
const { folders } = systemIOActor.getSnapshot().context
const options: CommandArgumentOption<string>[] = []
folders.forEach((folder) => {
options.push({
name: folder.name,
value: folder.name,
isCurrent: false,
})
})
return options
},
},
newProjectName: {
inputType: 'text',
required: (commandsContext) =>
isDesktop() &&
commandsContext.argumentsToSubmit.method === 'newProject',
skip: true,
},
prompt: {
inputType: 'text',
required: true,
},
},
}
return isDesktop() ? [textToCADCommand] : [textToCADCommand]
}

View File

@ -151,9 +151,6 @@ export type ModelingCommandSchema = {
}
namedValue: KclCommandValue
}
'Text-to-CAD': {
prompt: string
}
'Prompt-to-edit': {
prompt: string
selection: Selections
@ -959,16 +956,6 @@ export const modelingMachineCommandConfig: StateMachineCommandSetConfig<
},
},
},
'Text-to-CAD': {
description: 'Use the Zoo Text-to-CAD API to generate part starters.',
icon: 'chat',
args: {
prompt: {
inputType: 'text',
required: true,
},
},
},
'Prompt-to-edit': {
description: 'Use Zoo AI to edit your kcl',
icon: 'chat',

View File

@ -28,6 +28,7 @@ import type { AppMachineContext } from '@src/lib/types'
import { createAuthCommands } from '@src/lib/commandBarConfigs/authCommandConfig'
import { commandBarMachine } from '@src/machines/commandBarMachine'
import { createProjectCommands } from '@src/lib/commandBarConfigs/projectsCommandConfig'
import { createApplicationCommands } from '@src/lib/commandBarConfigs/applicationCommandConfig'
export const codeManager = new CodeManager()
export const engineCommandManager = new EngineCommandManager()
@ -226,6 +227,7 @@ commandBarActor.send({
commands: [
...createAuthCommands({ authActor }),
...createProjectCommands({ systemIOActor }),
...createApplicationCommands({ systemIOActor }),
],
},
})

View File

@ -2,8 +2,6 @@ import type { Models } from '@kittycad/lib'
import { VITE_KC_API_BASE_URL } from '@src/env'
import toast from 'react-hot-toast'
import type { NavigateFunction } from 'react-router-dom'
import type { ContextFrom, EventFrom } from 'xstate'
import {
ToastTextToCadError,
ToastTextToCadSuccess,
@ -12,13 +10,12 @@ import { FILE_EXT } from '@src/lib/constants'
import crossPlatformFetch from '@src/lib/crossPlatformFetch'
import { getNextFileName } from '@src/lib/desktopFS'
import { isDesktop } from '@src/lib/isDesktop'
import { kclManager } from '@src/lib/singletons'
import type { Themes } from '@src/lib/theme'
import { kclManager, systemIOActor } from '@src/lib/singletons'
import { SystemIOMachineEvents } from '@src/machines/systemIO/utils'
import { reportRejection } from '@src/lib/trap'
import { toSync } from '@src/lib/utils'
import type { fileMachine } from '@src/machines/fileMachine'
async function submitTextToCadPrompt(
export async function submitTextToCadPrompt(
prompt: string,
projectName: string,
token?: string
@ -52,7 +49,7 @@ async function submitTextToCadPrompt(
return data
}
async function getTextToCadResult(
export async function getTextToCadResult(
id: string,
token?: string
): Promise<Models['TextToCad_type'] | Error> {
@ -68,29 +65,25 @@ async function getTextToCadResult(
return data
}
interface TextToKclProps {
interface TextToKclPropsApplicationLevel {
trimmedPrompt: string
fileMachineSend: (
type: EventFrom<typeof fileMachine>,
data?: unknown
) => unknown
navigate: NavigateFunction
context: ContextFrom<typeof fileMachine>
token?: string
settings: {
theme: Themes
projectName: string
isProjectNew: boolean
settings?: {
highlightEdges: boolean
}
}
export async function submitAndAwaitTextToKcl({
export async function submitAndAwaitTextToKclSystemIO({
trimmedPrompt,
fileMachineSend,
navigate,
context,
token,
projectName,
navigate,
isProjectNew,
settings,
}: TextToKclProps) {
}: TextToKclPropsApplicationLevel) {
const toastId = toast.loading('Submitting to Text-to-CAD API...')
const showFailureToast = (message: string) => {
toast.error(
@ -99,6 +92,9 @@ export async function submitAndAwaitTextToKcl({
toastId,
message,
prompt: trimmedPrompt,
method: isProjectNew ? 'newProject' : 'existingProject',
projectName: isProjectNew ? '' : projectName,
newProjectName: isProjectNew ? projectName : '',
}),
{
id: toastId,
@ -109,7 +105,7 @@ export async function submitAndAwaitTextToKcl({
const textToCadQueued = await submitTextToCadPrompt(
trimmedPrompt,
context.project.name,
projectName,
token
)
.then((value) => {
@ -174,12 +170,16 @@ export async function submitAndAwaitTextToKcl({
}
)
let newFileName = ''
const textToCadOutputCreated = await textToCadComplete
.catch((e) => {
showFailureToast('Failed to generate parametric model')
return e
})
.then(async (value) => {
console.log('completed')
console.log(value)
if (value.code === undefined || !value.code || value.code.length === 0) {
// We want to show the real error message to the user.
if (value.error && value.error.length > 0) {
@ -193,7 +193,7 @@ export async function submitAndAwaitTextToKcl({
}
const TRUNCATED_PROMPT_LENGTH = 24
let newFileName = `${value.prompt
newFileName = `${value.prompt
.slice(0, TRUNCATED_PROMPT_LENGTH)
.replace(/\s/gi, '-')
.replace(/\W/gi, '-')
@ -205,16 +205,15 @@ export async function submitAndAwaitTextToKcl({
// and by extension the file-deletion-on-reject logic.
newFileName = getNextFileName({
entryName: newFileName,
baseDir: context.selectedDirectory.path,
baseDir: projectName,
}).name
fileMachineSend({
type: 'Create file',
systemIOActor.send({
type: SystemIOMachineEvents.createKCLFile,
data: {
name: newFileName,
makeDir: false,
content: value.code,
silent: true,
requestedProjectName: projectName,
requestedCode: value.code,
requestedFileName: newFileName,
},
})
}
@ -234,13 +233,16 @@ export async function submitAndAwaitTextToKcl({
// and options to reject or accept the model
toast.success(
() =>
// EXPECTED: This will throw a error in dev mode, do not worry about it.
// Warning: Internal React error: Expected static flag was missing. Please notify the React team.
ToastTextToCadSuccess({
toastId,
data: textToCadOutputCreated,
token,
projectName: projectName,
fileName: newFileName,
navigate,
context,
fileMachineSend,
isProjectNew,
settings,
}),
{

View File

@ -1,5 +1,6 @@
import { DEV } from '@src/env'
import type { EventFrom, StateFrom } from 'xstate'
import { settingsActor } from '@src/lib/singletons'
import type { CustomIconName } from '@src/components/CustomIcon'
import { createLiteral } from '@src/lang/create'
@ -418,11 +419,21 @@ export const toolbarConfig: Record<ToolbarModeName, ToolbarMode> = {
array: [
{
id: 'text-to-cad',
onClick: () =>
onClick: () => {
const currentProject =
settingsActor.getSnapshot().context.currentProject
commandBarActor.send({
type: 'Find and select command',
data: { name: 'Text-to-CAD', groupId: 'modeling' },
}),
data: {
name: 'Text-to-CAD',
groupId: 'application',
argDefaultValues: {
method: 'existingProject',
projectName: currentProject?.name,
},
},
})
},
icon: 'sparkles',
iconColor: '#29FFA4',
alwaysDark: true,

View File

@ -63,6 +63,7 @@ type FileMachineEvents =
}
| { type: 'assign'; data: { [key: string]: any } }
| { type: 'Refresh' }
| { type: 'Refresh with new project'; data: { project: Project } }
export const fileMachine = setup({
types: {} as {
@ -95,6 +96,10 @@ export const fileMachine = setup({
)
},
}),
setProject: assign(({ event }) => {
if (event.type !== 'Refresh with new project') return {}
return { project: event.data.project }
}),
navigateToFile: () => {},
openFileInNewWindow: () => {},
renameToastSuccess: () => {},
@ -183,6 +188,10 @@ export const fileMachine = setup({
},
Refresh: '.Reading files',
'Refresh with new project': {
actions: ['setProject'],
target: '.Reading files',
},
},
states: {
'Has no files': {

View File

@ -371,7 +371,6 @@ export type ModelingMachineEvent =
| { type: 'Chamfer'; data?: ModelingCommandSchema['Chamfer'] }
| { type: 'Offset plane'; data: ModelingCommandSchema['Offset plane'] }
| { type: 'Helix'; data: ModelingCommandSchema['Helix'] }
| { type: 'Text-to-CAD'; data: ModelingCommandSchema['Text-to-CAD'] }
| { type: 'Prompt-to-edit'; data: ModelingCommandSchema['Prompt-to-edit'] }
| {
type: 'Delete selection'

View File

@ -19,3 +19,9 @@ export const useHasListedProjects = () =>
export const useClearURLParams = () =>
useSelector(systemIOActor, (state) => state.context.clearURLParams)
export const useRequestedTextToCadGeneration = () =>
useSelector(
systemIOActor,
(state) => state.context.requestedTextToCadGeneration
)

View File

@ -76,6 +76,22 @@ export const systemIOMachine = setup({
| {
type: SystemIOMachineEvents.setDefaultProjectFolderName
data: { requestedDefaultProjectFolderName: string }
}
// TODO: Move this generateTextToCAD to another machine in the future and make a whole machine out of it.
| {
type: SystemIOMachineEvents.generateTextToCAD
data: {
requestedPrompt: string
requestedProjectName: string
isProjectNew: boolean
}
}
| {
type: SystemIOMachineEvents.deleteKCLFile
data: {
requestedProjectName: string
requestedFileName: string
}
},
},
actions: {
@ -143,6 +159,12 @@ export const systemIOMachine = setup({
return event.output
},
}),
[SystemIOMachineActions.setRequestedTextToCadGeneration]: assign({
requestedTextToCadGeneration: ({ event }) => {
assertEvent(event, SystemIOMachineEvents.generateTextToCAD)
return event.data
},
}),
},
actors: {
[SystemIOMachineActors.readFoldersFromProjectDirectory]: fromPromise(
@ -213,6 +235,23 @@ export const systemIOMachine = setup({
return { value: true, error: undefined }
}
),
[SystemIOMachineActors.deleteKCLFile]: fromPromise(
async ({
input,
}: {
input: {
context: SystemIOContext
requestedProjectName: string
requestedFileName: string
}
}): Promise<{
message: string
fileName: string
projectName: string
}> => {
return { message: '', fileName: '', projectName: '' }
}
),
},
}).createMachine({
initial: SystemIOMachineStates.idle,
@ -231,6 +270,11 @@ export const systemIOMachine = setup({
},
canReadWriteProjectDirectory: { value: true, error: undefined },
clearURLParams: { value: false },
requestedTextToCadGeneration: {
requestedPrompt: '',
requestedProjectName: NO_PROJECT_DIRECTORY,
isProjectNew: true,
},
}),
states: {
[SystemIOMachineStates.idle]: {
@ -267,6 +311,12 @@ export const systemIOMachine = setup({
[SystemIOMachineEvents.importFileFromURL]: {
target: SystemIOMachineStates.importFileFromURL,
},
[SystemIOMachineEvents.generateTextToCAD]: {
actions: [SystemIOMachineActions.setRequestedTextToCadGeneration],
},
[SystemIOMachineEvents.deleteKCLFile]: {
target: SystemIOMachineStates.deletingKCLFile,
},
},
},
[SystemIOMachineStates.readingFolders]: {
@ -374,7 +424,7 @@ export const systemIOMachine = setup({
}
},
onDone: {
target: SystemIOMachineStates.idle,
target: SystemIOMachineStates.readingFolders,
},
onError: {
target: SystemIOMachineStates.idle,
@ -440,5 +490,26 @@ export const systemIOMachine = setup({
},
},
},
[SystemIOMachineStates.deletingKCLFile]: {
invoke: {
id: SystemIOMachineActors.deleteKCLFile,
src: SystemIOMachineActors.deleteKCLFile,
input: ({ context, event }) => {
assertEvent(event, SystemIOMachineEvents.deleteKCLFile)
return {
context,
requestedProjectName: event.data.requestedProjectName,
requestedFileName: event.data.requestedFileName,
}
},
onDone: {
target: SystemIOMachineStates.readingFolders,
},
onError: {
target: SystemIOMachineStates.readingFolders,
actions: [SystemIOMachineActions.toastError],
},
},
},
},
})

View File

@ -228,5 +228,28 @@ export const systemIOMachineDesktop = systemIOMachine.provide({
return result
}
),
[SystemIOMachineActors.deleteKCLFile]: fromPromise(
async ({
input,
}: {
input: {
context: SystemIOContext
requestedProjectName: string
requestedFileName: string
}
}) => {
const path = window.electron.path.join(
input.context.projectDirectoryPath,
input.requestedProjectName,
input.requestedFileName
)
await window.electron.rm(path)
return {
message: 'File deleted successfully',
projectName: input.requestedProjectName,
fileName: input.requestedFileName,
}
}
),
},
})

View File

@ -9,6 +9,7 @@ export enum SystemIOMachineActors {
createKCLFile = 'create kcl file',
checkReadWrite = 'check read write',
importFileFromURL = 'import file from URL',
deleteKCLFile = 'delete kcl delete',
}
export enum SystemIOMachineStates {
@ -21,6 +22,7 @@ export enum SystemIOMachineStates {
creatingKCLFile = 'creatingKCLFile',
checkingReadWrite = 'checkingReadWrite',
importFileFromURL = 'importFileFromURL',
deletingKCLFile = 'deletingKCLFile',
}
const donePrefix = 'xstate.done.actor.'
@ -40,6 +42,8 @@ export enum SystemIOMachineEvents {
done_checkReadWrite = donePrefix + 'check read write',
importFileFromURL = 'import file from URL',
done_importFileFromURL = donePrefix + 'import file from URL',
generateTextToCAD = 'generate text to CAD',
deleteKCLFile = 'delete kcl file',
}
export enum SystemIOMachineActions {
@ -51,6 +55,7 @@ export enum SystemIOMachineActions {
toastSuccess = 'toastSuccess',
toastError = 'toastError',
setReadWriteProjectDirectory = 'set read write project directory',
setRequestedTextToCadGeneration = 'set requested text to cad generation',
}
export const NO_PROJECT_DIRECTORY = ''
@ -70,4 +75,9 @@ export type SystemIOContext = {
requestedFileName: { project: string; file: string }
canReadWriteProjectDirectory: { value: boolean; error: unknown }
clearURLParams: { value: boolean }
requestedTextToCadGeneration: {
requestedPrompt: string
requestedProjectName: string
isProjectNew: boolean
}
}