Compare commits

...

12 Commits

Author SHA1 Message Date
9b177b9cbd Change units KCL functions to read from new module local setting 2024-12-21 16:05:57 -05:00
bb51646738 Fix e2e default planes tests to use mask on state indicator (#4849)
* Fix e2e default planes tests to use mask on state indicator

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* A snapshot a day keeps the bugs away! 📷🐛 (OS: windows-16-cores)

* A snapshot a day keeps the bugs away! 📷🐛 (OS: windows-16-cores)

* Trigger CI

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-12-20 17:29:14 -05:00
c02e31a530 First draft of a feature tree pane (#4782) 2024-12-20 16:19:59 -05:00
1d06cc7845 Add 3-point circle tool (#4832)
* Add 3-point circle tool

This adds a 1st pass for the 3-point circle tool.

There is disabled code to drag around the 3 points and redraw the circle and
a triangle created by those points. It will be enabled in a follow-up PR
when we have circle3Point in the stdlib.

For now, all it does is after the 3rd click, will insert circle center-radius
KCL code for users to modify.

* PR comments
2024-12-20 19:30:37 +00:00
e0c07eecfe Re-enable test 'code pane closed at start' after electron migration (#4841)
* Re-enable 'code pane closed at start'
Relates to #4838

* Enable test on current branch

* Revert "Enable test on current branch"

This reverts commit 0d970b9ad6.
2024-12-20 10:04:03 -05:00
c5d42500fa update discord bot (#4837) 2024-12-20 09:31:21 -05:00
e6e47f77f0 Tweak prompt wording (#4846)
tweak prompt wording
2024-12-19 19:52:07 -08:00
662c2485ac Yeet telemetry to text to cad endpoint (#4847)
yeet telemetry to text to cad endpoint
2024-12-20 03:50:05 +00:00
9f891deebb Prompt to edit (#4830)
* initial plumbing for getting the new option into the cmd-bar

* start of prompt edit

* update AI poll

* add spinner

* more prompt engineering

* add success toast, allowing user's to reject code

* select code that changed in prompt to edit

* selection in scene should not disappear when opening prompt cmd

* tweak

* fmt

* add tests

* some clean up

* clean up

* fix tests
2024-12-20 13:39:06 +11:00
d08a07a1f8 Remove guards from modeling commands in the toolbar (#4800)
* Remove guards from modeling commands in the toolbar

* A snapshot a day keeps the bugs away! 📷🐛 (OS: ubuntu-latest-8-cores)

* A snapshot a day keeps the bugs away! 📷🐛 (OS: ubuntu-latest-8-cores)

* A snapshot a day keeps the bugs away! 📷🐛 (OS: windows-latest-8-cores)

* Remove the deprecated function, update doc comment for the one still in use

* A snapshot a day keeps the bugs away! 📷🐛 (OS: ubuntu-latest-8-cores)

* A snapshot a day keeps the bugs away! 📷🐛 (OS: ubuntu-latest-8-cores)

* A snapshot a day keeps the bugs away! 📷🐛 (OS: windows-latest-8-cores)

* A snapshot a day keeps the bugs away! 📷🐛 (OS: windows-latest-8-cores)

* A snapshot a day keeps the bugs away! 📷🐛 (OS: windows-latest-8-cores)

* Remove more selection check functions that are no longer used

* Update E2E tests that assumed the extrude button could be disabled due to selection

* Update a few fillet tests that expected the button to disable based on selection

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* A snapshot a day keeps the bugs away! 📷🐛 (OS: windows-16-cores)

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-macos-8-cores)

* Trigger CI

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* A snapshot a day keeps the bugs away! 📷🐛 (OS: windows-16-cores)

* Trigger CI

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Pierre Jacquier <pierre@zoo.dev>
2024-12-19 18:42:39 -05:00
872b196a86 Fix mac issue after electron test migration PR (#4842)
Fix mac issue after electron test migration pR
2024-12-19 21:27:00 +00:00
d535a2862d Implement some basic cache program generation (#4840)
Implement some basic cache program generation

A bit ago, @jessfraz added the ability to control reexecution from the
executor. When we did this, it used the digest to determine if there
was a code change rather than a non-code change (whitespace, comment,
etc).

This allows the creation of a new program to be run without clearing the
scene. This, in conjunction with being able to delete Engine objects by
ID will allow us to do some clever stuff when incrementally executing
a program.

I'm still working on something a bit more advanced, but a good first
step to derisk some of the caching behavior here fully is to implement a
basic "changed program" stub.

This process the ast programs (old and new) if it doesn't exactly match.
This would have been a complete refresh before this commit.

 1) Check all overlapping top-level statements of the body of the new and
    old AST and ensure they all match.
      - If this is true, this means that one of the two AST programs has more
        elements then the other, and they all otherwise match (addition or
        deletion of the end of the program). We continue to #2 in this
        case.
      - If this is false, we have a meaingful difference in a section of
        overlapping code. This will result in a cache miss and rebuild
        the scene. We short-cut here and the scene is rebuilt.

 2) Check the lengths of the two bodies.
   - If they're the same, we shouldn't have even been called. We will
     short-cut with a noop cache return (no clear, no program).
   - if the old ast is longer, we've removed instructions from the
     program. We can't delete things now, so this will result in a cache
     miss and rebuild the scene. We short-cut here and the scene is
     rebuilt.
   - If the new ast is longer, we have an insertion of code at the end.

 3) construct a new program using only the new elements from the new
    ast, and return a `CacheResult` that *does not clear the scene*.
    This means nothing will be rebuilt, and only a new object will polp
    onto the scene. This is the first case where we diverge with
    existing behavior.

Signed-off-by: Paul R. Tagliamonte <paul@zoo.dev>
2024-12-19 21:18:35 +00:00
99 changed files with 2789 additions and 557 deletions

View File

@ -149,7 +149,7 @@ test.describe('Basic sketch', () => {
await doBasicSketch(page, homePage, ['code'])
})
test.fixme('code pane closed at start', async ({ page, homePage }) => {
test('code pane closed at start', async ({ page, homePage }) => {
// Load the app with the code panes
await page.addInitScript(async (persistModelingContext) => {
localStorage.setItem(

View File

@ -76,7 +76,7 @@ test.describe('Editor tests', () => {
await u.openDebugPanel()
await expect(
page.locator('[data-receive-command-type="scene_clear_all"]')
).toHaveCount(2)
).toHaveCount(1)
await expect(
page.locator('[data-message-type="execution-done"]')
).toHaveCount(2)
@ -100,7 +100,60 @@ test.describe('Editor tests', () => {
).toHaveCount(3)
await expect(
page.locator('[data-receive-command-type="scene_clear_all"]')
).toHaveCount(1)
})
test('ensure we use the cache, and do not clear on append', async ({
homePage,
page,
}) => {
const u = await getUtils(page)
await page.setBodyDimensions({ width: 1000, height: 500 })
await homePage.goToModelingScene()
await u.waitForPageLoad()
await u.codeLocator.click()
await page.keyboard.type(`sketch001 = startSketchOn('XY')
|> startProfileAt([-10, -10], %)
|> line([20, 0], %)
|> line([0, 20], %)
|> line([-20, 0], %)
|> close(%)`)
// 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)
// Add whitespace to the end of the code.
await u.codeLocator.click()
await page.keyboard.press('ArrowDown')
await page.keyboard.press('ArrowDown')
await page.keyboard.press('ArrowDown')
await page.keyboard.press('ArrowDown')
await page.keyboard.press('ArrowDown')
await page.keyboard.press('ArrowDown')
await page.keyboard.press('ArrowDown')
await page.keyboard.press('ArrowDown')
await page.keyboard.press('ArrowDown')
await page.keyboard.press('End')
await page.keyboard.press('Enter')
await page.keyboard.press('Enter')
await page.keyboard.type('const x = 1')
await page.keyboard.press('Enter')
await u.openDebugPanel()
await expect(
page.locator('[data-message-type="execution-done"]')
).toHaveCount(3)
await expect(
page.locator('[data-receive-command-type="scene_clear_all"]')
).toHaveCount(1)
})
test('if you click the format button it formats your code', async ({

View File

@ -0,0 +1,127 @@
import { test, expect } from './zoo-test'
import * as fsp from 'fs/promises'
import { join } from 'path'
const FEATURE_TREE_EXAMPLE_CODE = `export fn timesFive(x) {
return 5 * x
}
export fn triangle() {
return startSketchOn('XZ')
|> startProfileAt([0, 0], %)
|> xLine(10, %)
|> line([-10, -5], %)
|> lineTo([profileStartX(%), profileStartY(%)], %)
|> close(%)
}
length001 = timesFive(1) * 5
sketch001 = startSketchOn('XZ')
|> startProfileAt([20, 10], %)
|> line([10, 10], %)
|> angledLine([-45, length001], %)
|> lineTo([profileStartX(%), profileStartY(%)], %)
|> close(%)
revolve001 = revolve({ axis = "X" }, sketch001)
triangle()
|> extrude(30, %)
plane001 = offsetPlane('XY', 10)
sketch002 = startSketchOn(plane001)
|> startProfileAt([-20, 0], %)
|> line([5, -15], %)
|> xLine(-10, %)
|> lineTo([-40, 0], %)
|> lineTo([profileStartX(%), profileStartY(%)], %)
|> close(%)
extrude001 = extrude(10, sketch002)
`
test.describe('Feature Tree pane', () => {
test(
'User can go to definition and go to function definition',
{ tag: '@electron' },
async ({ context, homePage, scene, editor, toolbar }) => {
await context.folderSetupFn(async (dir) => {
const bracketDir = join(dir, 'test-sample')
await fsp.mkdir(bracketDir, { recursive: true })
await fsp.writeFile(
join(bracketDir, 'main.kcl'),
FEATURE_TREE_EXAMPLE_CODE,
'utf-8'
)
})
await test.step('setup test', async () => {
await homePage.expectState({
projectCards: [
{
title: 'test-sample',
fileCount: 1,
},
],
sortBy: 'last-modified-desc',
})
await homePage.openProject('test-sample')
await scene.waitForExecutionDone()
await editor.closePane()
await toolbar.openFeatureTreePane()
})
async function testViewSource({
operationName,
operationIndex,
expectedActiveLine,
}: {
operationName: string
operationIndex: number
expectedActiveLine: string
}) {
await test.step(`Go to definition of the ${operationName}`, async () => {
await toolbar.viewSourceOnOperation(operationName, operationIndex)
await editor.expectState({
highlightedCode: '',
diagnostics: [],
activeLines: [expectedActiveLine],
})
await expect(
editor.activeLine.first(),
`${operationName} code should be scrolled into view`
).toBeVisible()
})
}
await testViewSource({
operationName: 'Offset Plane',
operationIndex: 0,
expectedActiveLine: "plane001 = offsetPlane('XY', 10)",
})
await testViewSource({
operationName: 'Extrude',
operationIndex: 1,
expectedActiveLine: 'extrude001 = extrude(10, sketch002)',
})
await testViewSource({
operationName: 'Revolve',
operationIndex: 0,
expectedActiveLine: 'revolve001 = revolve({ axis = "X" }, sketch001)',
})
await testViewSource({
operationName: 'Triangle',
operationIndex: 0,
expectedActiveLine: 'triangle()',
})
await test.step('Go to definition on the triangle function', async () => {
await toolbar.goToDefinitionOnOperation('Triangle', 0)
await editor.expectState({
highlightedCode: '',
diagnostics: [],
activeLines: ['export fn triangle() {'],
})
await expect(
editor.activeLine.first(),
'Triangle function definition should be scrolled into view'
).toBeVisible()
})
}
)
})

View File

@ -1,4 +1,4 @@
import type { Page } from '@playwright/test'
import type { Page, Locator } from '@playwright/test'
import { expect } from '@playwright/test'
type CmdBarSerialised =
@ -26,9 +26,11 @@ type CmdBarSerialised =
export class CmdBarFixture {
public page: Page
cmdBarOpenBtn!: Locator
constructor(page: Page) {
this.page = page
this.cmdBarOpenBtn = page.getByTestId('command-bar-open-button')
}
reConstruct = (page: Page) => {
this.page = page
@ -116,4 +118,21 @@ export class CmdBarFixture {
await this.page.keyboard.press('Enter')
}
}
openCmdBar = async (selectCmd?: 'promptToEdit') => {
// TODO why does this button not work in electron tests?
// await this.cmdBarOpenBtn.click()
await this.page.keyboard.down('ControlOrMeta')
await this.page.keyboard.press('KeyK')
await this.page.keyboard.up('ControlOrMeta')
await expect(this.page.getByPlaceholder('Search commands')).toBeVisible()
if (selectCmd === 'promptToEdit') {
const promptEditCommand = this.page.getByText(
'Use Zoo AI to edit your kcl'
)
await expect(promptEditCommand.first()).toBeVisible()
await promptEditCommand.first().scrollIntoViewIfNeeded()
await promptEditCommand.first().click()
}
}
}

View File

@ -20,7 +20,7 @@ export class EditorFixture {
private diagnosticsTooltip!: Locator
private diagnosticsGutterIcon!: Locator
private codeContent!: Locator
private activeLine!: Locator
public activeLine!: Locator
constructor(page: Page) {
this.page = page

View File

@ -1,6 +1,13 @@
import type { Page, Locator } from '@playwright/test'
import { expect } from '../zoo-test'
import { doAndWaitForImageDiff } from '../test-utils'
import {
checkIfPaneIsOpen,
closePane,
doAndWaitForImageDiff,
openPane,
} from '../test-utils'
import { SidebarType } from 'components/ModelingSidebar/ModelingPanes'
import { SIDEBAR_BUTTON_SUFFIX } from 'lib/constants'
export class ToolbarFixture {
public page: Page
@ -20,6 +27,10 @@ export class ToolbarFixture {
filePane!: Locator
exeIndicator!: Locator
treeInputField!: Locator
/** The sidebar button for the Feature Tree pane */
featureTreeId = 'feature-tree' as const
/** The pane element for the Feature Tree */
featureTreePane!: Locator
constructor(page: Page) {
this.page = page
@ -41,6 +52,7 @@ export class ToolbarFixture {
this.treeInputField = page.getByTestId('tree-input-field')
this.filePane = page.locator('#files-pane')
this.featureTreePane = page.locator('#feature-tree-pane')
this.fileCreateToast = page.getByText('Successfully created')
this.exeIndicator = page.getByTestId('model-state-indicator-execution-done')
}
@ -91,4 +103,76 @@ export class ToolbarFixture {
await expect(this.exeIndicator).toBeVisible({ timeout: 15_000 })
}
}
async closePane(paneId: SidebarType) {
return closePane(this.page, paneId + SIDEBAR_BUTTON_SUFFIX)
}
async openPane(paneId: SidebarType) {
return openPane(this.page, paneId + SIDEBAR_BUTTON_SUFFIX)
}
async checkIfPaneIsOpen(paneId: SidebarType) {
return checkIfPaneIsOpen(this.page, paneId + SIDEBAR_BUTTON_SUFFIX)
}
async openFeatureTreePane() {
return this.openPane(this.featureTreeId)
}
async closeFeatureTreePane() {
await this.closePane(this.featureTreeId)
}
async checkIfFeatureTreePaneIsOpen() {
return this.checkIfPaneIsOpen(this.featureTreeId)
}
/**
* Get a specific operation button from the Feature Tree pane
*/
async getFeatureTreeOperation(operationName: string, operationIndex: number) {
await this.openFeatureTreePane()
await expect(this.featureTreePane).toBeVisible()
return this.featureTreePane
.getByRole('button', {
name: operationName,
})
.nth(operationIndex)
}
/**
* View source on a specific operation in the Feature Tree pane.
* @param operationName The name of the operation type
* @param operationIndex The index out of operations of this type
*/
async viewSourceOnOperation(operationName: string, operationIndex: number) {
const operationButton = await this.getFeatureTreeOperation(
operationName,
operationIndex
)
const viewSourceMenuButton = this.page.getByRole('button', {
name: 'View KCL source code',
})
await operationButton.click({ button: 'right' })
await expect(viewSourceMenuButton).toBeVisible()
await viewSourceMenuButton.click()
}
/**
* Go to definition on a specific operation in the Feature Tree pane
*/
async goToDefinitionOnOperation(
operationName: string,
operationIndex: number
) {
const operationButton = await this.getFeatureTreeOperation(
operationName,
operationIndex
)
const goToDefinitionMenuButton = this.page.getByRole('button', {
name: 'View function definition',
})
await operationButton.click({ button: 'right' })
await expect(goToDefinitionMenuButton).toBeVisible()
await goToDefinitionMenuButton.click()
}
}

View File

@ -0,0 +1,190 @@
import { test, expect } from './zoo-test'
/* eslint-disable jest/no-conditional-expect */
const file = `sketch001 = startSketchOn('XZ')
profile001 = startProfileAt([57.81, 250.51], sketch001)
|> line([121.13, 56.63], %, $seg02)
|> line([83.37, -34.61], %, $seg01)
|> line([19.66, -116.4], %)
|> line([-221.8, -41.69], %)
|> lineTo([profileStartX(%), profileStartY(%)], %)
|> close(%)
extrude001 = extrude(200, profile001)
sketch002 = startSketchOn('XZ')
|> startProfileAt([-73.64, -42.89], %)
|> xLine(173.71, %)
|> line([-22.12, -94.4], %)
|> xLine(-156.98, %)
|> lineTo([profileStartX(%), profileStartY(%)], %)
|> close(%)
extrude002 = extrude(50, sketch002)
sketch003 = startSketchOn('XY')
|> startProfileAt([52.92, 157.81], %)
|> angledLine([0, 176.4], %, $rectangleSegmentA001)
|> angledLine([
segAng(rectangleSegmentA001) - 90,
53.4
], %, $rectangleSegmentB001)
|> angledLine([
segAng(rectangleSegmentA001),
-segLen(rectangleSegmentA001)
], %, $rectangleSegmentC001)
|> lineTo([profileStartX(%), profileStartY(%)], %)
|> close(%)
extrude003 = extrude(20, sketch003)
`
test.describe('Check the happy path, for basic changing color', () => {
const cases = [
{
desc: 'User accepts change',
shouldReject: false,
},
{
desc: 'User rejects change',
shouldReject: true,
},
] as const
for (const { desc, shouldReject } of cases) {
test(`${desc}`, async ({
context,
homePage,
cmdBar,
editor,
page,
scene,
}) => {
await context.addInitScript((file) => {
localStorage.setItem('persistCode', file)
}, file)
await homePage.goToModelingScene()
const body1CapCoords = { x: 571, y: 351 }
const greenCheckCoords = { x: 565, y: 345 }
const body2WallCoords = { x: 609, y: 153 }
const [clickBody1Cap] = scene.makeMouseHelpers(
body1CapCoords.x,
body1CapCoords.y
)
const yellow: [number, number, number] = [179, 179, 131]
const green: [number, number, number] = [108, 152, 75]
const notGreen: [number, number, number] = [132, 132, 132]
const body2NotGreen: [number, number, number] = [88, 88, 88]
const submittingToast = page.getByText('Submitting to Text-to-CAD API...')
const successToast = page.getByText('Prompt to edit successful')
const acceptBtn = page.getByRole('button', { name: 'checkmark Accept' })
const rejectBtn = page.getByRole('button', { name: 'close Reject' })
await test.step('wait for scene to load select body and check selection came through', async () => {
await scene.expectPixelColor([134, 134, 134], body1CapCoords, 15)
await clickBody1Cap()
await scene.expectPixelColor(yellow, body1CapCoords, 20)
await editor.expectState({
highlightedCode: '',
activeLines: ['|>startProfileAt([-73.64,-42.89],%)'],
diagnostics: [],
})
})
await test.step('fire off edit prompt', async () => {
await cmdBar.openCmdBar('promptToEdit')
// being specific about the color with a hex means asserting pixel color is more stable
await page
.getByTestId('cmd-bar-arg-value')
.fill('make this neon green please, use #39FF14')
await page.waitForTimeout(100)
await cmdBar.progressCmdBar()
await expect(submittingToast).toBeVisible()
await expect(submittingToast).not.toBeVisible({ timeout: 2 * 60_000 }) // can take a while
await expect(successToast).toBeVisible()
})
await test.step('verify initial change', async () => {
await scene.expectPixelColor(green, greenCheckCoords, 15)
await scene.expectPixelColor(body2NotGreen, body2WallCoords, 15)
await editor.expectEditor.toContain('appearance({')
})
if (!shouldReject) {
await test.step('check accept works and can be "undo"ed', async () => {
await acceptBtn.click()
await expect(successToast).not.toBeVisible()
await scene.expectPixelColor(green, greenCheckCoords, 15)
await editor.expectEditor.toContain('appearance({')
// ctrl-z works after accepting
await page.keyboard.down('ControlOrMeta')
await page.keyboard.press('KeyZ')
await page.keyboard.up('ControlOrMeta')
await editor.expectEditor.not.toContain('appearance({')
await scene.expectPixelColor(notGreen, greenCheckCoords, 15)
})
} else {
await test.step('check reject works', async () => {
await rejectBtn.click()
await expect(successToast).not.toBeVisible()
await scene.expectPixelColor(notGreen, greenCheckCoords, 15)
await editor.expectEditor.not.toContain('appearance({')
})
}
})
}
})
test.describe('bad path', () => {
test(`bad edit prompt`, async ({
context,
homePage,
cmdBar,
editor,
toolbar,
page,
scene,
}) => {
await context.addInitScript((file) => {
localStorage.setItem('persistCode', file)
}, file)
await homePage.goToModelingScene()
const body1CapCoords = { x: 571, y: 351 }
const [clickBody1Cap] = scene.makeMouseHelpers(
body1CapCoords.x,
body1CapCoords.y
)
const yellow: [number, number, number] = [179, 179, 131]
const submittingToast = page.getByText('Submitting to Text-to-CAD API...')
const failToast = page.getByText(
'Failed to edit your KCL code, please try again with a different prompt or selection'
)
await test.step('wait for scene to load and select body', async () => {
await scene.expectPixelColor([134, 134, 134], body1CapCoords, 15)
await clickBody1Cap()
await scene.expectPixelColor(yellow, body1CapCoords, 20)
await editor.expectState({
highlightedCode: '',
activeLines: ['|>startProfileAt([-73.64,-42.89],%)'],
diagnostics: [],
})
})
await test.step('fire of bad prompt', async () => {
await cmdBar.openCmdBar('promptToEdit')
await page
.getByTestId('cmd-bar-arg-value')
.fill('ansheusha asnthuatshoeuhtaoetuhthaeu laughs in dvorak')
await page.waitForTimeout(100)
await cmdBar.progressCmdBar()
await expect(submittingToast).toBeVisible()
})
await test.step('check fail toast appeared', async () => {
await expect(submittingToast).not.toBeVisible({ timeout: 2 * 60_000 }) // can take a while
await expect(failToast).toBeVisible()
})
})
})

View File

@ -82,19 +82,16 @@ test.describe('Sketch tests', () => {
await u.closeDebugPanel()
await page.getByText(selectionsSnippets.startProfileAt1).click()
await expect(page.getByRole('button', { name: 'Extrude' })).toBeDisabled()
await expect(
page.getByRole('button', { name: 'Edit Sketch' })
).toBeVisible()
await page.getByText(selectionsSnippets.startProfileAt2).click()
await expect(page.getByRole('button', { name: 'Extrude' })).toBeDisabled()
await expect(
page.getByRole('button', { name: 'Edit Sketch' })
).toBeVisible()
await page.getByText(selectionsSnippets.startProfileAt3).click()
await expect(page.getByRole('button', { name: 'Extrude' })).toBeDisabled()
await expect(
page.getByRole('button', { name: 'Edit Sketch' })
).toBeVisible()

View File

@ -375,6 +375,7 @@ const extrudeDefaultPlane = async (context: any, page: any, plane: string) => {
await u.closeKclCodePanel()
await expect(page).toHaveScreenshot({
maxDiffPixels: 100,
mask: [page.getByTestId('model-state-indicator')],
})
await u.openKclCodePanel()
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 148 KiB

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 144 KiB

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 132 KiB

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 128 KiB

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 38 KiB

View File

@ -486,7 +486,7 @@ test.describe('Testing selections', () => {
await u.clearCommandLogs()
await page.keyboard.press('Backspace')
await expect(page.getByText('Unable to delete part')).toBeVisible()
await expect(page.getByText('Unable to delete selection')).toBeVisible()
})
test('Hovering over 3d features highlights code, clicking puts the cursor in the right place and sends selection id to engine', async ({
page,
@ -874,17 +874,15 @@ test.describe('Testing selections', () => {
}
const clickEmpty = () => page.mouse.click(700, 460)
await selectUnExtrudable()
// expect extrude button to be disabled
await expect(page.getByRole('button', { name: 'Extrude' })).toBeDisabled()
// expect extrude button to be enabled, since we don't guard
// until the extrude button is clicked
await expect(page.getByRole('button', { name: 'Extrude' })).toBeEnabled()
await clickEmpty()
// expect active line to contain nothing
await expect(page.locator('.cm-activeLine')).toHaveText('')
// and extrude to still be disabled
await expect(page.getByRole('button', { name: 'Extrude' })).toBeDisabled()
const codeToAdd = `${await u.codeLocator.allInnerTexts()}
sketch002 = startSketchOn(extrude001, $seg01)
|> startProfileAt([-12.94, 6.6], %)
@ -896,8 +894,9 @@ test.describe('Testing selections', () => {
await u.codeLocator.fill(codeToAdd)
await selectUnExtrudable()
// expect extrude button to be disabled
await expect(page.getByRole('button', { name: 'Extrude' })).toBeDisabled()
// expect extrude button to be enabled, since we don't guard
// until the extrude button is clicked
await expect(page.getByRole('button', { name: 'Extrude' })).toBeEnabled()
await clickEmpty()
await expect(page.locator('.cm-activeLine')).toHaveText('')
@ -932,11 +931,14 @@ test.describe('Testing selections', () => {
const selectClose = () => page.getByText(`close(%)`).click()
const clickEmpty = () => page.mouse.click(950, 100)
// expect fillet button without any bodies in the scene
// Now that we don't disable toolbar buttons based on selection,
// but rather based on a "selection" step in the command palette,
// the fillet button should always be enabled with a good network connection.
// I'm not sure if this test is actually useful anymore.
await selectSegment()
await expect(page.getByRole('button', { name: 'Fillet' })).toBeDisabled()
await expect(page.getByRole('button', { name: 'Fillet' })).toBeEnabled()
await clickEmpty()
await expect(page.getByRole('button', { name: 'Fillet' })).toBeDisabled()
await expect(page.getByRole('button', { name: 'Fillet' })).toBeEnabled()
// test fillet button with the body in the scene
const codeToAdd = `${await u.codeLocator.allInnerTexts()}
@ -946,7 +948,7 @@ test.describe('Testing selections', () => {
await selectSegment()
await expect(page.getByRole('button', { name: 'Fillet' })).toBeEnabled()
await selectClose()
await expect(page.getByRole('button', { name: 'Fillet' })).toBeDisabled()
await expect(page.getByRole('button', { name: 'Fillet' })).toBeEnabled()
await clickEmpty()
await expect(page.getByRole('button', { name: 'Fillet' })).toBeEnabled()
})
@ -1201,7 +1203,9 @@ test.describe('Testing selections', () => {
).not.toBeDisabled()
await page.getByText(selectionsSnippets.extrudeAndEditBlocked).click()
await expect(page.getByRole('button', { name: 'Extrude' })).toBeDisabled()
// expect extrude button to be enabled, since we don't guard
// until the extrude button is clicked
await expect(page.getByRole('button', { name: 'Extrude' })).toBeEnabled()
await page.getByText(selectionsSnippets.extrudeAndEditAllowed).click()
await expect(
@ -1212,7 +1216,9 @@ test.describe('Testing selections', () => {
).not.toBeDisabled()
await page.getByText(selectionsSnippets.editOnly).click()
await expect(page.getByRole('button', { name: 'Extrude' })).toBeDisabled()
// expect extrude button to be enabled, since we don't guard
// until the extrude button is clicked
await expect(page.getByRole('button', { name: 'Extrude' })).toBeEnabled()
await expect(
page.getByRole('button', { name: 'Edit Sketch' })
).not.toBeDisabled()
@ -1220,7 +1226,9 @@ test.describe('Testing selections', () => {
await page
.getByText(selectionsSnippets.extrudeAndEditBlockedInFunction)
.click()
await expect(page.getByRole('button', { name: 'Extrude' })).toBeDisabled()
// expect extrude button to be enabled, since we don't guard
// until the extrude button is clicked
await expect(page.getByRole('button', { name: 'Extrude' })).toBeEnabled()
await expect(
page.getByRole('button', { name: 'Edit Sketch' })
).not.toBeVisible()

View File

@ -39,6 +39,7 @@
"chokidar": "^4.0.1",
"codemirror": "^6.0.1",
"decamelize": "^6.0.0",
"diff": "^7.0.0",
"electron-updater": "6.3.0",
"fuse.js": "^7.0.0",
"html2canvas-pro": "^1.5.8",
@ -154,6 +155,7 @@
"@testing-library/jest-dom": "^5.14.1",
"@testing-library/react": "^15.0.2",
"@types/d3-force": "^3.0.10",
"@types/diff": "^6.0.0",
"@types/electron": "^1.6.10",
"@types/isomorphic-fetch": "^0.0.39",
"@types/minimist": "^1.2.5",

View File

@ -1,6 +1,7 @@
import re
import os
import requests
import textwrap
webhook_url = os.getenv('DISCORD_WEBHOOK_URL')
release_version = os.getenv('RELEASE_VERSION')
@ -25,12 +26,11 @@ if len(modified_release_body) > max_length:
# Message to send to Discord
data = {
"content":
f'''
"content": textwrap.dedent(f'''
**{release_version}** is now available! Check out the latest features and improvements here: <https://zoo.dev/modeling-app/download>
{modified_release_body}
''',
'''),
"username": "Modeling App Release Updates",
"avatar_url": "https://raw.githubusercontent.com/KittyCAD/modeling-app/main/public/discord-avatar.png"
}

View File

@ -3,6 +3,9 @@ import {
DoubleSide,
Group,
Intersection,
Line,
LineDashedMaterial,
BufferGeometry,
Mesh,
MeshBasicMaterial,
Object3D,
@ -13,6 +16,7 @@ import {
Points,
Quaternion,
Scene,
SphereGeometry,
Vector2,
Vector3,
} from 'three'
@ -31,6 +35,8 @@ import {
SKETCH_LAYER,
X_AXIS,
Y_AXIS,
CIRCLE_3_POINT_DRAFT_POINT,
CIRCLE_3_POINT_DRAFT_CIRCLE,
} from './sceneInfra'
import { isQuaternionVertical, quaternionFromUpNForward } from './helpers'
import {
@ -64,6 +70,7 @@ import { getNodeFromPath, getNodePathFromSourceRange } from 'lang/queryAst'
import { executeAst, ToolTip } from 'lang/langHelpers'
import {
createProfileStartHandle,
createArcGeometry,
SegmentUtils,
segmentUtils,
} from './segments'
@ -1219,6 +1226,228 @@ export class SceneEntities {
},
})
}
// lee: Well, it appears all our code in sceneEntities each act as their own
// kind of classes. In this case, I'll keep utility functions pertaining to
// circle3Point here. Feel free to extract as needed.
entryDraftCircle3Point = async (
startSketchOnASTNodePath: PathToNode,
forward: Vector3,
up: Vector3,
sketchOrigin: Vector3
) => {
// lee: Not a fan we need to re-iterate this dummy object all over the place
// just to get the scale but okie dokie.
const dummy = new Mesh()
dummy.position.set(0, 0, 0)
const scale = sceneInfra.getClientSceneScaleFactor(dummy)
const orientation = quaternionFromUpNForward(up, forward)
// Reminder: the intersection plane is the primary way to derive a XY
// position from a mouse click in ThreeJS.
// Here, we position and orient so it's facing the viewer.
this.intersectionPlane!.setRotationFromQuaternion(orientation)
this.intersectionPlane!.position.copy(sketchOrigin)
// Keep track of points in the scene with their ThreeJS ids.
const points: Map<number, Vector2> = new Map()
// Keep a reference so we can destroy and recreate as needed.
let groupCircle: Group | undefined
// Add our new group to the list of groups to render
const groupOfDrafts = new Group()
groupOfDrafts.name = 'circle-3-point-group'
groupOfDrafts.position.copy(sketchOrigin)
// lee: I'm keeping this here as a developer gotchya:
// Do not reorient your surfaces to the intersection plane. Your points are
// already in 3D space, not 2D. If you intersect say XZ, you want the points
// to continue to live at the 3D intersection point, not be rotated to end
// up elsewhere!
// groupOfDrafts.setRotationFromQuaternion(orientation)
this.scene.add(groupOfDrafts)
const DRAFT_POINT_RADIUS = 6
const createPoint = (center: Vector3): number => {
const geometry = new SphereGeometry(DRAFT_POINT_RADIUS)
const color = getThemeColorForThreeJs(sceneInfra._theme)
const material = new MeshBasicMaterial({ color })
const mesh = new Mesh(geometry, material)
mesh.userData = { type: CIRCLE_3_POINT_DRAFT_POINT }
mesh.layers.set(SKETCH_LAYER)
mesh.position.copy(center)
mesh.scale.set(scale, scale, scale)
mesh.renderOrder = 100
groupOfDrafts.add(mesh)
return mesh.id
}
const circle3Point = (
points: Vector2[]
): undefined | { center: Vector3; radius: number } => {
// A 3-point circle is undefined if it doesn't have 3 points :)
if (points.length !== 3) return undefined
// y = (i/j)(x-h) + b
// i and j variables for the slopes
const i = [points[1].x - points[0].x, points[2].x - points[1].x]
const j = [points[1].y - points[0].y, points[2].y - points[1].y]
// Our / threejs coordinate system affects this a lot. If you take this
// code into a different code base, you may have to adjust a/b to being
// -1/a/b, b/a, etc! In this case, a/-b did the trick.
const m = [i[0] / -j[0], i[1] / -j[1]]
const h = [
(points[0].x + points[1].x) / 2,
(points[1].x + points[2].x) / 2,
]
const b = [
(points[0].y + points[1].y) / 2,
(points[1].y + points[2].y) / 2,
]
// Algebraically derived
const x = (-m[0] * h[0] + b[0] - b[1] + m[1] * h[1]) / (m[1] - m[0])
const y = m[0] * (x - h[0]) + b[0]
const center = new Vector3(x, y, 0)
const radius = Math.sqrt((points[1].x - x) ** 2 + (points[1].y - y) ** 2)
return {
center,
radius,
}
}
// TO BE SHORT LIVED: unused function to draw the circle and lines.
// @ts-ignore
// eslint-disable-next-line
const createCircle3Point = (points: Vector2[]) => {
const circleParams = circle3Point(points)
// A circle cannot be created for these points.
if (!circleParams) return
const color = getThemeColorForThreeJs(sceneInfra._theme)
const geometryCircle = createArcGeometry({
center: [circleParams.center.x, circleParams.center.y],
radius: circleParams.radius,
startAngle: 0,
endAngle: Math.PI * 2,
ccw: true,
isDashed: true,
scale,
})
const materialCircle = new MeshBasicMaterial({ color })
if (groupCircle) groupOfDrafts.remove(groupCircle)
groupCircle = new Group()
groupCircle.renderOrder = 1
const meshCircle = new Mesh(geometryCircle, materialCircle)
meshCircle.userData = { type: CIRCLE_3_POINT_DRAFT_CIRCLE }
meshCircle.layers.set(SKETCH_LAYER)
meshCircle.position.set(circleParams.center.x, circleParams.center.y, 0)
meshCircle.scale.set(scale, scale, scale)
groupCircle.add(meshCircle)
const geometryPolyLine = new BufferGeometry().setFromPoints([
...points,
points[0],
])
const materialPolyLine = new LineDashedMaterial({
color,
scale,
dashSize: 6,
gapSize: 6,
})
const meshPolyLine = new Line(geometryPolyLine, materialPolyLine)
meshPolyLine.computeLineDistances()
groupCircle.add(meshPolyLine)
groupOfDrafts.add(groupCircle)
}
const cleanup = () => {
this.scene.remove(groupOfDrafts)
}
// The target of our dragging
let target: Object3D | undefined = undefined
sceneInfra.setCallbacks({
async onDrag(args) {
const draftPointsIntersected = args.intersects.filter(
(intersected) =>
intersected.object.userData.type === CIRCLE_3_POINT_DRAFT_POINT
)
const firstPoint = draftPointsIntersected[0]
if (firstPoint && !target) {
target = firstPoint.object
}
// The user was off their mark! Missed the object to select.
if (!target) return
target.position.copy(args.intersectionPoint.threeD)
points.set(target.id, args.intersectionPoint.twoD)
},
async onDragEnd(_args) {
target = undefined
},
async onClick(args) {
if (points.size >= 3) return
if (!args.intersectionPoint) return
const id = createPoint(args.intersectionPoint.threeD)
points.set(id, args.intersectionPoint.twoD)
if (points.size < 2) return
// We've now got 3 points, let's create our circle!
const astSnapshot = structuredClone(kclManager.ast)
let nodeQueryResult
nodeQueryResult = getNodeFromPath<VariableDeclaration>(
astSnapshot,
startSketchOnASTNodePath,
'VariableDeclaration'
)
if (err(nodeQueryResult)) return Promise.reject(nodeQueryResult)
const startSketchOnASTNode = nodeQueryResult
const circleParams = circle3Point(Array.from(points.values()))
if (!circleParams) return
const kclCircle3Point = parse(`circle({
center = [${circleParams.center.x}, ${circleParams.center.y}],
radius = ${circleParams.radius},
}, %)`)
if (err(kclCircle3Point) || kclCircle3Point.program === null) return
if (kclCircle3Point.program.body[0].type !== 'ExpressionStatement')
return
const clonedStartSketchOnASTNode = structuredClone(startSketchOnASTNode)
startSketchOnASTNode.node.declaration.init = createPipeExpression([
clonedStartSketchOnASTNode.node.declaration.init,
kclCircle3Point.program.body[0].expression,
])
await kclManager.executeAstMock(astSnapshot)
await codeManager.updateEditorWithAstAndWriteToFile(astSnapshot)
sceneInfra.modelingSend({ type: 'circle3PointsFinished', cleanup })
},
})
}
setupDraftCircle = async (
sketchPathToNode: PathToNode,
forward: [number, number, number],

View File

@ -62,6 +62,8 @@ export const ARROWHEAD = 'arrowhead'
export const SEGMENT_LENGTH_LABEL = 'segment-length-label'
export const SEGMENT_LENGTH_LABEL_TEXT = 'segment-length-label-text'
export const SEGMENT_LENGTH_LABEL_OFFSET_PX = 30
export const CIRCLE_3_POINT_DRAFT_POINT = 'circle-3-point-draft-point'
export const CIRCLE_3_POINT_DRAFT_CIRCLE = 'circle-3-point-draft-circle'
export interface OnMouseEnterLeaveArgs {
selected: Object3D<Object3DEventMap>

View File

@ -21,7 +21,8 @@ export function AstExplorer() {
const node = _node
return (
<div id="ast-explorer" className="relative">
<details id="ast-explorer" className="relative">
<summary>AST Explorer</summary>
<div className="">
filter out keys:<div className="w-2 inline-block"></div>
{['start', 'end', 'type'].map((key) => {
@ -58,7 +59,7 @@ export function AstExplorer() {
/>
</pre>
</div>
</div>
</details>
)
}

View File

@ -78,7 +78,7 @@ function CommandBarSelectionInput({
return () => {
toSync(() => {
const promises = [
new Promise(() => kclManager.defaultSelectionFilter()),
new Promise(() => kclManager.defaultSelectionFilter(selection)),
]
if (!kclManager._isAstEmpty(kclManager.ast)) {
promises.push(kclManager.hidePlanes())

View File

@ -392,6 +392,26 @@ const CustomIconMap = {
/>
</svg>
),
eyeOpen: (
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M10 14.5C5.58172 14.5 3 10.5 3 10.5C3 10.5 5.58172 5.5 10 5.5C14.4183 5.5 17 10.5 17 10.5C17 10.5 14.4183 14.5 10 14.5ZM4.24209 10.4865L4.19946 10.4348C4.23234 10.3833 4.26774 10.3288 4.30565 10.2717C4.59304 9.83862 5.01753 9.26342 5.56519 8.69184C6.67884 7.52958 8.18459 6.5 10 6.5C11.8154 6.5 13.3212 7.52958 14.4348 8.69184C14.9825 9.26342 15.407 9.83862 15.6944 10.2717C15.7323 10.3288 15.7677 10.3833 15.8005 10.4348L15.7579 10.4865C15.4766 10.8257 15.0582 11.2796 14.516 11.7324C13.4249 12.6433 11.8946 13.5 10 13.5C8.10539 13.5 6.57507 12.6433 5.48405 11.7324C4.9418 11.2796 4.52342 10.8257 4.24209 10.4865ZM12 10C12 11.1046 11.1046 12 10 12C8.89543 12 8 11.1046 8 10C8 8.89543 8.89543 8 10 8C11.1046 8 12 8.89543 12 10ZM13 10C13 11.6569 11.6569 13 10 13C8.34315 13 7 11.6569 7 10C7 8.34315 8.34315 7 10 7C11.6569 7 13 8.34315 13 10Z"
fill="currentColor"
/>
</svg>
),
eyeCrossedOut: (
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M4.35352 5.39647L14.253 15.296L14.9601 14.5889L5.06062 4.68936L4.35352 5.39647ZM10.9303 13.4303L11.7785 14.2785C11.2246 14.4184 10.631 14.5 10 14.5C5.58172 14.5 3 10.5 3 10.5C3 10.5 3.76379 9.02078 5.17147 7.67148L5.8787 8.37872C5.771 8.48155 5.66647 8.58616 5.56519 8.69186C5.01753 9.26343 4.59304 9.83863 4.30565 10.2717C4.26774 10.3288 4.23234 10.3833 4.19946 10.4348L4.24209 10.4866C4.52342 10.8257 4.9418 11.2797 5.48405 11.7324C6.57507 12.6433 8.10539 13.5 10 13.5C10.3206 13.5 10.6309 13.4755 10.9303 13.4303ZM10 5.50001C9.16896 5.50001 8.4029 5.6769 7.70677 5.96414L8.48545 6.74282C8.96231 6.58848 9.46785 6.50001 10 6.50001C11.8154 6.50001 13.3212 7.52959 14.4348 8.69186C14.9825 9.26343 15.407 9.83863 15.6944 10.2717C15.7323 10.3288 15.7677 10.3833 15.8005 10.4348L15.7579 10.4866C15.4766 10.8257 15.0582 11.2797 14.516 11.7324C14.3321 11.8859 14.1357 12.0379 13.9272 12.1845L14.6438 12.9011C16.1692 11.7871 17 10.5 17 10.5C17 10.5 14.4183 5.50001 10 5.50001ZM10 7.00001C9.62554 7.00001 9.2671 7.06862 8.93658 7.19395L9.75723 8.0146C9.8368 8.00497 9.91782 8.00001 10 8.00001C11.1046 8.00001 12 8.89544 12 10C12 10.0822 11.995 10.1632 11.9854 10.2428L12.8061 11.0634C12.9314 10.7329 13 10.3745 13 10C13 8.34316 11.6569 7.00001 10 7.00001ZM7 10C7 9.8421 7.0122 9.68704 7.03571 9.53572L8.08776 10.5878C8.28175 11.2197 8.78035 11.7183 9.41224 11.9123L10.4643 12.9643C10.313 12.9878 10.1579 13 10 13C8.34315 13 7 11.6569 7 10Z"
fill="currentColor"
/>
</svg>
),
fillet: (
<svg
viewBox="0 0 20 20"
@ -533,6 +553,16 @@ const CustomIconMap = {
/>
</svg>
),
hollow: (
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M5.67443 5.38863L7.24585 3.87359L6.55986 3.15721L4.51827 5.12555L4.5391 5.14731L4.51129 5.14139V6.14139V13.9073V14.9073L5.48944 15.1152L12.5048 16.6064L13.4829 16.8143V16.7896L13.5043 16.8119L15.5459 14.8436L14.8599 14.1272L13.4829 15.4548V8.04838V7.63961L13.5043 7.66193L15.5459 5.69359L14.8599 4.97721L12.851 6.91405L12.5048 6.84046L5.67443 5.38863ZM12.5048 7.84046L5.48944 6.34931V14.1152L12.5048 15.6064V7.84046ZM6.21381 8.04101V8.51384V8.54101L7.1075 8.73098V8.7038L7.19195 8.72175V7.74893L7.1075 7.73098L6.70288 7.64497L6.21381 7.54101V8.04101ZM6.21381 12.4051V12.3779L7.1075 12.5679V12.5951L7.19195 12.613V13.5859L7.1075 13.5679L6.70288 13.4819L6.21381 13.3779V12.8779V12.4051ZM10.6823 14.3277L10.5978 14.3098V13.337L10.6823 13.3549V13.3277L11.576 13.5177V13.5449V14.0177V14.5177L11.0869 14.4138L10.6823 14.3277ZM11.576 9.6536V9.68078L10.6823 9.49082V9.46364L10.5978 9.44569V8.47287L10.6823 8.49082L11.0869 8.57683L11.576 8.68078V9.18078V9.6536ZM8.0012 8.42094V7.92094L9.7886 8.30086V8.80086V9.30086L8.0012 8.92094V8.42094ZM11.0869 10.5225L11.576 10.6264V12.5721L11.0869 12.4681L10.5978 12.3642V10.4185L11.0869 10.5225ZM9.7886 13.6378V14.1378L8.0012 13.7579V13.2579V12.7579L9.7886 13.1378V13.6378ZM6.70288 11.5363L6.21381 11.4323V9.48666L6.70288 9.59061L7.19195 9.69457V11.6402L6.70288 11.5363Z"
fill="currentColor"
/>
</svg>
),
horizontal: (
<svg
viewBox="0 0 20 20"
@ -563,6 +593,22 @@ const CustomIconMap = {
/>
</svg>
),
import: (
<svg
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M9.64645 12.3535L10 12.7071L10.3536 12.3535L13.8536 8.85352L13.1464 8.14642L10.5 10.7929L10.5 3H9.5L9.5 10.7929L6.85355 8.14642L6.14645 8.85352L9.64645 12.3535ZM15 5H12.4999V4H15H16V5V15V16H15H5H4V15V5V4H5H7.49988V5H5V15H15V5Z"
fill="currentColor"
/>
</svg>
),
'intersection-offset': (
<svg
viewBox="0 0 20 20"
@ -741,6 +787,16 @@ const CustomIconMap = {
/>
</svg>
),
model: (
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M4.91773 10L10 6.89417L15.0823 10L10 13.1058L4.91773 10ZM10 5.72222L16.0411 9.41403L17 10L17 12.0541H16V10.6111L10.5 13.9722V16.0541H9.5V13.9722L4 10.6111V12.0541H3V10L3.95886 9.41403L10 5.72222Z"
fill="currentColor"
/>
</svg>
),
move: (
<svg
viewBox="0 0 20 20"
@ -801,6 +857,58 @@ const CustomIconMap = {
/>
</svg>
),
patternCircular2d: (
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M11 4C11 4.55228 10.5523 5 10 5C9.44772 5 9 4.55228 9 4C9 3.44772 9.44772 3 10 3C10.5523 3 11 3.44772 11 4ZM12 4C12 5.10457 11.1046 6 10 6C8.89543 6 8 5.10457 8 4C8 2.89543 8.89543 2 10 2C11.1046 2 12 2.89543 12 4ZM16 7C16 7.55228 15.5523 8 15 8C14.4477 8 14 7.55228 14 7C14 6.44772 14.4477 6 15 6C15.5523 6 16 6.44772 16 7ZM17 7C17 8.10457 16.1046 9 15 9C13.8954 9 13 8.10457 13 7C13 5.89543 13.8954 5 15 5C16.1046 5 17 5.89543 17 7ZM15 14C15.5523 14 16 13.5523 16 13C16 12.4477 15.5523 12 15 12C14.4477 12 14 12.4477 14 13C14 13.5523 14.4477 14 15 14ZM15 15C16.1046 15 17 14.1046 17 13C17 11.8954 16.1046 11 15 11C13.8954 11 13 11.8954 13 13C13 14.1046 13.8954 15 15 15ZM11 16C11 16.5523 10.5523 17 10 17C9.44772 17 9 16.5523 9 16C9 15.4477 9.44772 15 10 15C10.5523 15 11 15.4477 11 16ZM12 16C12 17.1046 11.1046 18 10 18C8.89543 18 8 17.1046 8 16C8 14.8954 8.89543 14 10 14C11.1046 14 12 14.8954 12 16ZM5 14C5.55228 14 6 13.5523 6 13C6 12.4477 5.55228 12 5 12C4.44772 12 4 12.4477 4 13C4 13.5523 4.44772 14 5 14ZM5 15C6.10457 15 7 14.1046 7 13C7 11.8954 6.10457 11 5 11C3.89543 11 3 11.8954 3 13C3 14.1046 3.89543 15 5 15ZM6 7C6 7.55228 5.55228 8 5 8C4.44772 8 4 7.55228 4 7C4 6.44772 4.44772 6 5 6C5.55228 6 6 6.44772 6 7ZM7 7C7 8.10457 6.10457 9 5 9C3.89543 9 3 8.10457 3 7C3 5.89543 3.89543 5 5 5C6.10457 5 7 5.89543 7 7Z"
fill="currentColor"
/>
</svg>
),
patternCircular3d: (
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M11 4C11 4.55228 10.5523 5 10 5C9.44772 5 9 4.55228 9 4C9 3.44772 9.44772 3 10 3C10.5523 3 11 3.44772 11 4ZM12 4C12 5.10457 11.1046 6 10 6C8.89543 6 8 5.10457 8 4C8 2.89543 8.89543 2 10 2C11.1046 2 12 2.89543 12 4ZM16 7C16 7.55228 15.5523 8 15 8C14.4477 8 14 7.55228 14 7C14 6.44772 14.4477 6 15 6C15.5523 6 16 6.44772 16 7ZM17 7C17 8.10457 16.1046 9 15 9C13.8954 9 13 8.10457 13 7C13 5.89543 13.8954 5 15 5C16.1046 5 17 5.89543 17 7ZM15 14C15.5523 14 16 13.5523 16 13C16 12.4477 15.5523 12 15 12C14.4477 12 14 12.4477 14 13C14 13.5523 14.4477 14 15 14ZM15 15C16.1046 15 17 14.1046 17 13C17 11.8954 16.1046 11 15 11C13.8954 11 13 11.8954 13 13C13 14.1046 13.8954 15 15 15ZM11 16C11 16.5523 10.5523 17 10 17C9.44772 17 9 16.5523 9 16C9 15.4477 9.44772 15 10 15C10.5523 15 11 15.4477 11 16ZM12 16C12 17.1046 11.1046 18 10 18C8.89543 18 8 17.1046 8 16C8 14.8954 8.89543 14 10 14C11.1046 14 12 14.8954 12 16ZM5 14C5.55228 14 6 13.5523 6 13C6 12.4477 5.55228 12 5 12C4.44772 12 4 12.4477 4 13C4 13.5523 4.44772 14 5 14ZM5 15C6.10457 15 7 14.1046 7 13C7 11.8954 6.10457 11 5 11C3.89543 11 3 11.8954 3 13C3 14.1046 3.89543 15 5 15ZM6 7C6 7.55228 5.55228 8 5 8C4.44772 8 4 7.55228 4 7C4 6.44772 4.44772 6 5 6C5.55228 6 6 6.44772 6 7ZM7 7C7 8.10457 6.10457 9 5 9C3.89543 9 3 8.10457 3 7C3 5.89543 3.89543 5 5 5C6.10457 5 7 5.89543 7 7Z"
fill="currentColor"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M11.642 5.1421C12.1606 4.78075 12.5 4.18001 12.5 3.5C12.5 2.39543 11.6046 1.5 10.5 1.5C9.82004 1.5 9.21933 1.83933 8.85797 2.35789C9.18176 2.13228 9.57543 1.99999 9.99999 1.99999C11.1046 1.99999 12 2.89542 12 3.99999C12 4.42459 11.8677 4.81829 11.642 5.1421ZM16.642 8.1421C17.1606 7.78075 17.5 7.18001 17.5 6.5C17.5 5.39543 16.6046 4.5 15.5 4.5C14.82 4.5 14.2193 4.83933 13.858 5.35789C14.1818 5.13228 14.5754 4.99999 15 4.99999C16.1046 4.99999 17 5.89542 17 6.99999C17 7.42459 16.8677 7.81829 16.642 8.1421ZM13.858 11.3579C14.1818 11.1323 14.5754 11 15 11C16.1046 11 17 11.8954 17 13C17 13.4246 16.8677 13.8183 16.642 14.1421C17.1606 13.7808 17.5 13.18 17.5 12.5C17.5 11.3954 16.6046 10.5 15.5 10.5C14.82 10.5 14.2193 10.8393 13.858 11.3579ZM11.642 17.1421C12.1606 16.7808 12.5 16.18 12.5 15.5C12.5 14.3954 11.6046 13.5 10.5 13.5C9.82004 13.5 9.21933 13.8393 8.85797 14.3579C9.18176 14.1323 9.57543 14 9.99999 14C11.1046 14 12 14.8954 12 16C12 16.4246 11.8677 16.8183 11.642 17.1421ZM6.64202 14.1421C7.16064 13.7808 7.50001 13.18 7.50001 12.5C7.50001 11.3954 6.60458 10.5 5.50001 10.5C4.82004 10.5 4.21933 10.8393 3.85797 11.3579C4.18176 11.1323 4.57543 11 4.99999 11C6.10456 11 6.99999 11.8954 6.99999 13C6.99999 13.4246 6.86767 13.8183 6.64202 14.1421ZM6.64202 8.1421C7.16064 7.78075 7.50001 7.18001 7.50001 6.5C7.50001 5.39543 6.60458 4.5 5.50001 4.5C4.82004 4.5 4.21933 4.83933 3.85797 5.35789C4.18176 5.13228 4.57543 4.99999 4.99999 4.99999C6.10456 4.99999 6.99999 5.89542 6.99999 6.99999C6.99999 7.42459 6.86767 7.81829 6.64202 8.1421Z"
fill="currentColor"
/>
</svg>
),
patternLinear2d: (
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M4 4H6V6H4V4ZM3 3H4H6H7V4V6V7H6H4H3V6V4V3ZM4 9H6V11H4V9ZM3 8H4H6H7V9V11V12H6H4H3V11V9V8ZM6 14H4V16H6V14ZM4 13H3V14V16V17H4H6H7V16V14V13H6H4ZM9 4H11V6H9V4ZM8 3H9H11H12V4V6V7H11H9H8V6V4V3ZM11 9H9V11H11V9ZM9 8H8V9V11V12H9H11H12V11V9V8H11H9ZM14 4H16V6H14V4ZM13 3H14H16H17V4V6V7H16H14H13V6V4V3Z"
fill="currentColor"
/>
</svg>
),
patternLinear3d: (
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M4 4H6V6H4V4ZM3 3H4H6H7V4V6V7H6H4H3V6V4V3ZM4 9H6V11H4V9ZM3 8H4H6H7V9V11V12H6H4H3V11V9V8ZM6 14H4V16H6V14ZM4 13H3V14V16V17H4H6H7V16V14V13H6H4ZM9 4H11V6H9V4ZM8 3H9H11H12V4V6V7H11H9H8V6V4V3ZM11 9H9V11H11V9ZM9 8H8V9V11V12H9H11H12V11V9V8H11H9ZM14 4H16V6H14V4ZM13 3H14H16H17V4V6V7H16H14H13V6V4V3Z"
fill="currentColor"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M4.5 2.5H3.5V3H7V6.5H7.5V5.5V3.5V2.5H6.5H4.5ZM4.5 7.5H3.5V8H7V11.5H7.5V10.5V8.5V7.5H6.5H4.5ZM3.5 12.5H4.5H6.5H7.5V13.5V15.5V16.5H7V13H3.5V12.5ZM9.5 2.5H8.5V3H12V6.5H12.5V5.5V3.5V2.5H11.5H9.5ZM8.5 7.5H9.5H11.5H12.5V8.5V10.5V11.5H12V8H8.5V7.5ZM14.5 2.5H13.5V3H17V6.5H17.5V5.5V3.5V2.5H16.5H14.5Z"
fill="currentColor"
/>
</svg>
),
person: (
<svg
viewBox="0 0 20 20"

View File

@ -13,7 +13,11 @@ import { engineCommandManager } from '../lib/singletons'
import { Spinner } from './Spinner'
const Loading = ({ children }: React.PropsWithChildren) => {
interface LoadingProps extends React.PropsWithChildren {
className?: string
}
const Loading = ({ children, className }: LoadingProps) => {
const [error, setError] = useState<ConnectionError>(ConnectionError.Unset)
useEffect(() => {
@ -64,7 +68,7 @@ const Loading = ({ children }: React.PropsWithChildren) => {
return (
<div
className="body-bg flex flex-col items-center justify-center h-screen"
className={`body-bg flex flex-col items-center justify-center h-screen ${className}`}
data-testid="loading"
>
<Spinner />

View File

@ -46,16 +46,9 @@ import {
applyConstraintLength,
} from './Toolbar/setAngleLength'
import {
canSweepSelection,
handleSelectionBatch,
isSelectionLastLine,
isRangeBetweenCharacters,
isSketchPipe,
Selections,
updateSelections,
canLoftSelection,
canRevolveSelection,
canShellSelection,
} from 'lib/selections'
import { applyConstraintIntersect } from './Toolbar/Intersect'
import { applyConstraintAbsDistance } from './Toolbar/SetAbsDistance'
@ -75,21 +68,17 @@ import {
} from 'lang/modifyAst'
import { PathToNode, Program, parse, recast, resultIsOk } from 'lang/wasm'
import {
doesSceneHaveExtrudedSketch,
doesSceneHaveSweepableSketch,
artifactIsPlaneWithPaths,
getNodePathFromSourceRange,
isSingleCursorInPipe,
} from 'lang/queryAst'
import { exportFromEngine } from 'lib/exportFromEngine'
import { Models } from '@kittycad/lib/dist/types/src'
import toast from 'react-hot-toast'
import { EditorSelection, Transaction } from '@codemirror/state'
import { useLoaderData, useNavigate, useSearchParams } from 'react-router-dom'
import { letEngineAnimateAndSyncCamAfter } from 'clientSideScene/CameraControls'
import { err, reportRejection, trap } from 'lib/trap'
import { useCommandsContext } from 'hooks/useCommandsContext'
import { modelingMachineEvent } from 'editor/manager'
import { hasValidEdgeTreatmentSelection } from 'lang/modifyAst/addEdgeTreatment'
import {
ExportIntent,
EngineConnectionStateType,
@ -100,6 +89,8 @@ import { useFileContext } from 'hooks/useFileContext'
import { uuidv4 } from 'lib/utils'
import { IndexLoaderData } from 'lib/types'
import { Node } from 'wasm-lib/kcl/bindings/Node'
import { promptToEditFlow } from 'lib/promptToEdit'
import { kclEditorActor } from 'machines/kclEditorMachine'
type MachineContext<T extends AnyStateMachine> = {
state: StateFrom<T>
@ -309,22 +300,6 @@ export const ModelingMachineProvider = ({
null
if (!setSelections) return {}
const dispatchSelection = (selection?: EditorSelection) => {
if (!selection) return // TODO less of hack for the below please
if (!editorManager.editorView) return
setTimeout(() => {
if (!editorManager.editorView) return
editorManager.editorView.dispatch({
selection,
annotations: [
modelingMachineEvent,
Transaction.addToHistory.of(false),
],
})
})
}
let selections: Selections = {
graphSelections: [],
otherSelections: [],
@ -364,7 +339,15 @@ export const ModelingMachineProvider = ({
} = handleSelectionBatch({
selections,
})
codeMirrorSelection && dispatchSelection(codeMirrorSelection)
if (codeMirrorSelection) {
kclEditorActor.send({
type: 'setLastSelectionEvent',
data: {
codeMirrorSelection,
scrollIntoView: setSelections.scrollIntoView ?? false,
},
})
}
engineEvents &&
engineEvents.forEach((event) => {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
@ -556,78 +539,6 @@ export const ModelingMachineProvider = ({
},
},
guards: {
'has valid sweep selection': ({ context: { selectionRanges } }) => {
// A user can begin extruding if they either have 1+ faces selected or nothing selected
// TODO: I believe this guard only allows for extruding a single face at a time
const hasNoSelection =
selectionRanges.graphSelections.length === 0 ||
isRangeBetweenCharacters(selectionRanges) ||
isSelectionLastLine(selectionRanges, codeManager.code)
if (hasNoSelection) {
// they have no selection, we should enable the button
// so they can select the face through the cmdbar
// BUT only if there's extrudable geometry
return doesSceneHaveSweepableSketch(kclManager.ast)
}
if (!isSketchPipe(selectionRanges)) return false
const canSweep = canSweepSelection(selectionRanges)
if (err(canSweep)) return false
return canSweep
},
'has valid revolve selection': ({ context: { selectionRanges } }) => {
// A user can begin extruding if they either have 1+ faces selected or nothing selected
// TODO: I believe this guard only allows for extruding a single face at a time
const hasNoSelection =
selectionRanges.graphSelections.length === 0 ||
isRangeBetweenCharacters(selectionRanges) ||
isSelectionLastLine(selectionRanges, codeManager.code)
if (hasNoSelection) {
// they have no selection, we should enable the button
// so they can select the face through the cmdbar
// BUT only if there's extrudable geometry
return doesSceneHaveSweepableSketch(kclManager.ast)
}
if (!isSketchPipe(selectionRanges)) return false
const canSweep = canRevolveSelection(selectionRanges)
if (err(canSweep)) return false
return canSweep
},
'has valid loft selection': ({ context: { selectionRanges } }) => {
const hasNoSelection =
selectionRanges.graphSelections.length === 0 ||
isRangeBetweenCharacters(selectionRanges) ||
isSelectionLastLine(selectionRanges, codeManager.code)
if (hasNoSelection) {
const count = 2
return doesSceneHaveSweepableSketch(kclManager.ast, count)
}
const canLoft = canLoftSelection(selectionRanges)
if (err(canLoft)) return false
return canLoft
},
'has valid shell selection': ({
context: { selectionRanges },
event,
}) => {
const hasNoSelection =
selectionRanges.graphSelections.length === 0 ||
isRangeBetweenCharacters(selectionRanges) ||
isSelectionLastLine(selectionRanges, codeManager.code)
if (hasNoSelection) {
return doesSceneHaveExtrudedSketch(kclManager.ast)
}
const canShell = canShellSelection(selectionRanges)
if (err(canShell)) return false
return canShell
},
'has valid selection for deletion': ({
context: { selectionRanges },
}) => {
@ -635,18 +546,12 @@ export const ModelingMachineProvider = ({
if (selectionRanges.graphSelections.length <= 0) return false
return true
},
'has valid edge treatment selection': ({
context: { selectionRanges },
}) => {
return hasValidEdgeTreatmentSelection({
selectionRanges,
ast: kclManager.ast,
code: codeManager.code,
})
},
'Selection is on face': ({ context: { selectionRanges }, event }) => {
if (event.type !== 'Enter sketch') return false
if (event.data?.forceNewSketch) return false
if (artifactIsPlaneWithPaths(selectionRanges)) {
return true
}
if (!isSingleCursorInPipe(selectionRanges, kclManager.ast))
return false
return !!isCursorInSketchCommandRange(
@ -1198,6 +1103,15 @@ export const ModelingMachineProvider = ({
}
}
),
'submit-prompt-edit': fromPromise(async ({ input }) => {
return await promptToEditFlow({
code: codeManager.code,
prompt: input.prompt,
selections: input.selection,
token,
artifactGraph: engineCommandManager.artifactGraph,
})
}),
},
}),
{

View File

@ -0,0 +1,381 @@
import { Diagnostic } from '@codemirror/lint'
import { useMachine, useSelector } from '@xstate/react'
import { ContextMenu, ContextMenuItem } from 'components/ContextMenu'
import { CustomIcon, CustomIconName } from 'components/CustomIcon'
import Loading from 'components/Loading'
import { useModelingContext } from 'hooks/useModelingContext'
import { useKclContext } from 'lang/KclProvider'
import { codeRefFromRange, getArtifactFromRange } from 'lang/std/artifactGraph'
import { sourceRangeFromRust } from 'lang/wasm'
import {
filterOperations,
getOperationIcon,
getOperationLabel,
} from 'lib/operations'
import { editorManager, engineCommandManager, kclManager } from 'lib/singletons'
import { ComponentProps, useEffect, useMemo, useRef, useState } from 'react'
import { Operation } from 'wasm-lib/kcl/bindings/Operation'
import { Actor, Prop } from 'xstate'
import { featureTreeMachine } from 'machines/featureTreeMachine'
import {
editorIsMountedSelector,
kclEditorActor,
selectionEventSelector,
} from 'machines/kclEditorMachine'
export const FeatureTreePane = () => {
const isEditorMounted = useSelector(kclEditorActor, editorIsMountedSelector)
const lastSelectionEvent = useSelector(kclEditorActor, selectionEventSelector)
const { send: modelingSend, state: modelingState } = useModelingContext()
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [_featureTreeState, featureTreeSend] = useMachine(
featureTreeMachine.provide({
guards: {
codePaneIsOpen: () =>
modelingState.context.store.openPanes.includes('code') &&
editorManager.editorView !== null,
},
actions: {
openCodePane: () => {
modelingSend({
type: 'Set context',
data: {
openPanes: [...modelingState.context.store.openPanes, 'code'],
},
})
},
sendEditFlowStart: () => {
modelingSend({ type: 'Enter sketch' })
},
scrollToError: () => {
editorManager.scrollToFirstErrorDiagnosticIfExists()
},
sendSelectionEvent: ({ context }) => {
if (!context.targetSourceRange) {
return
}
const artifact = context.targetSourceRange
? getArtifactFromRange(
context.targetSourceRange,
engineCommandManager.artifactGraph
)
: null
if (!artifact || !('codeRef' in artifact)) {
modelingSend({
type: 'Set selection',
data: {
selectionType: 'singleCodeCursor',
selection: {
codeRef: codeRefFromRange(
context.targetSourceRange,
kclManager.ast
),
},
scrollIntoView: true,
},
})
} else {
modelingSend({
type: 'Set selection',
data: {
selectionType: 'singleCodeCursor',
selection: {
artifact: artifact,
codeRef: codeRefFromRange(
context.targetSourceRange,
kclManager.ast
),
},
scrollIntoView: true,
},
})
}
},
},
})
)
// If there are parse errors we show the last successful operations
// and overlay a message on top of the pane
const parseErrors = kclManager.errors.filter((e) => e.kind !== 'engine')
// If there are engine errors we show the successful operations
// Errors return an operation list, so use the longest one if there are multiple
const longestErrorOperationList = kclManager.errors.reduce((acc, error) => {
return error.operations && error.operations.length > acc.length
? error.operations
: acc
}, [] as Operation[])
const unfilteredOperationList = !parseErrors.length
? !kclManager.errors.length
? kclManager.execState.operations
: longestErrorOperationList
: kclManager.lastSuccessfulOperations
// We filter out operations that are not useful to show in the feature tree
const operationList = filterOperations(unfilteredOperationList)
// Watch for changes in the open panes and send an event to the feature tree machine
useEffect(() => {
const codeOpen = modelingState.context.store.openPanes.includes('code')
if (codeOpen && isEditorMounted) {
featureTreeSend({ type: 'codePaneOpened' })
}
}, [modelingState.context.store.openPanes, isEditorMounted])
// Watch for changes in the selection and send an event to the feature tree machine
useEffect(() => {
featureTreeSend({ type: 'selected' })
}, [lastSelectionEvent])
function goToError() {
featureTreeSend({ type: 'goToError' })
}
return (
<div className="relative">
<section
data-testid="debug-panel"
className="absolute inset-0 p-1 box-border overflow-auto"
>
{kclManager.isExecuting ? (
<Loading className="h-full">Building feature tree...</Loading>
) : (
<>
{parseErrors.length > 0 && (
<div
className={`absolute inset-0 rounded-lg p-2 ${
operationList.length &&
`bg-destroy-10/40 dark:bg-destroy-80/40`
}`}
>
<div className="text-sm bg-destroy-80 text-chalkboard-10 py-1 px-2 rounded flex gap-2 items-center">
<p className="flex-1">
Errors found in KCL code.
<br />
Please fix them before continuing.
</p>
<button
onClick={goToError}
className="bg-chalkboard-10 text-destroy-80 p-1 rounded-sm flex-none hover:bg-chalkboard-10 hover:border-destroy-70 hover:text-destroy-80 border-transparent"
>
View error
</button>
</div>
</div>
)}
{operationList.map((operation) => {
const key = `${operation.type}-${
'name' in operation ? operation.name : 'anonymous'
}-${
'sourceRange' in operation ? operation.sourceRange[0] : 'start'
}`
return (
<OperationItem
key={key}
item={operation}
send={featureTreeSend}
/>
)
})}
</>
)}
</section>
</div>
)
}
export const visibilityMap = new Map<string, boolean>()
interface VisibilityToggleProps {
entityId: string
initialVisibility: boolean
onVisibilityChange?: () => void
}
/**
* A button that toggles the visibility of an entity
* tied to an artifact in the feature tree.
* TODO: this is unimplemented and will be used for
* default planes after we fix them and add them to the artifact graph / feature tree
*/
const VisibilityToggle = (props: VisibilityToggleProps) => {
const [visible, setVisible] = useState(props.initialVisibility)
function handleToggleVisible() {
setVisible(!visible)
visibilityMap.set(props.entityId, !visible)
props.onVisibilityChange?.()
}
return (
<button
onClick={handleToggleVisible}
className="border-transparent p-0 m-0"
>
<CustomIcon
name={visible ? 'eyeOpen' : 'eyeCrossedOut'}
className={`w-5 h-5 ${
visible
? 'hidden group-hover/item:block group-focus-within/item:block'
: 'text-chalkboard-50'
}`}
/>
</button>
)
}
/**
* More generic version of OperationListItem,
* to be used for default planes after we fix them and
* add them to the artifact graph / feature tree
*/
const OperationItemWrapper = ({
icon,
name,
visibilityToggle,
menuItems,
errors,
className,
...props
}: React.HTMLAttributes<HTMLButtonElement> & {
icon: CustomIconName
name: string
visibilityToggle?: VisibilityToggleProps
menuItems?: ComponentProps<typeof ContextMenu>['items']
errors?: Diagnostic[]
}) => {
const menuRef = useRef<HTMLDivElement>(null)
return (
<div
ref={menuRef}
className="flex select-none items-center group/item my-0 py-0.5 px-1 focus-within:bg-primary/10 hover:bg-primary/5"
>
<button
{...props}
className={`reset flex-1 flex items-center gap-2 border-transparent dark:border-transparent text-left text-base ${className}`}
>
<CustomIcon name={icon} className="w-5 h-5 block" />
{name}
</button>
{errors && errors.length > 0 && (
<em className="text-destroy-80 text-xs">has error</em>
)}
{visibilityToggle && <VisibilityToggle {...visibilityToggle} />}
{menuItems && (
<ContextMenu menuTargetElement={menuRef} items={menuItems} />
)}
</div>
)
}
/**
* A button with an icon, name, and context menu
* for an operation in the feature tree.
*/
const OperationItem = (props: {
item: Operation
send: Prop<Actor<typeof featureTreeMachine>, 'send'>
}) => {
const kclContext = useKclContext()
const name =
'name' in props.item && props.item.name !== null
? getOperationLabel(props.item)
: 'anonymous'
const errors = useMemo(() => {
return kclContext.diagnostics.filter(
(diag) =>
diag.severity === 'error' &&
'sourceRange' in props.item &&
diag.from >= props.item.sourceRange[0] &&
diag.to <= props.item.sourceRange[1]
)
}, [kclContext.diagnostics.length])
function selectOperation() {
if (props.item.type === 'UserDefinedFunctionReturn') {
return
}
props.send({
type: 'selectOperation',
data: {
targetSourceRange: sourceRangeFromRust(props.item.sourceRange),
},
})
}
/**
* For now we can only enter the "edit" flow for the startSketchOn operation.
* TODO: https://github.com/KittyCAD/modeling-app/issues/4442
*/
function enterEditFlow() {
if (
props.item.type === 'StdLibCall' &&
props.item.name === 'startSketchOn'
) {
props.send({
type: 'enterEditFlow',
data: {
targetSourceRange: sourceRangeFromRust(props.item.sourceRange),
},
})
}
}
const menuItems = useMemo(
() => [
<ContextMenuItem
onClick={() => {
if (props.item.type === 'UserDefinedFunctionReturn') {
return
}
props.send({
type: 'goToKclSource',
data: {
targetSourceRange: sourceRangeFromRust(props.item.sourceRange),
},
})
}}
>
View KCL source code
</ContextMenuItem>,
...(props.item.type === 'UserDefinedFunctionCall'
? [
<ContextMenuItem
onClick={() => {
if (props.item.type !== 'UserDefinedFunctionCall') {
return
}
const functionRange = props.item.functionSourceRange
// For some reason, the cursor goes to the end of the source
// range we select. So set the end equal to the beginning.
functionRange[1] = functionRange[0]
props.send({
type: 'goToKclSource',
data: {
targetSourceRange: sourceRangeFromRust(functionRange),
},
})
}}
>
View function definition
</ContextMenuItem>,
]
: []),
],
[props.item, props.send]
)
return (
<OperationItemWrapper
icon={getOperationIcon(props.item)}
name={name}
menuItems={menuItems}
onClick={selectOperation}
onDoubleClick={enterEditFlow}
errors={errors}
/>
)
}

View File

@ -1,7 +1,7 @@
import { TEST } from 'env'
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
import { Themes, getSystemTheme } from 'lib/theme'
import { useMemo, useRef } from 'react'
import { useEffect, useMemo, useRef } from 'react'
import { highlightSelectionMatches, searchKeymap } from '@codemirror/search'
import { lineHighlightField } from 'editor/highlightextension'
import { onMouseDragMakeANewNumber, onMouseDragRegex } from 'lib/utils'
@ -36,7 +36,7 @@ import interact from '@replit/codemirror-interact'
import { kclManager, editorManager, codeManager } from 'lib/singletons'
import { useHotkeys } from 'react-hotkeys-hook'
import { useLspContext } from 'components/LspProvider'
import { Prec, EditorState, Extension } from '@codemirror/state'
import { Prec, EditorState, Extension, Transaction } from '@codemirror/state'
import {
closeBrackets,
closeBracketsKeymap,
@ -44,6 +44,13 @@ import {
} from '@codemirror/autocomplete'
import CodeEditor from './CodeEditor'
import { codeManagerHistoryCompartment } from 'lang/codeManager'
import {
editorIsMountedSelector,
kclEditorActor,
selectionEventSelector,
} from 'machines/kclEditorMachine'
import { useSelector } from '@xstate/react'
import { modelingMachineEvent } from 'editor/manager'
export const editorShortcutMeta = {
formatCode: {
@ -59,6 +66,8 @@ export const KclEditorPane = () => {
const {
settings: { context },
} = useSettingsAuthContext()
const lastSelectionEvent = useSelector(kclEditorActor, selectionEventSelector)
const editorIsMounted = useSelector(kclEditorActor, editorIsMountedSelector)
const theme =
context.app.theme.current === Themes.System
? getSystemTheme()
@ -76,6 +85,25 @@ export const KclEditorPane = () => {
editorManager.redo()
})
// When this component unmounts, we need to tell the machine that the editor
useEffect(() => {
return () => {
kclEditorActor.send({ type: 'setKclEditorMounted', data: false })
kclEditorActor.send({ type: 'setLastSelectionEvent', data: undefined })
}
}, [])
useEffect(() => {
if (!editorIsMounted || !lastSelectionEvent || !editorManager.editorView) {
return
}
editorManager.editorView.dispatch({
selection: lastSelectionEvent.codeMirrorSelection,
annotations: [modelingMachineEvent, Transaction.addToHistory.of(false)],
scrollIntoView: lastSelectionEvent.scrollIntoView,
})
}, [editorIsMounted, lastSelectionEvent])
const textWrapping = context.textEditor.textWrapping
const cursorBlinking = context.textEditor.blinkingCursor
// DO NOT ADD THE CODEMIRROR HOTKEYS HERE TO THE DEPENDENCY ARRAY
@ -174,6 +202,7 @@ export const KclEditorPane = () => {
if (_editorView === null) return
editorManager.setEditorView(_editorView)
kclEditorActor.send({ type: 'setKclEditorMounted', data: true })
// On first load of this component, ensure we show the current errors
// in the editor.

View File

@ -17,12 +17,14 @@ import { useKclContext } from 'lang/KclProvider'
import { editorManager } from 'lib/singletons'
import { ContextFrom } from 'xstate'
import { settingsMachine } from 'machines/settingsMachine'
import { FeatureTreePane } from './FeatureTreePane'
export type SidebarType =
| 'code'
| 'debug'
| 'export'
| 'files'
| 'feature-tree'
| 'logs'
| 'lspMessages'
| 'variables'
@ -69,6 +71,23 @@ export type SidebarAction = {
// changes to be a spinning loader on loading.
export const sidebarPanes: SidebarPane[] = [
{
id: 'feature-tree',
icon: 'model',
keybinding: 'Shift + T',
sidebarName: 'Feature Tree',
Content: (props) => (
<>
<ModelingPaneHeader
id={props.id}
icon="model"
title="Feature Tree"
onClose={props.onClose}
/>
<FeatureTreePane />
</>
),
},
{
id: 'code',
icon: 'code',

View File

@ -20,6 +20,7 @@ import { IconDefinition } from '@fortawesome/free-solid-svg-icons'
import { useKclContext } from 'lang/KclProvider'
import { MachineManagerContext } from 'components/MachineManagerProvider'
import { onboardingPaths } from 'routes/Onboarding/paths'
import { SIDEBAR_BUTTON_SUFFIX } from 'lib/constants'
interface ModelingSidebarProps {
paneOpacity: '' | 'opacity-20' | 'opacity-40'
@ -302,7 +303,7 @@ function ModelingPaneButton({
className="group pointer-events-auto flex items-center justify-center border-transparent dark:border-transparent disabled:!border-transparent p-0 m-0 rounded-sm !outline-0 focus-visible:border-primary"
onClick={onClick}
name={paneConfig.sidebarName}
data-testid={paneConfig.id + '-pane-button'}
data-testid={paneConfig.id + SIDEBAR_BUTTON_SUFFIX}
disabled={disabledText !== undefined}
aria-disabled={disabledText !== undefined}
{...props}

View File

@ -4,7 +4,10 @@ import { useFileContext } from 'hooks/useFileContext'
import { isDesktop } from 'lib/isDesktop'
import { PATHS } from 'lib/paths'
import toast from 'react-hot-toast'
import { TextToCad_type } from '@kittycad/lib/dist/types/src/models'
import {
TextToCad_type,
TextToCadIteration_type,
} from '@kittycad/lib/dist/types/src/models'
import { useCallback, useEffect, useRef, useState } from 'react'
import {
Box3,
@ -29,6 +32,7 @@ import { commandBarMachine } from 'machines/commandBarMachine'
import { EventFrom } from 'xstate'
import { fileMachine } from 'machines/fileMachine'
import { reportRejection } from 'lib/trap'
import { codeManager, kclManager } from 'lib/singletons'
const CANVAS_SIZE = 128
const PROMPT_TRUNCATE_LENGTH = 128
@ -411,3 +415,67 @@ function traverseSceneToStyleObjects({
}
})
}
export function ToastPromptToEditCadSuccess({
toastId,
token,
data,
oldCode,
}: {
toastId: string
oldCode: string
data: TextToCadIteration_type
token?: string
}) {
const modelId = data.id
return (
<div className="flex gap-4 min-w-80">
<div className="flex flex-col justify-between gap-6">
<section>
<h2>Prompt to edit successful</h2>
<p className="text-sm text-chalkboard-70 dark:text-chalkboard-30">
Prompt: "
{data?.prompt && data?.prompt?.length > PROMPT_TRUNCATE_LENGTH
? data.prompt.slice(0, PROMPT_TRUNCATE_LENGTH) + '...'
: data.prompt}
"
</p>
<p>Do you want to keep the change?</p>
</section>
<div className="flex justify-between gap-8">
<ActionButton
Element="button"
iconStart={{
icon: 'close',
}}
data-negative-button={'reject'}
name={'Reject'}
onClick={() => {
sendTelemetry(modelId, 'rejected', token).catch(reportRejection)
codeManager.updateCodeEditor(oldCode)
kclManager.executeCode().catch(reportRejection)
toast.dismiss(toastId)
}}
>
{'Reject'}
</ActionButton>
<ActionButton
Element="button"
iconStart={{
icon: 'checkmark',
}}
name="Accept"
onClick={() => {
sendTelemetry(modelId, 'accepted', token).catch(reportRejection)
toast.dismiss(toastId)
}}
>
Accept
</ActionButton>
</div>
</div>
</div>
)
}

View File

@ -14,6 +14,7 @@ import {
} from '@codemirror/lint'
import { StateFrom } from 'xstate'
import { markOnce } from 'lib/performance'
import { kclEditorActor } from 'machines/kclEditorMachine'
declare global {
interface Window {
@ -70,6 +71,7 @@ export default class EditorManager {
setEditorView(editorView: EditorView) {
this._editorView = editorView
kclEditorActor.send({ type: 'setKclEditorMounted', data: true })
this.overrideTreeHighlighterUpdateForPerformanceTracking()
}
@ -207,6 +209,32 @@ export default class EditorManager {
})
}
/**
* Scroll to the first selection in the editor.
*/
scrollToSelection() {
if (!this._editorView || !this._selectionRanges.graphSelections[0]) return
const firstSelection = this._selectionRanges.graphSelections[0]
this._editorView.focus()
this._editorView.dispatch({
effects: [
EditorView.scrollIntoView(
EditorSelection.range(
firstSelection.codeRef.range[0],
firstSelection.codeRef.range[1]
),
{ y: 'center' }
),
],
annotations: [
updateOutsideEditorEvent,
Transaction.addToHistory.of(false),
],
})
}
scrollToFirstErrorDiagnosticIfExists() {
if (!this._editorView) return

View File

@ -311,6 +311,14 @@ code {
@apply bg-chalkboard-20 text-chalkboard-80;
@apply dark:bg-chalkboard-80 dark:text-chalkboard-30;
}
button.reset {
@apply bg-transparent border-transparent m-0 p-0 rounded-none text-base;
}
button.reset:hover {
@apply bg-transparent border-transparent;
}
}
#code-mirror-override .cm-scroller,

View File

@ -3,6 +3,7 @@ import { type IndexLoaderData } from 'lib/types'
import { useLoaderData } from 'react-router-dom'
import { codeManager, kclManager } from 'lib/singletons'
import { Diagnostic } from '@codemirror/lint'
import { KCLError } from './errors'
const KclContext = createContext({
code: codeManager?.code || '',
@ -11,6 +12,7 @@ const KclContext = createContext({
isExecuting: kclManager?.isExecuting,
diagnostics: kclManager?.diagnostics,
logs: kclManager?.logs,
errors: kclManager?.errors,
wasmInitFailed: kclManager?.wasmInitFailed,
})
@ -32,7 +34,8 @@ export function KclContextProvider({
const [programMemory, setProgramMemory] = useState(kclManager.programMemory)
const [ast, setAst] = useState(kclManager.ast)
const [isExecuting, setIsExecuting] = useState(false)
const [diagnostics, setErrors] = useState<Diagnostic[]>([])
const [diagnostics, setDiagnostics] = useState<Diagnostic[]>([])
const [errors, setErrors] = useState<KCLError[]>([])
const [logs, setLogs] = useState<string[]>([])
const [wasmInitFailed, setWasmInitFailed] = useState(false)
@ -44,7 +47,8 @@ export function KclContextProvider({
setProgramMemory,
setAst,
setLogs,
setKclErrors: setErrors,
setErrors,
setDiagnostics,
setIsExecuting,
setWasmInitFailed,
})
@ -59,6 +63,7 @@ export function KclContextProvider({
isExecuting,
diagnostics,
logs,
errors,
wasmInitFailed,
}}
>

View File

@ -1,5 +1,5 @@
import { executeAst, lintAst } from 'lang/langHelpers'
import { Selections } from 'lib/selections'
import { handleSelectionBatch, Selections } from 'lib/selections'
import {
KCLError,
complilationErrorsToDiagnostics,
@ -28,7 +28,11 @@ import { codeManager, editorManager, sceneInfra } from 'lib/singletons'
import { Diagnostic } from '@codemirror/lint'
import { markOnce } from 'lib/performance'
import { Node } from 'wasm-lib/kcl/bindings/Node'
import { EntityType_type } from '@kittycad/lib/dist/types/src/models'
import {
EntityType_type,
ModelingCmdReq_type,
} from '@kittycad/lib/dist/types/src/models'
import { Operation } from 'wasm-lib/kcl/bindings/Operation'
interface ExecuteArgs {
ast?: Node<Program>
@ -55,7 +59,9 @@ export class KclManager {
private _execState: ExecState = emptyExecState()
private _programMemory: ProgramMemory = ProgramMemory.empty()
lastSuccessfulProgramMemory: ProgramMemory = ProgramMemory.empty()
lastSuccessfulOperations: Operation[] = []
private _logs: string[] = []
private _errors: KCLError[] = []
private _diagnostics: Diagnostic[] = []
private _isExecuting = false
private _executeIsStale: ExecuteArgs | null = null
@ -69,7 +75,8 @@ export class KclManager {
private _astCallBack: (arg: Node<Program>) => void = () => {}
private _programMemoryCallBack: (arg: ProgramMemory) => void = () => {}
private _logsCallBack: (arg: string[]) => void = () => {}
private _kclErrorsCallBack: (errors: Diagnostic[]) => void = () => {}
private _kclErrorsCallBack: (errors: KCLError[]) => void = () => {}
private _diagnosticsCallback: (errors: Diagnostic[]) => void = () => {}
private _wasmInitFailedCallback: (arg: boolean) => void = () => {}
private _executeCallback: () => void = () => {}
@ -103,6 +110,13 @@ export class KclManager {
return this._execState
}
get errors() {
return this._errors
}
set errors(errors) {
this._errors = errors
this._kclErrorsCallBack(errors)
}
get logs() {
return this._logs
}
@ -132,7 +146,7 @@ export class KclManager {
setDiagnosticsForCurrentErrors() {
editorManager?.setDiagnostics(this.diagnostics)
this._kclErrorsCallBack(this.diagnostics)
this._diagnosticsCallback(this.diagnostics)
}
get isExecuting() {
@ -185,21 +199,24 @@ export class KclManager {
setProgramMemory,
setAst,
setLogs,
setKclErrors,
setErrors,
setDiagnostics,
setIsExecuting,
setWasmInitFailed,
}: {
setProgramMemory: (arg: ProgramMemory) => void
setAst: (arg: Node<Program>) => void
setLogs: (arg: string[]) => void
setKclErrors: (errors: Diagnostic[]) => void
setErrors: (errors: KCLError[]) => void
setDiagnostics: (errors: Diagnostic[]) => void
setIsExecuting: (arg: boolean) => void
setWasmInitFailed: (arg: boolean) => void
}) {
this._programMemoryCallBack = setProgramMemory
this._astCallBack = setAst
this._logsCallBack = setLogs
this._kclErrorsCallBack = setKclErrors
this._kclErrorsCallBack = setErrors
this._diagnosticsCallback = setDiagnostics
this._isExecutingCallback = setIsExecuting
this._wasmInitFailedCallback = setWasmInitFailed
}
@ -311,7 +328,7 @@ export class KclManager {
// Do not send send scene commands if the program was interrupted, go to clean up
if (!isInterrupted) {
this.addDiagnostics(await lintAst({ ast: ast }))
setSelectionFilterToDefault(execState.memory, this.engineCommandManager)
setSelectionFilterToDefault(this.engineCommandManager)
if (args.zoomToFit) {
let zoomObjectId: string | undefined = ''
@ -349,11 +366,13 @@ export class KclManager {
}
this.logs = logs
this.errors = errors
// Do not add the errors since the program was interrupted and the error is not a real KCL error
this.addDiagnostics(isInterrupted ? [] : kclErrorsToDiagnostics(errors))
this.execState = execState
if (!errors.length) {
this.lastSuccessfulProgramMemory = execState.memory
this.lastSuccessfulOperations = execState.operations
}
this.ast = { ...ast }
// updateArtifactGraph relies on updated executeState/programMemory
@ -408,6 +427,7 @@ export class KclManager {
this._programMemory = execState.memory
if (!errors.length) {
this.lastSuccessfulProgramMemory = execState.memory
this.lastSuccessfulOperations = execState.operations
}
if (updates !== 'artifactRanges') return
@ -603,8 +623,8 @@ export class KclManager {
return Promise.all(thePromises)
}
/** TODO: this function is hiding unawaited asynchronous work */
defaultSelectionFilter() {
setSelectionFilterToDefault(this.programMemory, this.engineCommandManager)
defaultSelectionFilter(selectionsToRestore?: Selections) {
setSelectionFilterToDefault(this.engineCommandManager, selectionsToRestore)
}
/** TODO: this function is hiding unawaited asynchronous work */
setSelectionFilter(filter: EntityType_type[]) {
@ -640,25 +660,65 @@ const defaultSelectionFilter: EntityType_type[] = [
/** TODO: This function is not synchronous but is currently treated as such */
function setSelectionFilterToDefault(
programMemory: ProgramMemory,
engineCommandManager: EngineCommandManager
engineCommandManager: EngineCommandManager,
selectionsToRestore?: Selections
) {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
setSelectionFilter(defaultSelectionFilter, engineCommandManager)
setSelectionFilter(
defaultSelectionFilter,
engineCommandManager,
selectionsToRestore
)
}
/** TODO: This function is not synchronous but is currently treated as such */
function setSelectionFilter(
filter: EntityType_type[],
engineCommandManager: EngineCommandManager
engineCommandManager: EngineCommandManager,
selectionsToRestore?: Selections
) {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
engineCommandManager.sendSceneCommand({
type: 'modeling_cmd_req',
cmd_id: uuidv4(),
cmd: {
type: 'set_selection_filter',
filter,
},
const { engineEvents } = selectionsToRestore
? handleSelectionBatch({
selections: selectionsToRestore,
})
: { engineEvents: undefined }
if (!selectionsToRestore || !engineEvents) {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
engineCommandManager.sendSceneCommand({
type: 'modeling_cmd_req',
cmd_id: uuidv4(),
cmd: {
type: 'set_selection_filter',
filter,
},
})
return
}
const modelingCmd: ModelingCmdReq_type[] = []
engineEvents.forEach((event) => {
if (event.type === 'modeling_cmd_req') {
modelingCmd.push({
cmd_id: uuidv4(),
cmd: event.cmd,
})
}
})
// batch is needed other wise the selection flickers.
engineCommandManager
.sendSceneCommand({
type: 'modeling_cmd_batch_req',
batch_id: uuidv4(),
requests: [
{
cmd_id: uuidv4(),
cmd: {
type: 'set_selection_filter',
filter,
},
},
...modelingCmd,
],
responses: false,
})
.catch(reportError)
}

View File

@ -1146,31 +1146,43 @@ export async function deleteFromSelection(
)
if (err(varDec)) return varDec
if (
(selection?.artifact?.type === 'wall' ||
((selection?.artifact?.type === 'wall' ||
selection?.artifact?.type === 'cap') &&
varDec.node.init.type === 'PipeExpression'
varDec.node.init.type === 'PipeExpression') ||
selection.artifact?.type === 'sweep'
) {
const varDecName = varDec.node.id.name
let pathToNode: PathToNode | null = null
let extrudeNameToDelete = ''
traverse(astClone, {
enter: (node, path) => {
if (node.type === 'VariableDeclaration') {
const dec = node.declaration
if (
dec.init.type === 'CallExpression' &&
(dec.init.callee.name === 'extrude' ||
dec.init.callee.name === 'revolve') &&
dec.init.arguments?.[1].type === 'Identifier' &&
dec.init.arguments?.[1].name === varDecName
) {
pathToNode = path
extrudeNameToDelete = dec.id.name
let pathToNode: PathToNode | null = null
if (selection.artifact?.type !== 'sweep') {
const varDecName = varDec.node.id.name
traverse(astClone, {
enter: (node, path) => {
if (node.type === 'VariableDeclaration') {
const dec = node.declaration
if (
dec.init.type === 'CallExpression' &&
(dec.init.callee.name === 'extrude' ||
dec.init.callee.name === 'revolve') &&
dec.init.arguments?.[1].type === 'Identifier' &&
dec.init.arguments?.[1].name === varDecName
) {
pathToNode = path
extrudeNameToDelete = dec.id.name
}
}
}
},
})
if (!pathToNode) return new Error('Could not find extrude variable')
},
})
if (!pathToNode) return new Error('Could not find extrude variable')
} else {
pathToNode = selection.codeRef.pathToNode
const extrudeVarDec = getNodeFromPath<VariableDeclarator>(
astClone,
pathToNode,
'VariableDeclarator'
)
if (err(extrudeVarDec)) return extrudeVarDec
extrudeNameToDelete = extrudeVarDec.node.id.name
}
const expressionIndex = pathToNode[1][0] as number
astClone.body.splice(expressionIndex, 1)

View File

@ -10,7 +10,6 @@ import {
isNodeSafeToReplace,
isTypeInValue,
getNodePathFromSourceRange,
doesPipeHaveCallExp,
hasExtrudeSketch,
findUsesOfTagInPipe,
hasSketchPipeBeenExtruded,
@ -362,82 +361,6 @@ describe('testing getNodePathFromSourceRange', () => {
})
})
describe('testing doesPipeHave', () => {
it('finds close', () => {
const exampleCode = `length001 = 2
part001 = startSketchAt([-1.41, 3.46])
|> line([19.49, 1.16], %, $seg01)
|> angledLine([-35, length001], %)
|> line([-3.22, -7.36], %)
|> angledLine([-175, segLen(seg01)], %)
|> close(%)
`
const ast = assertParse(exampleCode)
const result = doesPipeHaveCallExp({
calleeName: 'close',
ast,
selection: {
codeRef: codeRefFromRange([100, 101, true], ast),
},
})
expect(result).toEqual(true)
})
it('finds extrude', () => {
const exampleCode = `length001 = 2
part001 = startSketchAt([-1.41, 3.46])
|> line([19.49, 1.16], %, $seg01)
|> angledLine([-35, length001], %)
|> line([-3.22, -7.36], %)
|> angledLine([-175, segLen(seg01)], %)
|> close(%)
|> extrude(1, %)
`
const ast = assertParse(exampleCode)
const result = doesPipeHaveCallExp({
calleeName: 'extrude',
ast,
selection: {
codeRef: codeRefFromRange([100, 101, true], ast),
},
})
expect(result).toEqual(true)
})
it('does NOT find close', () => {
const exampleCode = `length001 = 2
part001 = startSketchAt([-1.41, 3.46])
|> line([19.49, 1.16], %, $seg01)
|> angledLine([-35, length001], %)
|> line([-3.22, -7.36], %)
|> angledLine([-175, segLen(seg01)], %)
`
const ast = assertParse(exampleCode)
const result = doesPipeHaveCallExp({
calleeName: 'close',
ast,
selection: {
codeRef: codeRefFromRange([100, 101, true], ast),
},
})
expect(result).toEqual(false)
})
it('returns false if not a pipe', () => {
const exampleCode = `length001 = 2`
const ast = assertParse(exampleCode)
const result = doesPipeHaveCallExp({
calleeName: 'close',
ast,
selection: {
codeRef: codeRefFromRange([9, 10, true], ast),
},
})
expect(result).toEqual(false)
})
})
describe('testing hasExtrudeSketch', () => {
it('find sketch', async () => {
const exampleCode = `length001 = 2

View File

@ -831,33 +831,6 @@ export function isLinesParallelAndConstrained(
}
}
export function doesPipeHaveCallExp({
ast,
selection,
calleeName,
}: {
calleeName: string
ast: Program
selection: Selection
}): boolean {
const pipeExpressionMeta = getNodeFromPath<PipeExpression>(
ast,
selection?.codeRef?.pathToNode,
'PipeExpression'
)
if (err(pipeExpressionMeta)) {
console.error(pipeExpressionMeta)
return false
}
const pipeExpression = pipeExpressionMeta.node
if (pipeExpression.type !== 'PipeExpression') return false
return pipeExpression.body.some(
(expression) =>
expression.type === 'CallExpression' &&
expression.callee.name === calleeName
)
}
export function hasExtrudeSketch({
ast,
selection,
@ -886,6 +859,14 @@ export function hasExtrudeSketch({
)
}
export function artifactIsPlaneWithPaths(selectionRanges: Selections) {
return (
selectionRanges.graphSelections.length &&
selectionRanges.graphSelections[0].artifact?.type === 'plane' &&
selectionRanges.graphSelections[0].artifact.pathIds.length
)
}
export function isSingleCursorInPipe(
selectionRanges: Selections,
ast: Program

View File

@ -882,7 +882,7 @@ export function getArtifactFromRange(
for (const artifact of artifactGraph.values()) {
if ('codeRef' in artifact) {
const match =
artifact.codeRef.range[0] === range[0] &&
artifact.codeRef?.range[0] === range[0] &&
artifact.codeRef.range[1] === range[1]
if (match) return artifact
}

View File

@ -43,6 +43,7 @@ import { Node } from 'wasm-lib/kcl/bindings/Node'
import { CompilationError } from 'wasm-lib/kcl/bindings/CompilationError'
import { SourceRange as RustSourceRange } from 'wasm-lib/kcl/bindings/SourceRange'
import { getAllCurrentSettings } from 'lib/settings/settingsUtils'
import { Operation } from 'wasm-lib/kcl/bindings/Operation'
import { KclErrorWithOutputs } from 'wasm-lib/kcl/bindings/KclErrorWithOutputs'
export type { Configuration } from 'wasm-lib/kcl/bindings/Configuration'
@ -245,6 +246,7 @@ export const isPathToNodeNumber = (
export interface ExecState {
memory: ProgramMemory
operations: Operation[]
}
/**
@ -254,12 +256,14 @@ export interface ExecState {
export function emptyExecState(): ExecState {
return {
memory: ProgramMemory.empty(),
operations: [],
}
}
function execStateFromRaw(raw: RawExecState): ExecState {
return {
memory: ProgramMemory.fromRaw(raw.modLocal.memory),
operations: raw.modLocal.operations,
}
}

View File

@ -76,6 +76,10 @@ export type ModelingCommandSchema = {
'Text-to-CAD': {
prompt: string
}
'Prompt-to-edit': {
prompt: string
selection: Selections
}
}
export const modelingMachineCommandConfig: StateMachineCommandSetConfig<
@ -479,4 +483,29 @@ export const modelingMachineCommandConfig: StateMachineCommandSetConfig<
},
},
},
'Prompt-to-edit': {
description: 'Use Zoo AI to edit your kcl',
icon: 'chat',
args: {
selection: {
inputType: 'selection',
selectionTypes: [
'solid2D',
'segment',
'sweepEdge',
'cap',
'wall',
'edgeCut',
'edgeCutEdge',
],
multiple: true,
required: true,
skip: true,
},
prompt: {
inputType: 'text',
required: true,
},
},
},
}

View File

@ -136,3 +136,5 @@ export const VIEW_NAMES_SEMANTIC = {
[AxisNames.NEG_Y]: 'Front',
[AxisNames.NEG_Z]: 'Bottom',
} as const
/** The modeling sidebar buttons' IDs get a suffix to prevent collisions */
export const SIDEBAR_BUTTON_SUFFIX = '-pane-button'

122
src/lib/operations.test.ts Normal file
View File

@ -0,0 +1,122 @@
import { defaultRustSourceRange } from 'lang/wasm'
import { filterOperations } from './operations'
import { Operation } from 'wasm-lib/kcl/bindings/Operation'
function stdlib(name: string): Operation {
return {
type: 'StdLibCall',
name,
unlabeledArg: null,
labeledArgs: {},
sourceRange: defaultRustSourceRange(),
isError: false,
}
}
function userCall(name: string): Operation {
return {
type: 'UserDefinedFunctionCall',
name,
functionSourceRange: defaultRustSourceRange(),
unlabeledArg: null,
labeledArgs: {},
sourceRange: defaultRustSourceRange(),
}
}
function userReturn(): Operation {
return {
type: 'UserDefinedFunctionReturn',
}
}
describe('operations filtering', () => {
it('drops stdlib operations inside a user-defined function call', async () => {
const operations = [
stdlib('std1'),
userCall('foo'),
stdlib('std2'),
stdlib('std3'),
userReturn(),
stdlib('std4'),
stdlib('std5'),
]
const actual = filterOperations(operations)
expect(actual).toEqual([
stdlib('std1'),
userCall('foo'),
stdlib('std4'),
stdlib('std5'),
])
})
it('drops user-defined function calls that contain no stdlib operations', async () => {
const operations = [
stdlib('std1'),
userCall('foo'),
userReturn(),
stdlib('std2'),
userCall('bar'),
userReturn(),
stdlib('std3'),
]
const actual = filterOperations(operations)
expect(actual).toEqual([stdlib('std1'), stdlib('std2'), stdlib('std3')])
})
it('preserves user-defined function calls at the end of the list', async () => {
const operations = [stdlib('std1'), userCall('foo')]
const actual = filterOperations(operations)
expect(actual).toEqual([stdlib('std1'), userCall('foo')])
})
it('drops all user-defined function return operations', async () => {
// The returns allow us to group operations with the call, but we never
// display the returns.
const operations = [
stdlib('std1'),
userCall('foo'),
stdlib('std2'),
userReturn(),
stdlib('std3'),
stdlib('std4'),
userCall('foo2'),
stdlib('std5'),
stdlib('std6'),
userReturn(),
stdlib('std7'),
]
const actual = filterOperations(operations)
expect(actual).toEqual([
stdlib('std1'),
userCall('foo'),
stdlib('std3'),
stdlib('std4'),
userCall('foo2'),
stdlib('std7'),
])
})
it('correctly filters with nested function calls', async () => {
const operations = [
stdlib('std1'),
userCall('foo'),
stdlib('std2'),
userReturn(),
stdlib('std3'),
stdlib('std4'),
userCall('foo2'),
stdlib('std5'),
userCall('foo3-nested'),
stdlib('std6'),
userReturn(),
stdlib('std7'),
userReturn(),
stdlib('std8'),
]
const actual = filterOperations(operations)
expect(actual).toEqual([
stdlib('std1'),
userCall('foo'),
stdlib('std3'),
stdlib('std4'),
userCall('foo2'),
stdlib('std8'),
])
})
})

180
src/lib/operations.ts Normal file
View File

@ -0,0 +1,180 @@
import { CustomIconName } from 'components/CustomIcon'
import { Operation } from 'wasm-lib/kcl/bindings/Operation'
interface StdLibCallInfo {
label: string
icon: CustomIconName
}
const stdLibMap: Record<string, StdLibCallInfo> = {
chamfer: {
label: 'Chamfer',
icon: 'chamfer3d',
},
extrude: {
label: 'Extrude',
icon: 'extrude',
},
fillet: {
label: 'Fillet',
icon: 'fillet3d',
},
hole: {
label: 'Hole',
icon: 'hole',
},
hollow: {
label: 'Hollow',
icon: 'hollow',
},
import: {
label: 'Import',
icon: 'import',
},
loft: {
label: 'Loft',
icon: 'loft',
},
offsetPlane: {
label: 'Offset Plane',
icon: 'plane',
},
patternCircular2d: {
label: 'Circular Pattern',
icon: 'patternCircular2d',
},
patternCircular3d: {
label: 'Circular Pattern',
icon: 'patternCircular3d',
},
patternLinear2d: {
label: 'Linear Pattern',
icon: 'patternLinear2d',
},
patternLinear3d: {
label: 'Linear Pattern',
icon: 'patternLinear3d',
},
revolve: {
label: 'Revolve',
icon: 'revolve',
},
shell: {
label: 'Shell',
icon: 'shell',
},
startSketchOn: {
label: 'Sketch',
icon: 'sketch',
},
sweep: {
label: 'Sweep',
icon: 'sweep',
},
}
/**
* Returns the label of the operation
*/
export function getOperationLabel(op: Operation): string {
switch (op.type) {
case 'StdLibCall':
return stdLibMap[op.name]?.label ?? op.name
case 'UserDefinedFunctionCall':
return op.name ?? 'Anonymous custom function'
case 'UserDefinedFunctionReturn':
return 'User function return'
}
}
/**
* Returns the icon of the operation
*/
export function getOperationIcon(op: Operation): CustomIconName {
switch (op.type) {
case 'StdLibCall':
return stdLibMap[op.name]?.icon ?? 'questionMark'
default:
return 'make-variable'
}
}
/**
* Apply all filters to a list of operations.
*/
export function filterOperations(operations: Operation[]): Operation[] {
return operationFilters.reduce((ops, filterFn) => filterFn(ops), operations)
}
/**
* The filters to apply to a list of operations
* for use in the feature tree UI
*/
const operationFilters = [
isNotUserFunctionWithNoOperations,
isNotInsideUserFunction,
isNotUserFunctionReturn,
]
/**
* A filter to exclude everything that occurs inside a UserDefinedFunctionCall
* and its corresponding UserDefinedFunctionReturn from a list of operations.
* This works even when there are nested function calls.
*/
function isNotInsideUserFunction(operations: Operation[]): Operation[] {
const ops: Operation[] = []
let depth = 0
for (const op of operations) {
if (depth === 0) {
ops.push(op)
}
if (op.type === 'UserDefinedFunctionCall') {
depth++
}
if (op.type === 'UserDefinedFunctionReturn') {
depth--
console.assert(
depth >= 0,
'Unbalanced UserDefinedFunctionCall and UserDefinedFunctionReturn; too many returns'
)
}
}
// Depth could be non-zero here if there was an error in execution.
return ops
}
/**
* A filter to exclude UserDefinedFunctionCall operations and their
* corresponding UserDefinedFunctionReturn that don't have any operations inside
* them from a list of operations.
*/
function isNotUserFunctionWithNoOperations(
operations: Operation[]
): Operation[] {
return operations.filter((op, index) => {
if (
op.type === 'UserDefinedFunctionCall' &&
// If this is a call at the end of the array, it's preserved.
index < operations.length - 1 &&
operations[index + 1].type === 'UserDefinedFunctionReturn'
)
return false
if (
op.type === 'UserDefinedFunctionReturn' &&
// If this return is at the beginning of the array, it's preserved.
index > 0 &&
operations[index - 1].type === 'UserDefinedFunctionCall'
)
return false
return true
})
}
/**
* A filter to exclude UserDefinedFunctionReturn operations from a list of
* operations.
*/
function isNotUserFunctionReturn(ops: Operation[]): Operation[] {
return ops.filter((op) => op.type !== 'UserDefinedFunctionReturn')
}

345
src/lib/promptToEdit.ts Normal file
View File

@ -0,0 +1,345 @@
import { Models } from '@kittycad/lib'
import { VITE_KC_API_BASE_URL } from 'env'
import crossPlatformFetch from './crossPlatformFetch'
import { err, reportRejection } from './trap'
import { Selections } from './selections'
import { ArtifactGraph, getArtifactOfTypes } from 'lang/std/artifactGraph'
import { SourceRange } from 'lang/wasm'
import toast from 'react-hot-toast'
import { codeManager, editorManager, kclManager } from './singletons'
import { ToastPromptToEditCadSuccess } from 'components/ToastTextToCad'
import { uuidv4 } from './utils'
import { diffLines } from 'diff'
import { Transaction, EditorSelection, SelectionRange } from '@codemirror/state'
import { modelingMachineEvent } from 'editor/manager'
function sourceIndexToLineColumn(
code: string,
index: number
): { line: number; column: number } {
const codeStart = code.slice(0, index)
const lines = codeStart.split('\n')
const line = lines.length
const column = lines[lines.length - 1].length
return { line, column }
}
function convertAppRangeToApiRange(
range: SourceRange,
code: string
): Models['SourceRange_type'] {
return {
start: sourceIndexToLineColumn(code, range[0]),
end: sourceIndexToLineColumn(code, range[1]),
}
}
export async function submitPromptToEditToQueue({
prompt,
selections,
code,
token,
artifactGraph,
}: {
prompt: string
selections: Selections
code: string
token?: string
artifactGraph: ArtifactGraph
}): Promise<Models['TextToCadIteration_type'] | Error> {
const ranges: Models['TextToCadIterationBody_type']['source_ranges'] =
selections.graphSelections.flatMap((selection) => {
const artifact = selection.artifact
const prompts: Models['TextToCadIterationBody_type']['source_ranges'] = []
if (artifact?.type === 'cap') {
prompts.push({
prompt: `The users main selection is the end cap of a general-sweep (that is an extrusion, revolve, sweep or loft).
The source range most likely refers to "startProfileAt" simply because this is the start of the profile that was swept.
If you need to operate on this cap, for example for sketching on the face, you can use the special string ${
artifact.subType === 'end' ? 'END' : 'START'
} i.e. \`startSketchOn(someSweepVariable, ${
artifact.subType === 'end' ? 'END' : 'START'
})\`
When they made this selection they main have intended this surface directly or meant something more general like the sweep body.
See later source ranges for more context.`,
range: convertAppRangeToApiRange(selection.codeRef.range, code),
})
let sweep = getArtifactOfTypes(
{ key: artifact.sweepId, types: ['sweep'] },
artifactGraph
)
if (!err(sweep)) {
prompts.push({
prompt: `This is the sweep's source range from the user's main selection of the end cap.`,
range: convertAppRangeToApiRange(sweep.codeRef.range, code),
})
}
}
if (artifact?.type === 'wall') {
prompts.push({
prompt: `The users main selection is the wall of a general-sweep (that is an extrusion, revolve, sweep or loft).
The source range though is for the original segment before it was extruded, you can add a tag to that segment in order to refer to this wall, for example "startSketchOn(someSweepVariable, segmentTag)"
But it's also worth bearing in mind that the user may have intended to select the sweep itself, not this individual wall, see later source ranges for more context. about the sweep`,
range: convertAppRangeToApiRange(selection.codeRef.range, code),
})
let sweep = getArtifactOfTypes(
{ key: artifact.sweepId, types: ['sweep'] },
artifactGraph
)
if (!err(sweep)) {
prompts.push({
prompt: `This is the sweep's source range from the user's main selection of the end cap.`,
range: convertAppRangeToApiRange(sweep.codeRef.range, code),
})
}
}
if (artifact?.type === 'sweepEdge') {
prompts.push({
prompt: `The users main selection is the edge of a general-sweep (that is an extrusion, revolve, sweep or loft).
it is an ${
artifact.subType
} edge, in order to refer to this edge you should add a tag to the segment function in this source range,
and then use the function ${
artifact.subType === 'adjacent'
? 'getAdjacentEdge'
: 'getOppositeEdge'
}
See later source ranges for more context. about the sweep`,
range: convertAppRangeToApiRange(selection.codeRef.range, code),
})
let sweep = getArtifactOfTypes(
{ key: artifact.sweepId, types: ['sweep'] },
artifactGraph
)
if (!err(sweep)) {
prompts.push({
prompt: `This is the sweep's source range from the user's main selection of the end cap.`,
range: convertAppRangeToApiRange(sweep.codeRef.range, code),
})
}
}
if (artifact?.type === 'segment') {
if (!artifact.surfaceId) {
prompts.push({
prompt: `This selection is of a segment, likely an individual part of a profile. Segments are often "constrained" by the use of variables and relationships with other segments. Adding tags to segments helps refer to their length, angle or other properties`,
range: convertAppRangeToApiRange(selection.codeRef.range, code),
})
} else {
prompts.push({
prompt: `This selection is for a segment (line, xLine, angledLine etc) that has been swept (a general-sweep, either an extrusion, revolve, sweep or loft).
Because it now refers to an edge the way to refer to this edge is to add a tag to the segment, and then use that tag directly.
i.e. \`fillet({ radius = someInteger, tags = [newTag] }, %)\` will work in the case of filleting this edge
See later source ranges for more context. about the sweep`,
range: convertAppRangeToApiRange(selection.codeRef.range, code),
})
let path = getArtifactOfTypes(
{ key: artifact.pathId, types: ['path'] },
artifactGraph
)
if (!err(path)) {
const sweep = getArtifactOfTypes(
{ key: path.sweepId, types: ['sweep'] },
artifactGraph
)
if (!err(sweep)) {
prompts.push({
prompt: `This is the sweep's source range from the user's main selection of the edge.`,
range: convertAppRangeToApiRange(sweep.codeRef.range, code),
})
}
}
}
}
return prompts
})
const body: Models['TextToCadIterationBody_type'] = {
original_source_code: code,
prompt,
source_ranges: ranges,
}
const url = VITE_KC_API_BASE_URL + '/ml/text-to-cad/iteration'
const data: Models['TextToCadIteration_type'] | Error =
await crossPlatformFetch(
url,
{
method: 'POST',
body: JSON.stringify(body),
},
token
)
// Make sure we have an id.
if (data instanceof Error) {
return data
}
if (!data.id) {
return new Error('No id returned from Text-to-CAD API')
}
return data
}
export async function getPromptToEditResult(
id: string,
token?: string
): Promise<Models['TextToCadIteration_type'] | Error> {
const url = VITE_KC_API_BASE_URL + '/async/operations/' + id
const data: Models['TextToCadIteration_type'] | Error =
await crossPlatformFetch(
url,
{
method: 'GET',
},
token
)
return data
}
export async function doPromptEdit({
prompt,
selections,
code,
token,
artifactGraph,
}: {
prompt: string
selections: Selections
code: string
token?: string
artifactGraph: ArtifactGraph
}): Promise<Models['TextToCadIteration_type'] | Error> {
const toastId = toast.loading('Submitting to Text-to-CAD API...')
const submitResult = await submitPromptToEditToQueue({
prompt,
selections,
code,
token,
artifactGraph,
})
if (err(submitResult)) return submitResult
const textToCadComplete = new Promise<Models['TextToCadIteration_type']>(
(resolve, reject) => {
;(async () => {
const MAX_CHECK_TIMEOUT = 3 * 60_000
const CHECK_DELAY = 200
let timeElapsed = 0
while (timeElapsed < MAX_CHECK_TIMEOUT) {
const check = await getPromptToEditResult(submitResult.id, token)
if (check instanceof Error || check.status === 'failed') {
reject(check)
return
} else if (check.status === 'completed') {
resolve(check)
return
}
await new Promise((r) => setTimeout(r, CHECK_DELAY))
timeElapsed += CHECK_DELAY
}
reject(new Error('Text-to-CAD API timed out'))
})().catch(reportRejection)
}
)
try {
const result = await textToCadComplete
toast.dismiss(toastId)
return result
} catch (e) {
toast.dismiss(toastId)
toast.error(
'Failed to edit your KCL code, please try again with a different prompt or selection'
)
console.error('textToCadComplete', e)
}
return textToCadComplete
}
/** takes care of the whole submit prompt to endpoint flow including the accept-reject toast once the result is back */
export async function promptToEditFlow({
prompt,
selections,
code,
token,
artifactGraph,
}: {
prompt: string
selections: Selections
code: string
token?: string
artifactGraph: ArtifactGraph
}) {
const result = await doPromptEdit({
prompt,
selections,
code,
token,
artifactGraph,
})
if (err(result)) return Promise.reject(result)
const oldCode = codeManager.code
const { code: newCode } = result
codeManager.updateCodeEditor(newCode)
const diff = reBuildNewCodeWithRanges(oldCode, newCode)
const ranges: SelectionRange[] = diff.insertRanges.map((range) =>
EditorSelection.range(range[0], range[1])
)
editorManager?.editorView?.dispatch({
selection: EditorSelection.create(
ranges,
selections.graphSelections.length - 1
),
annotations: [modelingMachineEvent, Transaction.addToHistory.of(false)],
})
await kclManager.executeCode()
const toastId = uuidv4()
toast.success(
() =>
ToastPromptToEditCadSuccess({
toastId,
data: result,
token,
oldCode,
}),
{
id: toastId,
duration: Infinity,
icon: null,
}
)
}
const reBuildNewCodeWithRanges = (
oldCode: string,
newCode: string
): {
newCode: string
insertRanges: SourceRange[]
} => {
let insertRanges: SourceRange[] = []
const changes = diffLines(oldCode, newCode)
let newCodeWithRanges = ''
for (const change of changes) {
if (!change.added && !change.removed) {
// no change add it to newCodeWithRanges
newCodeWithRanges += change.value
} else if (change.added && !change.removed) {
const start = newCodeWithRanges.length
const end = start + change.value.length
insertRanges.push([start, end, true])
newCodeWithRanges += change.value
}
}
return {
newCode: newCodeWithRanges,
insertRanges,
}
}

View File

@ -125,7 +125,9 @@ export const fileLoader: LoaderFunction = async (
// If persistCode in localStorage is present, it'll persist that code
// through *anything*. INTENDED FOR TESTS.
code = codeManager.localStoragePersistCode() || code
if (window.electron.process.env.IS_PLAYWRIGHT) {
code = codeManager.localStoragePersistCode() || code
}
// Update both the state and the editor's code.
// We explicitly do not write to the file here since we are loading from

View File

@ -18,10 +18,8 @@ import { getNormalisedCoordinates, isOverlap } from 'lib/utils'
import { isCursorInSketchCommandRange } from 'lang/util'
import { Program } from 'lang/wasm'
import {
doesPipeHaveCallExp,
getNodeFromPath,
getNodePathFromSourceRange,
hasSketchPipeBeenExtruded,
isSingleCursorInPipe,
} from 'lang/queryAst'
import { CommandArgument } from './commandTypes'
@ -490,6 +488,9 @@ function resetAndSetEngineEntitySelectionCmds(
]
}
/**
* Is the selection a single cursor in a sketch pipe expression chain?
*/
export function isSketchPipe(selectionRanges: Selections) {
if (!isSingleCursorInPipe(selectionRanges, kclManager.ast)) return false
return isCursorInSketchCommandRange(
@ -498,115 +499,6 @@ export function isSketchPipe(selectionRanges: Selections) {
)
}
export function isSelectionLastLine(
selectionRanges: Selections,
code: string,
i = 0
) {
return selectionRanges.graphSelections[i]?.codeRef?.range[1] === code.length
}
export function isRangeBetweenCharacters(selectionRanges: Selections) {
return (
selectionRanges.graphSelections.length === 1 &&
selectionRanges.graphSelections[0]?.codeRef?.range[0] === 0 &&
selectionRanges.graphSelections[0]?.codeRef?.range[1] === 0
)
}
export type CommonASTNode = {
selection: Selection
ast: Program
}
function buildCommonNodeFromSelection(selectionRanges: Selections, i: number) {
return {
selection: selectionRanges.graphSelections[i],
ast: kclManager.ast,
}
}
function nodeHasExtrude(node: CommonASTNode) {
return (
doesPipeHaveCallExp({
calleeName: 'extrude',
...node,
}) ||
doesPipeHaveCallExp({
calleeName: 'revolve',
...node,
}) ||
doesPipeHaveCallExp({
calleeName: 'loft',
...node,
})
)
}
function nodeHasClose(node: CommonASTNode) {
return doesPipeHaveCallExp({
calleeName: 'close',
...node,
})
}
function nodeHasCircle(node: CommonASTNode) {
return doesPipeHaveCallExp({
calleeName: 'circle',
...node,
})
}
export function canSweepSelection(selection: Selections) {
const commonNodes = selection.graphSelections.map((_, i) =>
buildCommonNodeFromSelection(selection, i)
)
return (
!!isSketchPipe(selection) &&
commonNodes.every((n) => !hasSketchPipeBeenExtruded(n.selection, n.ast)) &&
(commonNodes.every((n) => nodeHasClose(n)) ||
commonNodes.every((n) => nodeHasCircle(n))) &&
commonNodes.every((n) => !nodeHasExtrude(n))
)
}
export function canRevolveSelection(selection: Selections) {
const commonNodes = selection.graphSelections.map((_, i) =>
buildCommonNodeFromSelection(selection, i)
)
return (
!!isSketchPipe(selection) &&
(commonNodes.every((n) => nodeHasClose(n)) ||
commonNodes.every((n) => nodeHasCircle(n)))
)
}
export function canLoftSelection(selection: Selections) {
const commonNodes = selection.graphSelections.map((_, i) =>
buildCommonNodeFromSelection(selection, i)
)
return (
!!isCursorInSketchCommandRange(
engineCommandManager.artifactGraph,
selection
) &&
commonNodes.length > 1 &&
commonNodes.every((n) => !hasSketchPipeBeenExtruded(n.selection, n.ast)) &&
commonNodes.every((n) => nodeHasClose(n) || nodeHasCircle(n)) &&
commonNodes.every((n) => !nodeHasExtrude(n))
)
}
export function canShellSelection(selection: Selections) {
const commonNodes = selection.graphSelections.map((_, i) =>
buildCommonNodeFromSelection(selection, i)
)
return commonNodes.every(
(n) =>
n.selection.artifact?.type === 'cap' ||
n.selection.artifact?.type === 'wall'
)
}
// This accounts for non-geometry selections under "other"
export type ResolvedSelectionType = Artifact['type'] | 'other'
export type SelectionCountsByType = Map<ResolvedSelectionType, number>
@ -889,6 +781,14 @@ export function codeToIdSelections(
}
}
}
if (entry.artifact.type === 'sweep') {
bestCandidate = {
artifact: entry.artifact,
selection,
id: entry.id,
}
}
})
if (bestCandidate) {

View File

@ -17,7 +17,7 @@ import { getNextFileName } from './desktopFS'
import { reportRejection } from './trap'
import { toSync } from './utils'
export async function submitTextToCadPrompt(
async function submitTextToCadPrompt(
prompt: string,
token?: string
): Promise<Models['TextToCad_type'] | Error> {
@ -45,7 +45,7 @@ export async function submitTextToCadPrompt(
return data
}
export async function getTextToCadResult(
async function getTextToCadResult(
id: string,
token?: string
): Promise<Models['TextToCad_type'] | Error> {

View File

@ -71,7 +71,6 @@ export const toolbarConfig: Record<ToolbarModeName, ToolbarMode> = {
: modelingSend({ type: 'Enter sketch' }),
icon: 'sketch',
status: 'available',
disabled: (state) => !state.matches('idle'),
title: ({ sketchPathId }) =>
`${sketchPathId ? 'Edit' : 'Start'} Sketch`,
showTitle: true,
@ -89,7 +88,6 @@ export const toolbarConfig: Record<ToolbarModeName, ToolbarMode> = {
type: 'Find and select command',
data: { name: 'Extrude', groupId: 'modeling' },
}),
disabled: (state) => !state.can({ type: 'Extrude' }),
icon: 'extrude',
status: 'available',
title: 'Extrude',
@ -104,9 +102,6 @@ export const toolbarConfig: Record<ToolbarModeName, ToolbarMode> = {
type: 'Find and select command',
data: { name: 'Revolve', groupId: 'modeling' },
}),
// TODO: disabled
// Who's state is this?
disabled: (state) => !state.can({ type: 'Revolve' }),
icon: 'revolve',
status: DEV ? 'available' : 'kcl-only',
title: 'Revolve',
@ -144,7 +139,6 @@ export const toolbarConfig: Record<ToolbarModeName, ToolbarMode> = {
type: 'Find and select command',
data: { name: 'Loft', groupId: 'modeling' },
}),
disabled: (state) => !state.can({ type: 'Loft' }),
icon: 'loft',
status: 'available',
title: 'Loft',
@ -172,7 +166,6 @@ export const toolbarConfig: Record<ToolbarModeName, ToolbarMode> = {
}),
icon: 'fillet3d',
status: DEV ? 'available' : 'kcl-only',
disabled: (state) => !state.can({ type: 'Fillet' }),
title: 'Fillet',
hotkey: 'F',
description: 'Round the edges of a 3D solid.',
@ -196,7 +189,6 @@ export const toolbarConfig: Record<ToolbarModeName, ToolbarMode> = {
data: { name: 'Shell', groupId: 'modeling' },
})
},
disabled: (state) => !state.can({ type: 'Shell' }),
icon: 'shell',
status: 'available',
title: 'Shell',
@ -440,10 +432,19 @@ export const toolbarConfig: Record<ToolbarModeName, ToolbarMode> = {
},
{
id: 'circle-three-points',
onClick: () =>
console.error('Three-point circle not yet implemented'),
onClick: ({ modelingState, modelingSend }) =>
modelingSend({
type: 'change tool',
data: {
tool: !modelingState.matches({
Sketch: 'circle3PointToolSelect',
})
? 'circle3Points'
: 'none',
},
}),
icon: 'circle',
status: 'unavailable',
status: 'available',
title: 'Three-point circle',
showTitle: false,
description: 'Draw a circle defined by three points',

View File

@ -0,0 +1,157 @@
import { SourceRange } from 'lang/wasm'
import { assign, setup } from 'xstate'
type FeatureTreeEvent =
| {
type: 'goToKclSource'
data: { targetSourceRange: SourceRange }
}
| {
type: 'selectOperation'
data: { targetSourceRange: SourceRange }
}
| {
type: 'enterEditFlow'
data: { targetSourceRange: SourceRange }
}
| { type: 'goToError' }
| { type: 'codePaneOpened' }
| { type: 'selected' }
| { type: 'done' }
export const featureTreeMachine = setup({
types: {
context: {} as { targetSourceRange?: SourceRange },
events: {} as FeatureTreeEvent,
},
guards: {
codePaneIsOpen: () => false,
},
actions: {
saveTargetSourceRange: assign({
targetSourceRange: ({ event }) =>
'data' in event ? event.data.targetSourceRange : undefined,
}),
clearTargetSourceRange: assign({
targetSourceRange: undefined,
}),
sendSelectionEvent: () => {},
openCodePane: () => {},
sendEditFlowStart: () => {},
scrollToError: () => {},
},
}).createMachine({
/** @xstate-layout N4IgpgJg5mDOIC5QDMwEMAuBXATmAKnmAHQCWEANmAMRQD2+dA0gMYUDKduLYA2gAwBdRKAAOdWKQyk6AOxEgAHogCMAFn7EAbAFYAHAHYAnGoOm9OgMw6tAGhABPRBsvF+a6wYBM-A-0uWepYGAL4h9qiYuAREZJQ0sGBULBgA8qJgOJgysgLCSCDiktJyCsoIKipabmo6-Co2Wip+-DoG9k4IWpp+Ae6W-N16KpZeYRHo2HiEYCTkVNRgshiZAKIQUgBiFHQA7nkKRVI5ZapergN6el46515GVdYdiN4GbjpGXiNGV2pan+MQJEpjFZsR6KRZFBGKwOFwcDxiIlktIodRkWAUpADgUjiV5AVyipWipiGZupYVCY1D5+F5ngg-jpiJ8DFo1OoDA13GNwkDJtEZiQIVCYWxONwSBA5DQcWIJMdSoTVG0yTodGoPAZKfwjP52o5EHVNIYvNd9M0zJZAcDBbERdDmOL4Yi6BlZJCoABhOgQMAABTQshoLF9AaDYHSS2xQkOCvxpy6lWIbS0Bm8JkM+ksDKZLK8bL0-S8NOCNoF01iGJSnqRSUxqKg6PrWIgcsK8ZOyq6aj0avTbU1Hg0KgZzVcrU+Xn+9Q+dPLUUrYOrjeI0uD1HbeK7oCJBj7xkafhMX28agZJeqHz0RhstTUD21WgXIKFxCWKxwnvWWx2uzrKKes2KIxvk8rFDuShGpY1RaKMRiHr4va9gyegPsQQRmiYVK+GYL52mCH6ZN+GwYNsexrjKm6xrinZKruqhaLBmr8PUvj6F4nGoeoxDEpYCFMVSowfPhS7CnQnqMKsOA4HQODEG6Syej6fqBhuoaqRGUbBm2NHgYqBIMV0-EproxLpoE7hWGOnFeGSxgaPwei6EYmaiaCczxLQDB0NJsk4FudGGVBFRpsQwRGGmgx6n4cH0oaFSVH21i6LqD5mtYejuW+DpSTJcmURugUQfRIX6MQfwcpqDxpUETwJSoXxqMQ04PjoSXspYtRhHyshhvABS2mJcYlcF5QALR2Al43TtoVJDD46ZfJS1p8kNHlxFQI0GYmNJjn8c21PuIzjuq2X2hJopOnCkrbQm3ZdXZNhsl1fzOSW1kJdYzKeK5VQqJh+7nWCuXXRKCIkCunp3ZB5RFq46ofOq6geDeo4JZqdlaEWt66l8jXfcD4mSWDLpSjKMOlUSzSwXS2qztVfy5jS2g43UnyVOcZ1rRWG2g7C4Ouu6ylhmpYCU2NiD8Zoz1wZq2NaB9OYYyz2O6gE3TtR8XVEwBDbQ7Ro2JtYT1pnLb2K7UyudIrFU3h8ZoamafhZTzi4bVDUJ6zWUIS7trGmS98vvVb+1GBhjUPtqer3KxOi657UCFeLhs7d2FmHe1ARmFr54NdqLUFrZnLBFUutEV+UI-mRf5+w9NgYZSNyBHB6rxTbcHhTY5wmDVMGrRM7tvhXJG-hRid10ZGjNUEjVWM533t4gaHh8aFgaOc+7XOXyzEVXpHkf+64p-p91T744VBEE7h0oEGpTZ0Dx2Sbhi9r4ozNLroN+XJk8hTBV51SRQGE7JiS9ErJjgnSRWC0bCu0Hq+C6JMf7yUUh6KEKlwzBj-uUKqKYgFQNAYrMcVI+yVFcqxfwFhAhf0uo6FByccHLyaC1JygRp5FkagaTo5CyFUj1KxO+gReRhCAA */
id: 'featureTree',
description: 'Workflows for interacting with the feature tree pane',
states: {
idle: {
on: {
goToKclSource: {
target: 'goingToKclSource',
actions: 'saveTargetSourceRange',
},
selectOperation: {
target: 'selecting',
actions: 'saveTargetSourceRange',
},
enterEditFlow: {
target: 'enteringEditFlow',
actions: 'saveTargetSourceRange',
},
goToError: 'goingToError',
},
},
goingToKclSource: {
states: {
selecting: {
on: {
selected: {
target: 'done',
},
},
entry: ['sendSelectionEvent'],
},
done: {
entry: ['clearTargetSourceRange'],
always: '#featureTree.idle',
},
openingCodePane: {
on: {
codePaneOpened: 'selecting',
},
entry: 'openCodePane',
},
},
initial: 'openingCodePane',
},
selecting: {
states: {
selecting: {
on: {
selected: 'done',
},
entry: 'sendSelectionEvent',
},
done: {
always: '#featureTree.idle',
entry: 'clearTargetSourceRange',
},
},
initial: 'selecting',
},
enteringEditFlow: {
states: {
selecting: {
on: {
selected: 'done',
},
},
done: {
always: '#featureTree.idle',
},
},
initial: 'selecting',
entry: 'sendSelectionEvent',
exit: ['clearTargetSourceRange', 'sendEditFlowStart'],
},
goingToError: {
states: {
openingCodePane: {
entry: 'openCodePane',
on: {
codePaneOpened: 'done',
},
},
done: {
entry: 'scrollToError',
always: '#featureTree.idle',
},
},
initial: 'openingCodePane',
},
},
initial: 'idle',
})

View File

@ -0,0 +1,67 @@
import { assign, createActor, setup, StateFrom } from 'xstate'
import { EditorSelection } from '@codemirror/state'
type SelectionEvent = {
codeMirrorSelection: EditorSelection
scrollIntoView: boolean
}
type KclEditorMachineEvent =
| { type: 'setKclEditorMounted'; data: boolean }
| { type: 'setLastSelectionEvent'; data?: SelectionEvent }
interface KclEditorMachineContext {
isKclEditorMounted: boolean
lastSelectionEvent?: SelectionEvent
}
/**
* This is a one-off XState machine not tied to React, so that we can publish
* state to it from singletons and other parts of the app.
*/
export const kclEditorMachine = setup({
types: {
events: {} as KclEditorMachineEvent,
context: {} as KclEditorMachineContext,
},
actions: {
setKclEditorMounted: assign({
isKclEditorMounted: ({ context, event }) =>
event.type === 'setKclEditorMounted'
? event.data
: context.isKclEditorMounted,
}),
setLastSelectionEvent: assign({
lastSelectionEvent: ({ context, event }) =>
event.type === 'setLastSelectionEvent'
? event.data
: context.lastSelectionEvent,
}),
},
}).createMachine({
/** @xstate-layout N4IgpgJg5mDOIC5QGsDGAbAohAlgFwHsAnAWQENUALHAOzAGJYw8BpDbfYkggVxr0gBtAAwBdRKAAOBWPhwEaEkAA9EARmEB2AHTCALACYAzGs0BWMwDYAHNZOWANCACeiALRrL2tQesGzamZ+FgYAnJqaAL7RTjQEEHBKaFi4hKQU1HRK0rJ48opIKupq2tZmwgZ6RpZ6msJGeqaOLupG1tq+bQFalZqWZtHRQA */
id: 'kclEditorMachine',
context: {
isKclEditorMounted: false,
lastSelectionEvent: undefined,
},
on: {
setKclEditorMounted: {
actions: 'setKclEditorMounted',
},
setLastSelectionEvent: {
actions: 'setLastSelectionEvent',
},
},
})
export const kclEditorActor = createActor(kclEditorMachine).start()
/** Watch for changes to `lastSelectionEvent` */
export const selectionEventSelector = (
snapshot?: StateFrom<typeof kclEditorMachine>
) => snapshot?.context?.lastSelectionEvent
/** Watch for the editorView to be mounted */
export const editorIsMountedSelector = (
snapshot?: StateFrom<typeof kclEditorMachine>
) => snapshot?.context?.isKclEditorMounted

File diff suppressed because one or more lines are too long

View File

@ -21,7 +21,7 @@ type Point2D = kcmc::shared::Point2d<f64>;
type Point3D = kcmc::shared::Point3d<f64>;
pub use function_param::FunctionParam;
pub use kcl_value::{KclObjectFields, KclValue};
pub use kcl_value::{KclObjectFields, KclValue, UnitLen};
use uuid::Uuid;
mod annotations;
@ -43,6 +43,7 @@ use crate::{
settings::types::UnitLength,
source_range::{ModuleId, SourceRange},
std::{args::Arg, StdLib},
walk::Node as WalkNode,
ExecError, Program,
};
@ -2002,9 +2003,12 @@ impl ExecutorContext {
return None;
}
let mut old_ast = old.ast.inner;
let mut old_ast = old.ast;
let mut new_ast = info.new_ast;
// The digests should already be computed, but just in case we don't
// want to compare against none.
old_ast.compute_digest();
let mut new_ast = info.new_ast.inner.clone();
new_ast.compute_digest();
// Check if the digest is the same.
@ -2013,12 +2017,84 @@ impl ExecutorContext {
}
// Check if the changes were only to Non-code areas, like comments or whitespace.
Some(self.generate_changed_program(old_ast, new_ast))
}
// For any unhandled cases just re-execute the whole thing.
Some(CacheResult {
clear_scene: true,
program: info.new_ast,
})
/// Force-generate a new CacheResult, even if one shouldn't be made. The
/// way in which this gets invoked should always be through
/// [Self::get_changed_program]. This is purely to contain the logic on
/// how we construct a new [CacheResult].
pub fn generate_changed_program(&self, old_ast: Node<AstProgram>, new_ast: Node<AstProgram>) -> CacheResult {
let mut generated_program = new_ast.clone();
generated_program.body = vec![];
if !old_ast.body.iter().zip(new_ast.body.iter()).all(|(old, new)| {
let old_node: WalkNode = old.into();
let new_node: WalkNode = new.into();
old_node.digest() == new_node.digest()
}) {
// If any of the nodes are different in the stretch of body that
// overlaps, we have to bust cache and rebuild the scene. This
// means a single insertion or deletion will result in a cache
// bust.
return CacheResult {
clear_scene: true,
program: new_ast,
};
}
// otherwise the overlapping section of the ast bodies matches.
// Let's see what the rest of the slice looks like.
match new_ast.body.len().cmp(&old_ast.body.len()) {
std::cmp::Ordering::Less => {
// the new AST is shorter than the old AST -- statements
// were removed from the "current" code in the "new" code.
//
// Statements up until now match which means this is a
// "pure delete" of the remaining slice, when we get to
// supporting that.
// Cache bust time.
CacheResult {
clear_scene: true,
program: new_ast,
}
}
std::cmp::Ordering::Greater => {
// the new AST is longer than the old AST, which means
// statements were added to the new code we haven't previously
// seen.
//
// Statements up until now are the same, which means this
// is a "pure addition" of the remaining slice.
generated_program
.body
.extend_from_slice(&new_ast.body[old_ast.body.len()..]);
CacheResult {
clear_scene: false,
program: generated_program,
}
}
std::cmp::Ordering::Equal => {
// currently unreachable, but lets pretend like the code
// above can do something meaningful here for when we get
// to diffing and yanking chunks of the program apart.
// We don't actually want to do anything here; so we're going
// to not clear and do nothing. Is this wrong? I don't think
// so but i think many things. This def needs to change
// when the code above changes.
CacheResult {
clear_scene: false,
program: generated_program,
}
}
}
}
/// Perform the execution of a program.

View File

@ -92,25 +92,6 @@ impl Args {
}
}
#[cfg(test)]
pub(crate) async fn new_test_args() -> Result<Self> {
use std::sync::Arc;
Ok(Self {
args: Vec::new(),
kw_args: Default::default(),
source_range: SourceRange::default(),
ctx: ExecutorContext {
engine: Arc::new(Box::new(crate::engine::conn_mock::EngineConnection::new().await?)),
fs: Arc::new(crate::fs::FileManager::new()),
stdlib: Arc::new(crate::std::StdLib::new()),
settings: Default::default(),
context_type: crate::execution::ContextType::Mock,
},
pipe_value: None,
})
}
/// Get a keyword argument. If not set, returns None.
pub(crate) fn get_kw_arg_opt<'a, T>(&'a self, label: &str) -> Option<T>
where

View File

@ -5,14 +5,13 @@ use derive_docs::stdlib;
use crate::{
errors::KclError,
execution::{ExecState, KclValue},
settings::types::UnitLength,
execution::{ExecState, KclValue, UnitLen},
std::Args,
};
/// Millimeters conversion factor for current projects units.
pub async fn mm(_exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
let result = inner_mm(&args)?;
pub async fn mm(exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
let result = inner_mm(exec_state)?;
Ok(args.make_user_val_from_f64(result))
}
@ -40,20 +39,20 @@ pub async fn mm(_exec_state: &mut ExecState, args: Args) -> Result<KclValue, Kcl
name = "mm",
tags = ["units"],
}]
fn inner_mm(args: &Args) -> Result<f64, KclError> {
match args.ctx.settings.units {
UnitLength::Mm => Ok(1.0),
UnitLength::In => Ok(measurements::Length::from_millimeters(1.0).as_inches()),
UnitLength::Ft => Ok(measurements::Length::from_millimeters(1.0).as_feet()),
UnitLength::M => Ok(measurements::Length::from_millimeters(1.0).as_meters()),
UnitLength::Cm => Ok(measurements::Length::from_millimeters(1.0).as_centimeters()),
UnitLength::Yd => Ok(measurements::Length::from_millimeters(1.0).as_yards()),
fn inner_mm(exec_state: &ExecState) -> Result<f64, KclError> {
match exec_state.mod_local.settings.default_length_units {
UnitLen::Mm => Ok(1.0),
UnitLen::Inches => Ok(measurements::Length::from_millimeters(1.0).as_inches()),
UnitLen::Feet => Ok(measurements::Length::from_millimeters(1.0).as_feet()),
UnitLen::M => Ok(measurements::Length::from_millimeters(1.0).as_meters()),
UnitLen::Cm => Ok(measurements::Length::from_millimeters(1.0).as_centimeters()),
UnitLen::Yards => Ok(measurements::Length::from_millimeters(1.0).as_yards()),
}
}
/// Inches conversion factor for current projects units.
pub async fn inch(_exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
let result = inner_inch(&args)?;
pub async fn inch(exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
let result = inner_inch(exec_state)?;
Ok(args.make_user_val_from_f64(result))
}
@ -81,20 +80,20 @@ pub async fn inch(_exec_state: &mut ExecState, args: Args) -> Result<KclValue, K
name = "inch",
tags = ["units"],
}]
fn inner_inch(args: &Args) -> Result<f64, KclError> {
match args.ctx.settings.units {
UnitLength::Mm => Ok(measurements::Length::from_inches(1.0).as_millimeters()),
UnitLength::In => Ok(1.0),
UnitLength::Ft => Ok(measurements::Length::from_inches(1.0).as_feet()),
UnitLength::M => Ok(measurements::Length::from_inches(1.0).as_meters()),
UnitLength::Cm => Ok(measurements::Length::from_inches(1.0).as_centimeters()),
UnitLength::Yd => Ok(measurements::Length::from_inches(1.0).as_yards()),
fn inner_inch(exec_state: &ExecState) -> Result<f64, KclError> {
match exec_state.mod_local.settings.default_length_units {
UnitLen::Mm => Ok(measurements::Length::from_inches(1.0).as_millimeters()),
UnitLen::Inches => Ok(1.0),
UnitLen::Feet => Ok(measurements::Length::from_inches(1.0).as_feet()),
UnitLen::M => Ok(measurements::Length::from_inches(1.0).as_meters()),
UnitLen::Cm => Ok(measurements::Length::from_inches(1.0).as_centimeters()),
UnitLen::Yards => Ok(measurements::Length::from_inches(1.0).as_yards()),
}
}
/// Feet conversion factor for current projects units.
pub async fn ft(_exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
let result = inner_ft(&args)?;
pub async fn ft(exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
let result = inner_ft(exec_state)?;
Ok(args.make_user_val_from_f64(result))
}
@ -123,20 +122,20 @@ pub async fn ft(_exec_state: &mut ExecState, args: Args) -> Result<KclValue, Kcl
name = "ft",
tags = ["units"],
}]
fn inner_ft(args: &Args) -> Result<f64, KclError> {
match args.ctx.settings.units {
UnitLength::Mm => Ok(measurements::Length::from_feet(1.0).as_millimeters()),
UnitLength::In => Ok(measurements::Length::from_feet(1.0).as_inches()),
UnitLength::Ft => Ok(1.0),
UnitLength::M => Ok(measurements::Length::from_feet(1.0).as_meters()),
UnitLength::Cm => Ok(measurements::Length::from_feet(1.0).as_centimeters()),
UnitLength::Yd => Ok(measurements::Length::from_feet(1.0).as_yards()),
fn inner_ft(exec_state: &ExecState) -> Result<f64, KclError> {
match exec_state.mod_local.settings.default_length_units {
UnitLen::Mm => Ok(measurements::Length::from_feet(1.0).as_millimeters()),
UnitLen::Inches => Ok(measurements::Length::from_feet(1.0).as_inches()),
UnitLen::Feet => Ok(1.0),
UnitLen::M => Ok(measurements::Length::from_feet(1.0).as_meters()),
UnitLen::Cm => Ok(measurements::Length::from_feet(1.0).as_centimeters()),
UnitLen::Yards => Ok(measurements::Length::from_feet(1.0).as_yards()),
}
}
/// Meters conversion factor for current projects units.
pub async fn m(_exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
let result = inner_m(&args)?;
pub async fn m(exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
let result = inner_m(exec_state)?;
Ok(args.make_user_val_from_f64(result))
}
@ -165,20 +164,20 @@ pub async fn m(_exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclE
name = "m",
tags = ["units"],
}]
fn inner_m(args: &Args) -> Result<f64, KclError> {
match args.ctx.settings.units {
UnitLength::Mm => Ok(measurements::Length::from_meters(1.0).as_millimeters()),
UnitLength::In => Ok(measurements::Length::from_meters(1.0).as_inches()),
UnitLength::Ft => Ok(measurements::Length::from_meters(1.0).as_feet()),
UnitLength::M => Ok(1.0),
UnitLength::Cm => Ok(measurements::Length::from_meters(1.0).as_centimeters()),
UnitLength::Yd => Ok(measurements::Length::from_meters(1.0).as_yards()),
fn inner_m(exec_state: &ExecState) -> Result<f64, KclError> {
match exec_state.mod_local.settings.default_length_units {
UnitLen::Mm => Ok(measurements::Length::from_meters(1.0).as_millimeters()),
UnitLen::Inches => Ok(measurements::Length::from_meters(1.0).as_inches()),
UnitLen::Feet => Ok(measurements::Length::from_meters(1.0).as_feet()),
UnitLen::M => Ok(1.0),
UnitLen::Cm => Ok(measurements::Length::from_meters(1.0).as_centimeters()),
UnitLen::Yards => Ok(measurements::Length::from_meters(1.0).as_yards()),
}
}
/// Centimeters conversion factor for current projects units.
pub async fn cm(_exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
let result = inner_cm(&args)?;
pub async fn cm(exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
let result = inner_cm(exec_state)?;
Ok(args.make_user_val_from_f64(result))
}
@ -207,20 +206,20 @@ pub async fn cm(_exec_state: &mut ExecState, args: Args) -> Result<KclValue, Kcl
name = "cm",
tags = ["units"],
}]
fn inner_cm(args: &Args) -> Result<f64, KclError> {
match args.ctx.settings.units {
UnitLength::Mm => Ok(measurements::Length::from_centimeters(1.0).as_millimeters()),
UnitLength::In => Ok(measurements::Length::from_centimeters(1.0).as_inches()),
UnitLength::Ft => Ok(measurements::Length::from_centimeters(1.0).as_feet()),
UnitLength::M => Ok(measurements::Length::from_centimeters(1.0).as_meters()),
UnitLength::Cm => Ok(1.0),
UnitLength::Yd => Ok(measurements::Length::from_centimeters(1.0).as_yards()),
fn inner_cm(exec_state: &ExecState) -> Result<f64, KclError> {
match exec_state.mod_local.settings.default_length_units {
UnitLen::Mm => Ok(measurements::Length::from_centimeters(1.0).as_millimeters()),
UnitLen::Inches => Ok(measurements::Length::from_centimeters(1.0).as_inches()),
UnitLen::Feet => Ok(measurements::Length::from_centimeters(1.0).as_feet()),
UnitLen::M => Ok(measurements::Length::from_centimeters(1.0).as_meters()),
UnitLen::Cm => Ok(1.0),
UnitLen::Yards => Ok(measurements::Length::from_centimeters(1.0).as_yards()),
}
}
/// Yards conversion factor for current projects units.
pub async fn yd(_exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
let result = inner_yd(&args)?;
pub async fn yd(exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
let result = inner_yd(exec_state)?;
Ok(args.make_user_val_from_f64(result))
}
@ -249,14 +248,14 @@ pub async fn yd(_exec_state: &mut ExecState, args: Args) -> Result<KclValue, Kcl
name = "yd",
tags = ["units"],
}]
fn inner_yd(args: &Args) -> Result<f64, KclError> {
match args.ctx.settings.units {
UnitLength::Mm => Ok(measurements::Length::from_yards(1.0).as_millimeters()),
UnitLength::In => Ok(measurements::Length::from_yards(1.0).as_inches()),
UnitLength::Ft => Ok(measurements::Length::from_yards(1.0).as_feet()),
UnitLength::M => Ok(measurements::Length::from_yards(1.0).as_meters()),
UnitLength::Cm => Ok(measurements::Length::from_yards(1.0).as_centimeters()),
UnitLength::Yd => Ok(1.0),
fn inner_yd(exec_state: &ExecState) -> Result<f64, KclError> {
match exec_state.mod_local.settings.default_length_units {
UnitLen::Mm => Ok(measurements::Length::from_yards(1.0).as_millimeters()),
UnitLen::Inches => Ok(measurements::Length::from_yards(1.0).as_inches()),
UnitLen::Feet => Ok(measurements::Length::from_yards(1.0).as_feet()),
UnitLen::M => Ok(measurements::Length::from_yards(1.0).as_meters()),
UnitLen::Cm => Ok(measurements::Length::from_yards(1.0).as_centimeters()),
UnitLen::Yards => Ok(1.0),
}
}
@ -266,75 +265,82 @@ mod tests {
use super::*;
fn exec_state_with_len_units(units: UnitLen) -> ExecState {
ExecState {
mod_local: crate::execution::ModuleState {
settings: crate::execution::MetaSettings {
default_length_units: units,
..Default::default()
},
..Default::default()
},
..Default::default()
}
}
#[tokio::test(flavor = "multi_thread")]
async fn test_units_inner_mm() {
let mut args = Args::new_test_args().await.unwrap();
args.ctx.settings.units = UnitLength::Mm;
let result = inner_mm(&args).unwrap();
let exec_state = exec_state_with_len_units(UnitLen::Mm);
let result = inner_mm(&exec_state).unwrap();
assert_eq!(result, 1.0);
args.ctx.settings.units = UnitLength::In;
let result = inner_mm(&args).unwrap();
let exec_state = exec_state_with_len_units(UnitLen::Inches);
let result = inner_mm(&exec_state).unwrap();
assert_eq!(result, 1.0 / 25.4);
}
#[tokio::test(flavor = "multi_thread")]
async fn test_units_inner_inch() {
let mut args = Args::new_test_args().await.unwrap();
args.ctx.settings.units = UnitLength::In;
let result = inner_inch(&args).unwrap();
let exec_state = exec_state_with_len_units(UnitLen::Inches);
let result = inner_inch(&exec_state).unwrap();
assert_eq!(result, 1.0);
args.ctx.settings.units = UnitLength::Mm;
let result = inner_inch(&args).unwrap();
let exec_state = exec_state_with_len_units(UnitLen::Mm);
let result = inner_inch(&exec_state).unwrap();
assert_eq!(result, 25.4);
}
#[tokio::test(flavor = "multi_thread")]
async fn test_units_inner_ft() {
let mut args = Args::new_test_args().await.unwrap();
args.ctx.settings.units = UnitLength::Ft;
let result = inner_ft(&args).unwrap();
let exec_state = exec_state_with_len_units(UnitLen::Feet);
let result = inner_ft(&exec_state).unwrap();
assert_eq!(result, 1.0);
args.ctx.settings.units = UnitLength::Mm;
let result = inner_ft(&args).unwrap();
let exec_state = exec_state_with_len_units(UnitLen::Mm);
let result = inner_ft(&exec_state).unwrap();
assert_eq!(result, 304.8);
}
#[tokio::test(flavor = "multi_thread")]
async fn test_units_inner_m() {
let mut args = Args::new_test_args().await.unwrap();
args.ctx.settings.units = UnitLength::M;
let result = inner_m(&args).unwrap();
let exec_state = exec_state_with_len_units(UnitLen::M);
let result = inner_m(&exec_state).unwrap();
assert_eq!(result, 1.0);
args.ctx.settings.units = UnitLength::Mm;
let result = inner_m(&args).unwrap();
let exec_state = exec_state_with_len_units(UnitLen::Mm);
let result = inner_m(&exec_state).unwrap();
assert_eq!(result, 1000.0);
}
#[tokio::test(flavor = "multi_thread")]
async fn test_units_inner_cm() {
let mut args = Args::new_test_args().await.unwrap();
args.ctx.settings.units = UnitLength::Cm;
let result = inner_cm(&args).unwrap();
let exec_state = exec_state_with_len_units(UnitLen::Cm);
let result = inner_cm(&exec_state).unwrap();
assert_eq!(result, 1.0);
args.ctx.settings.units = UnitLength::Mm;
let result = inner_cm(&args).unwrap();
let exec_state = exec_state_with_len_units(UnitLen::Mm);
let result = inner_cm(&exec_state).unwrap();
assert_eq!(result, 10.0);
}
#[tokio::test(flavor = "multi_thread")]
async fn test_units_inner_yd() {
let mut args = Args::new_test_args().await.unwrap();
args.ctx.settings.units = UnitLength::Yd;
let result = inner_yd(&args).unwrap();
let exec_state = exec_state_with_len_units(UnitLen::Yards);
let result = inner_yd(&exec_state).unwrap();
assert_eq!(result, 1.0);
args.ctx.settings.units = UnitLength::Mm;
let result = inner_yd(&args).unwrap();
let exec_state = exec_state_with_len_units(UnitLen::Mm);
let result = inner_yd(&exec_state).unwrap();
assert_eq!(result, 914.4);
}
}

View File

@ -2422,6 +2422,11 @@
dependencies:
"@types/ms" "*"
"@types/diff@^6.0.0":
version "6.0.0"
resolved "https://registry.yarnpkg.com/@types/diff/-/diff-6.0.0.tgz#031f27cf57564f3cce825f38fb19fdd4349ad07a"
integrity sha512-dhVCYGv3ZSbzmQaBSagrv1WJ6rXCdkyTcDyoNu1MD8JohI7pR7k8wdZEm+mvdxRKXyHVwckFzWU1vJc+Z29MlA==
"@types/electron@^1.6.10":
version "1.6.10"
resolved "https://registry.yarnpkg.com/@types/electron/-/electron-1.6.10.tgz#7e87888ed3888767cca68e92772c2c8ea46bc873"
@ -4247,6 +4252,11 @@ diff@^4.0.1:
resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.2.tgz#60f3aecb89d5fae520c11aa19efc2bb982aade7d"
integrity sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==
diff@^7.0.0:
version "7.0.0"
resolved "https://registry.yarnpkg.com/diff/-/diff-7.0.0.tgz#3fb34d387cd76d803f6eebea67b921dab0182a9a"
integrity sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==
dir-compare@^3.0.0:
version "3.3.0"
resolved "https://registry.yarnpkg.com/dir-compare/-/dir-compare-3.3.0.tgz#2c749f973b5c4b5d087f11edaae730db31788416"
@ -8533,7 +8543,16 @@ string-natural-compare@^3.0.1:
resolved "https://registry.yarnpkg.com/string-natural-compare/-/string-natural-compare-3.0.1.tgz#7a42d58474454963759e8e8b7ae63d71c1e7fdf4"
integrity sha512-n3sPwynL1nwKi3WJ6AIsClwBMa0zTi54fn2oLU6ndfTSIO05xaznjSf15PcBZU6FNWbmN5Q6cxT4V5hGvB4taw==
"string-width-cjs@npm:string-width@^4.2.0", "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
"string-width-cjs@npm:string-width@^4.2.0":
version "4.2.3"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
dependencies:
emoji-regex "^8.0.0"
is-fullwidth-code-point "^3.0.0"
strip-ansi "^6.0.1"
"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
version "4.2.3"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
@ -8627,7 +8646,14 @@ string_decoder@~1.1.1:
dependencies:
safe-buffer "~5.1.0"
"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1:
"strip-ansi-cjs@npm:strip-ansi@^6.0.1":
version "6.0.1"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
dependencies:
ansi-regex "^5.0.1"
strip-ansi@^6.0.0, strip-ansi@^6.0.1:
version "6.0.1"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
@ -9484,7 +9510,16 @@ word-wrap@^1.2.5:
resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34"
integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0:
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0":
version "7.0.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
dependencies:
ansi-styles "^4.0.0"
string-width "^4.1.0"
strip-ansi "^6.0.0"
wrap-ansi@^7.0.0:
version "7.0.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==