Assemblies: Set translate and rotate via point-and-click (#6167)

* WIP: Add point-and-click Import for geometry
Will eventually fix #6120
Right now the whole loop is there but the codemod doesn't work yet

* Better pathToNOde, log on non-working cm dispatch call

* Add workaround to updateModelingState not working

* Back to updateModelingState with a skip flag

* Better todo

* Change working from Import to Insert, cleanups

* Sister command in kclCommands to populate file options

* Improve path selector

* Unsure: move importAstMod to kclCommands onSubmit 😶

* Add e2e test

* Clean up for review

* Add native file menu entry and test

* No await yo lint said so

* WIP: UX improvements around foreign file imports
Fixes #6152

* WIP: Set translate and rotate via point-and-click on imports. Boilerplate code
Will eventually close #6020

* Full working loop of rotate and translate pipe mutation, including edits, only on module imports. VERY VERBOSE

* Add first e2e test for set transform. Bunch of caveats listed as TODOs

* @lrev-Dev's suggestion to remove a comment

Co-authored-by: Kurt Hutten <k.hutten@protonmail.ch>

* Update to scene.settled(cmdBar)

* Add partNNN default name for alias

* Lint

* Lint

* Fix unit tests

* Add sad path insert test
Thanks @Irev-Dev for the suggestion

* Add step insert test

* Lint

* Add test for second foreign import thru file tree click

* WIP: Add point-and-click Load to copy files from outside the project into the project
Towards #6210

* Move Insert button to modeling toolbar, update menus and toolbars

* Add default value for local name alias

* Aligning tests

* Fix tests

* Add padding for filenames starting with a digit

* Lint

* Lint

* Update snapshots

* Merge branch 'main' into pierremtb/issue6210-Add-point-and-click-Load-to-copy-files-from-outside-the-project-into-the-project

* Add disabled transform subbutton

* Allow start of Transform flow from toolbar with selection

* Merge kcl-samples and local disk load into one 'Load external model' command

* Fix em tests

* Fix test

* Add test for file pick import, better input

* Fix non .kcl loading

* Lint

* Update snapshots

* Fix issue leading to test failure

* Fix clone test

* Add note

* Fix nested clone issue

* Clean up for review

* Add valueSummary for path

* Fix test after path change

* Clean up for review

* Support much wider range for transform

* Set display names

* Bug fixed itself moment...

* Add test for extrude tranform

* Oops missed a thing

* Clean up selection arg

* More tests incl for variable stuff

* Fix imports

* Add supportsTransform: true on all solids returning operations

* Fix edit flow on variables, add test

* Split transform command into translate and rotate

* Clean up and comment

* Clean up operations.ts

* Add comment

* Improve assemblies test

* Support more things

* Typo

* Fix test after unit change on import

* Last clean up for review

* Fix remaining test

---------

Co-authored-by: Kurt Hutten <k.hutten@protonmail.ch>
This commit is contained in:
Pierre Jacquier
2025-04-17 11:44:31 -04:00
committed by GitHub
parent 056a4d4a22
commit 6f2d127c4f
17 changed files with 1496 additions and 48 deletions

View File

@ -178,6 +178,13 @@ export class CmdBarFixture {
return this.page.getByRole('option', options)
}
/**
* Clicks the Create new variable button for kcl input
*/
createNewVariable = async () => {
await this.page.getByRole('button', { name: 'Create new variable' }).click()
}
/**
* Captures a snapshot of the request sent to the text-to-cad API endpoint
* and saves it to a file named after the current test.

View File

@ -169,6 +169,180 @@ test.describe('Point-and-click assemblies tests', () => {
}
)
test(
`Insert the bracket part into an assembly and transform it`,
{ tag: ['@electron'] },
async ({
context,
page,
homePage,
scene,
editor,
toolbar,
cmdBar,
tronApp,
}) => {
if (!tronApp) {
fail()
}
const midPoint = { x: 500, y: 250 }
const moreToTheRightPoint = { x: 900, y: 250 }
const bgColor: [number, number, number] = [30, 30, 30]
const partColor: [number, number, number] = [100, 100, 100]
const tolerance = 30
const u = await getUtils(page)
const gizmo = page.locator('[aria-label*=gizmo]')
const resetCameraButton = page.getByRole('button', { name: 'Reset view' })
await test.step('Setup parts and expect empty assembly scene', async () => {
const projectName = 'assembly'
await context.folderSetupFn(async (dir) => {
const bracketDir = path.join(dir, projectName)
await fsp.mkdir(bracketDir, { recursive: true })
await Promise.all([
fsp.copyFile(
path.join('public', 'kcl-samples', 'bracket', 'main.kcl'),
path.join(bracketDir, 'bracket.kcl')
),
fsp.writeFile(path.join(bracketDir, 'main.kcl'), ''),
])
})
await page.setBodyDimensions({ width: 1000, height: 500 })
await homePage.openProject(projectName)
await scene.settled(cmdBar)
await toolbar.closePane('code')
})
await test.step('Insert kcl as module', async () => {
await insertPartIntoAssembly(
'bracket.kcl',
'bracket',
toolbar,
cmdBar,
page
)
await toolbar.openPane('code')
await editor.expectEditor.toContain(
`
import "bracket.kcl" as bracket
bracket
`,
{ shouldNormalise: true }
)
await scene.settled(cmdBar)
// Check scene for changes
await toolbar.closePane('code')
await u.doAndWaitForCmd(async () => {
await gizmo.click({ button: 'right' })
await resetCameraButton.click()
}, 'zoom_to_fit')
await toolbar.closePane('debug')
await scene.expectPixelColor(partColor, midPoint, tolerance)
await scene.expectPixelColor(bgColor, moreToTheRightPoint, tolerance)
})
await test.step('Set translate on module', async () => {
await toolbar.openPane('feature-tree')
const op = await toolbar.getFeatureTreeOperation('bracket', 0)
await op.click({ button: 'right' })
await page.getByTestId('context-menu-set-translate').click()
await cmdBar.expectState({
stage: 'arguments',
currentArgKey: 'x',
currentArgValue: '0',
headerArguments: {
X: '',
Y: '',
Z: '',
},
highlightedHeaderArg: 'x',
commandName: 'Translate',
})
await page.keyboard.insertText('5')
await cmdBar.progressCmdBar()
await page.keyboard.insertText('0.1')
await cmdBar.progressCmdBar()
await page.keyboard.insertText('0.2')
await cmdBar.progressCmdBar()
await cmdBar.expectState({
stage: 'review',
headerArguments: {
X: '5',
Y: '0.1',
Z: '0.2',
},
commandName: 'Translate',
})
await cmdBar.progressCmdBar()
await toolbar.closePane('feature-tree')
await toolbar.openPane('code')
await editor.expectEditor.toContain(
`
bracket
|> translate(x = 5, y = 0.1, z = 0.2)
`,
{ shouldNormalise: true }
)
// Expect translated part in the scene
await scene.expectPixelColor(bgColor, midPoint, tolerance)
await scene.expectPixelColor(partColor, moreToTheRightPoint, tolerance)
})
await test.step('Set rotate on module', async () => {
await toolbar.closePane('code')
await toolbar.openPane('feature-tree')
const op = await toolbar.getFeatureTreeOperation('bracket', 0)
await op.click({ button: 'right' })
await page.getByTestId('context-menu-set-rotate').click()
await cmdBar.expectState({
stage: 'arguments',
currentArgKey: 'roll',
currentArgValue: '0',
headerArguments: {
Roll: '',
Pitch: '',
Yaw: '',
},
highlightedHeaderArg: 'roll',
commandName: 'Rotate',
})
await page.keyboard.insertText('0.1')
await cmdBar.progressCmdBar()
await page.keyboard.insertText('0.2')
await cmdBar.progressCmdBar()
await page.keyboard.insertText('0.3')
await cmdBar.progressCmdBar()
await cmdBar.expectState({
stage: 'review',
headerArguments: {
Roll: '0.1',
Pitch: '0.2',
Yaw: '0.3',
},
commandName: 'Rotate',
})
await cmdBar.progressCmdBar()
await toolbar.closePane('feature-tree')
await toolbar.openPane('code')
await editor.expectEditor.toContain(
`
bracket
|> translate(x = 5, y = 0.1, z = 0.2)
|> rotate(roll = 0.1, pitch = 0.2, yaw = 0.3)
`,
{ shouldNormalise: true }
)
// Expect no change in the scene as the rotations are tiny
await scene.expectPixelColor(bgColor, midPoint, tolerance)
await scene.expectPixelColor(partColor, moreToTheRightPoint, tolerance)
})
}
)
test(
`Insert foreign parts into assembly as whole module import`,
{ tag: ['@electron'] },

View File

@ -3835,4 +3835,469 @@ extrude001 = extrude(profile001, length = 100)
)
})
})
const translateExtrudeCases: { variables: boolean }[] = [
{
variables: false,
},
{
variables: true,
},
]
translateExtrudeCases.map(({ variables }) => {
test(`Set translate on extrude through right-click menu (variables: ${variables})`, async ({
context,
page,
homePage,
scene,
editor,
toolbar,
cmdBar,
}) => {
const initialCode = `sketch001 = startSketchOn(XZ)
profile001 = circle(sketch001, center = [0, 0], radius = 1)
extrude001 = extrude(profile001, length = 1)
`
await context.addInitScript((initialCode) => {
localStorage.setItem('persistCode', initialCode)
}, initialCode)
await page.setBodyDimensions({ width: 1000, height: 500 })
await homePage.goToModelingScene()
await scene.settled(cmdBar)
// One dumb hardcoded screen pixel value
const midPoint = { x: 500, y: 250 }
const moreToTheRightPoint = { x: 800, y: 250 }
const bgColor: [number, number, number] = [50, 50, 50]
const partColor: [number, number, number] = [150, 150, 150]
const tolerance = 50
await test.step('Confirm extrude exists with default appearance', async () => {
await toolbar.closePane('code')
await scene.expectPixelColor(partColor, midPoint, tolerance)
await scene.expectPixelColor(bgColor, moreToTheRightPoint, tolerance)
})
await test.step('Set translate through command bar flow', async () => {
await toolbar.openPane('feature-tree')
const op = await toolbar.getFeatureTreeOperation('Extrude', 0)
await op.click({ button: 'right' })
await page.getByTestId('context-menu-set-translate').click()
await cmdBar.expectState({
stage: 'arguments',
currentArgKey: 'x',
currentArgValue: '0',
headerArguments: {
X: '',
Y: '',
Z: '',
},
highlightedHeaderArg: 'x',
commandName: 'Translate',
})
await page.keyboard.insertText('3')
if (variables) {
await cmdBar.createNewVariable()
}
await cmdBar.progressCmdBar()
await page.keyboard.insertText('0.1')
if (variables) {
await cmdBar.createNewVariable()
}
await cmdBar.progressCmdBar()
await page.keyboard.insertText('0.2')
if (variables) {
await cmdBar.createNewVariable()
}
await cmdBar.progressCmdBar()
await cmdBar.expectState({
stage: 'review',
headerArguments: {
X: '3',
Y: '0.1',
Z: '0.2',
},
commandName: 'Translate',
})
await cmdBar.progressCmdBar()
await toolbar.closePane('feature-tree')
})
await test.step('Confirm code and scene have changed', async () => {
await toolbar.openPane('code')
if (variables) {
await editor.expectEditor.toContain(
`
z001 = 0.2
y001 = 0.1
x001 = 3
sketch001 = startSketchOn(XZ)
profile001 = circle(sketch001, center = [0, 0], radius = 1)
extrude001 = extrude(profile001, length = 1)
|> translate(x = x001, y = y001, z = z001)
`,
{ shouldNormalise: true }
)
} else {
await editor.expectEditor.toContain(
`
sketch001 = startSketchOn(XZ)
profile001 = circle(sketch001, center = [0, 0], radius = 1)
extrude001 = extrude(profile001, length = 1)
|> translate(x = 3, y = 0.1, z = 0.2)
`,
{ shouldNormalise: true }
)
}
await scene.expectPixelColor(bgColor, midPoint, tolerance)
await scene.expectPixelColor(partColor, moreToTheRightPoint, tolerance)
})
await test.step('Edit translate', async () => {
await toolbar.openPane('feature-tree')
const op = await toolbar.getFeatureTreeOperation('Extrude', 0)
await op.click({ button: 'right' })
await page.getByTestId('context-menu-set-translate').click()
await cmdBar.expectState({
stage: 'arguments',
currentArgKey: 'z',
currentArgValue: variables ? 'z001' : '0.2',
headerArguments: {
X: '3',
Y: '0.1',
Z: '0.2',
},
highlightedHeaderArg: 'z',
commandName: 'Translate',
})
await page.keyboard.insertText('0.3')
await cmdBar.progressCmdBar()
await cmdBar.expectState({
stage: 'review',
headerArguments: {
X: '3',
Y: '0.1',
Z: '0.3',
},
commandName: 'Translate',
})
await cmdBar.progressCmdBar()
await toolbar.closePane('feature-tree')
await toolbar.openPane('code')
await editor.expectEditor.toContain(`z = 0.3`)
// Expect almost no change in scene
await scene.expectPixelColor(bgColor, midPoint, tolerance)
await scene.expectPixelColor(partColor, moreToTheRightPoint, tolerance)
})
})
})
const rotateExtrudeCases: { variables: boolean }[] = [
{
variables: false,
},
{
variables: true,
},
]
rotateExtrudeCases.map(({ variables }) => {
test(`Set rotate on extrude through right-click menu (variables: ${variables})`, async ({
context,
page,
homePage,
scene,
editor,
toolbar,
cmdBar,
}) => {
const initialCode = `sketch001 = startSketchOn(XZ)
profile001 = circle(sketch001, center = [0, 0], radius = 1)
extrude001 = extrude(profile001, length = 1)
`
await context.addInitScript((initialCode) => {
localStorage.setItem('persistCode', initialCode)
}, initialCode)
await page.setBodyDimensions({ width: 1000, height: 500 })
await homePage.goToModelingScene()
await scene.settled(cmdBar)
await test.step('Set rotate through command bar flow', async () => {
await toolbar.openPane('feature-tree')
const op = await toolbar.getFeatureTreeOperation('Extrude', 0)
await op.click({ button: 'right' })
await page.getByTestId('context-menu-set-rotate').click()
await cmdBar.expectState({
stage: 'arguments',
currentArgKey: 'roll',
currentArgValue: '0',
headerArguments: {
Roll: '',
Pitch: '',
Yaw: '',
},
highlightedHeaderArg: 'roll',
commandName: 'Rotate',
})
await page.keyboard.insertText('1.1')
if (variables) {
await cmdBar.createNewVariable()
}
await cmdBar.progressCmdBar()
await page.keyboard.insertText('1.2')
if (variables) {
await cmdBar.createNewVariable()
}
await cmdBar.progressCmdBar()
await page.keyboard.insertText('1.3')
if (variables) {
await cmdBar.createNewVariable()
}
await cmdBar.progressCmdBar()
await cmdBar.expectState({
stage: 'review',
headerArguments: {
Roll: '1.1',
Pitch: '1.2',
Yaw: '1.3',
},
commandName: 'Rotate',
})
await cmdBar.progressCmdBar()
await toolbar.closePane('feature-tree')
})
await test.step('Confirm code and scene have changed', async () => {
await toolbar.openPane('code')
if (variables) {
await editor.expectEditor.toContain(
`
yaw001 = 1.3
pitch001 = 1.2
roll001 = 1.1
sketch001 = startSketchOn(XZ)
profile001 = circle(sketch001, center = [0, 0], radius = 1)
extrude001 = extrude(profile001, length = 1)
|> rotate(roll = roll001, pitch = pitch001, yaw = yaw001)
`,
{ shouldNormalise: true }
)
} else {
await editor.expectEditor.toContain(
`
sketch001 = startSketchOn(XZ)
profile001 = circle(sketch001, center = [0, 0], radius = 1)
extrude001 = extrude(profile001, length = 1)
|> rotate(roll = 1.1, pitch = 1.2, yaw = 1.3)
`,
{ shouldNormalise: true }
)
}
})
await test.step('Edit rotate', async () => {
await toolbar.openPane('feature-tree')
const op = await toolbar.getFeatureTreeOperation('Extrude', 0)
await op.click({ button: 'right' })
await page.getByTestId('context-menu-set-rotate').click()
await cmdBar.expectState({
stage: 'arguments',
currentArgKey: 'yaw',
currentArgValue: variables ? 'yaw001' : '1.3',
headerArguments: {
Roll: '1.1',
Pitch: '1.2',
Yaw: '1.3',
},
highlightedHeaderArg: 'yaw',
commandName: 'Rotate',
})
await page.keyboard.insertText('13')
await cmdBar.progressCmdBar()
await cmdBar.expectState({
stage: 'review',
headerArguments: {
Roll: '1.1',
Pitch: '1.2',
Yaw: '13',
},
commandName: 'Rotate',
})
await cmdBar.progressCmdBar()
await toolbar.closePane('feature-tree')
await toolbar.openPane('code')
await editor.expectEditor.toContain(`yaw = 13`)
})
})
})
test(`Set translate and rotate on extrude through selection`, async ({
context,
page,
homePage,
scene,
editor,
toolbar,
cmdBar,
}) => {
const initialCode = `sketch001 = startSketchOn(XZ)
profile001 = circle(sketch001, center = [0, 0], radius = 1)
extrude001 = extrude(profile001, length = 1)
`
await context.addInitScript((initialCode) => {
localStorage.setItem('persistCode', initialCode)
}, initialCode)
await page.setBodyDimensions({ width: 1000, height: 500 })
await homePage.goToModelingScene()
await scene.settled(cmdBar)
// One dumb hardcoded screen pixel value
const midPoint = { x: 500, y: 250 }
const moreToTheRightPoint = { x: 800, y: 250 }
const bgColor: [number, number, number] = [50, 50, 50]
const partColor: [number, number, number] = [150, 150, 150]
const tolerance = 50
const [clickMidPoint] = scene.makeMouseHelpers(midPoint.x, midPoint.y)
const [clickMoreToTheRightPoint] = scene.makeMouseHelpers(
moreToTheRightPoint.x,
moreToTheRightPoint.y
)
await test.step('Confirm extrude exists with default appearance', async () => {
await toolbar.closePane('code')
await scene.expectPixelColor(partColor, midPoint, tolerance)
await scene.expectPixelColor(bgColor, moreToTheRightPoint, tolerance)
})
await test.step('Set translate through command bar flow', async () => {
await cmdBar.openCmdBar()
await cmdBar.chooseCommand('Translate')
await cmdBar.expectState({
stage: 'arguments',
currentArgKey: 'selection',
currentArgValue: '',
headerArguments: {
Selection: '',
X: '',
Y: '',
Z: '',
},
highlightedHeaderArg: 'selection',
commandName: 'Translate',
})
await clickMidPoint()
await cmdBar.progressCmdBar()
await cmdBar.expectState({
stage: 'arguments',
currentArgKey: 'x',
currentArgValue: '0',
headerArguments: {
Selection: '1 path',
X: '',
Y: '',
Z: '',
},
highlightedHeaderArg: 'x',
commandName: 'Translate',
})
await page.keyboard.insertText('2')
await cmdBar.progressCmdBar()
await cmdBar.progressCmdBar()
await cmdBar.progressCmdBar()
await cmdBar.expectState({
stage: 'review',
headerArguments: {
Selection: '1 path',
X: '2',
Y: '0',
Z: '0',
},
commandName: 'Translate',
})
await cmdBar.progressCmdBar()
})
await test.step('Confirm code and scene have changed', async () => {
await toolbar.openPane('code')
await editor.expectEditor.toContain(
`
sketch001 = startSketchOn(XZ)
profile001 = circle(sketch001, center = [0, 0], radius = 1)
extrude001 = extrude(profile001, length = 1)
|> translate(x = 2, y = 0, z = 0)
`,
{ shouldNormalise: true }
)
await scene.expectPixelColor(bgColor, midPoint, tolerance)
await scene.expectPixelColor(partColor, moreToTheRightPoint, tolerance)
})
await test.step('Set rotate through command bar flow', async () => {
// clear selection
await clickMidPoint()
await cmdBar.openCmdBar()
await cmdBar.chooseCommand('Rotate')
await cmdBar.expectState({
stage: 'arguments',
currentArgKey: 'selection',
currentArgValue: '',
headerArguments: {
Selection: '',
Roll: '',
Pitch: '',
Yaw: '',
},
highlightedHeaderArg: 'selection',
commandName: 'Rotate',
})
await clickMoreToTheRightPoint()
await cmdBar.progressCmdBar()
await cmdBar.expectState({
stage: 'arguments',
currentArgKey: 'roll',
currentArgValue: '0',
headerArguments: {
Selection: '1 path',
Roll: '',
Pitch: '',
Yaw: '',
},
highlightedHeaderArg: 'roll',
commandName: 'Rotate',
})
await page.keyboard.insertText('0.1')
await cmdBar.progressCmdBar()
await page.keyboard.insertText('0.2')
await cmdBar.progressCmdBar()
await page.keyboard.insertText('0.3')
await cmdBar.progressCmdBar()
await cmdBar.expectState({
stage: 'review',
headerArguments: {
Selection: '1 path',
Roll: '0.1',
Pitch: '0.2',
Yaw: '0.3',
},
commandName: 'Rotate',
})
await cmdBar.progressCmdBar()
})
await test.step('Confirm code has changed', async () => {
await toolbar.openPane('code')
await editor.expectEditor.toContain(
`
sketch001 = startSketchOn(XZ)
profile001 = circle(sketch001, center = [0, 0], radius = 1)
extrude001 = extrude(profile001, length = 1)
|> translate(x = 2, y = 0, z = 0)
|> rotate(roll = 0.1, pitch = 0.2, yaw = 0.3)
`,
{ shouldNormalise: true }
)
// No change here since the angles are super small
await scene.expectPixelColor(bgColor, midPoint, tolerance)
await scene.expectPixelColor(partColor, moreToTheRightPoint, tolerance)
})
})
})

View File

@ -117,7 +117,6 @@ export default function CommandBarSelectionMixedInput({
Continue without selection
</button>
)}
<span data-testid="cmd-bar-arg-name" className="sr-only">
{arg.name}
</span>

View File

@ -355,6 +355,38 @@ const OperationItem = (props: {
}
}
function enterTranslateFlow() {
if (
props.item.type === 'StdLibCall' ||
props.item.type === 'KclStdLibCall' ||
props.item.type === 'GroupBegin'
) {
props.send({
type: 'enterTranslateFlow',
data: {
targetSourceRange: sourceRangeFromRust(props.item.sourceRange),
currentOperation: props.item,
},
})
}
}
function enterRotateFlow() {
if (
props.item.type === 'StdLibCall' ||
props.item.type === 'KclStdLibCall' ||
props.item.type === 'GroupBegin'
) {
props.send({
type: 'enterRotateFlow',
data: {
targetSourceRange: sourceRangeFromRust(props.item.sourceRange),
currentOperation: props.item,
},
})
}
}
function deleteOperation() {
if (
props.item.type === 'StdLibCall' ||
@ -418,13 +450,6 @@ const OperationItem = (props: {
...(props.item.type === 'StdLibCall' ||
props.item.type === 'KclStdLibCall'
? [
<ContextMenuItem
disabled={!stdLibMap[props.item.name]?.supportsAppearance}
onClick={enterAppearanceFlow}
data-testid="context-menu-set-appearance"
>
Set appearance
</ContextMenuItem>,
<ContextMenuItem
disabled={!stdLibMap[props.item.name]?.prepareToEdit}
onClick={enterEditFlow}
@ -432,8 +457,39 @@ const OperationItem = (props: {
>
Edit
</ContextMenuItem>,
<ContextMenuItem
disabled={!stdLibMap[props.item.name]?.supportsAppearance}
onClick={enterAppearanceFlow}
data-testid="context-menu-set-appearance"
>
Set appearance
</ContextMenuItem>,
]
: []),
...(props.item.type === 'StdLibCall' ||
props.item.type === 'KclStdLibCall' ||
props.item.type === 'GroupBegin'
? [
<ContextMenuItem
onClick={enterTranslateFlow}
data-testid="context-menu-set-translate"
disabled={
props.item.type !== 'GroupBegin' &&
!stdLibMap[props.item.name]?.supportsTransform
}
>
Set translate
</ContextMenuItem>,
<ContextMenuItem
onClick={enterRotateFlow}
data-testid="context-menu-set-rotate"
disabled={
props.item.type !== 'GroupBegin' &&
!stdLibMap[props.item.name]?.supportsTransform
}
>
Set rotate
</ContextMenuItem>,
<ContextMenuItem
onClick={deleteOperation}
hotkey="Delete"
@ -441,6 +497,8 @@ const OperationItem = (props: {
>
Delete
</ContextMenuItem>,
]
: []),
],
[props.item, props.send]
)

View File

@ -54,23 +54,18 @@ export async function updateModelingState(
},
options?: {
focusPath?: Array<PathToNode>
skipUpdateAst?: boolean
}
): Promise<void> {
let updatedAst: {
newAst: Node<Program>
selections?: Selections
} = { newAst: ast }
// TODO: understand why this skip flag is needed for insertAstMod.
// It's unclear why we double casts the AST
if (!options?.skipUpdateAst) {
// Step 1: Update AST without executing (prepare selections)
updatedAst = await dependencies.kclManager.updateAst(
ast,
false, // Execution handled separately for error resilience
options
)
}
// Step 2: Update the code editor and save file
await dependencies.codeManager.updateEditorWithAstAndWriteToFile(

View File

@ -81,7 +81,10 @@ import type {
VariableMap,
} from '@src/lang/wasm'
import { isPathToNodeNumber, parse } from '@src/lang/wasm'
import type { KclExpressionWithVariable } from '@src/lib/commandTypes'
import type {
KclCommandValue,
KclExpressionWithVariable,
} from '@src/lib/commandTypes'
import { KCL_DEFAULT_CONSTANT_PREFIXES } from '@src/lib/constants'
import type { DefaultPlaneStr } from '@src/lib/planes'
import type { Selection } from '@src/lib/selections'
@ -1828,3 +1831,20 @@ export function createNodeFromExprSnippet(
if (!node) return new Error('No node found')
return node
}
export function insertVariableAndOffsetPathToNode(
variable: KclCommandValue,
modifiedAst: Node<Program>,
pathToNode: PathToNode
) {
if ('variableName' in variable && variable.variableName) {
modifiedAst.body.splice(
variable.insertIndex,
0,
variable.variableDeclarationAst
)
if (typeof pathToNode[1][0] === 'number') {
pathToNode[1][0]++
}
}
}

View File

@ -0,0 +1,142 @@
import type { Node } from '@rust/kcl-lib/bindings/Node'
import {
createCallExpressionStdLibKw,
createLabeledArg,
createPipeExpression,
} from '@src/lang/create'
import { getNodeFromPath } from '@src/lang/queryAst'
import type {
CallExpressionKw,
Expr,
ExpressionStatement,
PathToNode,
PipeExpression,
Program,
VariableDeclarator,
} from '@src/lang/wasm'
import { err } from '@src/lib/trap'
export function setTranslate({
modifiedAst,
pathToNode,
x,
y,
z,
}: {
modifiedAst: Node<Program>
pathToNode: PathToNode
x: Expr
y: Expr
z: Expr
}): Error | { modifiedAst: Node<Program>; pathToNode: PathToNode } {
const noPercentSign = null
const call = createCallExpressionStdLibKw('translate', noPercentSign, [
createLabeledArg('x', x),
createLabeledArg('y', y),
createLabeledArg('z', z),
])
const potentialPipe = getNodeFromPath<PipeExpression>(
modifiedAst,
pathToNode,
['PipeExpression']
)
if (!err(potentialPipe) && potentialPipe.node.type === 'PipeExpression') {
setTransformInPipe(potentialPipe.node, call)
} else {
const error = createPipeWithTransform(modifiedAst, pathToNode, call)
if (err(error)) {
return error
}
}
return {
modifiedAst,
pathToNode, // TODO: check if this should be updated
}
}
export function setRotate({
modifiedAst,
pathToNode,
roll,
pitch,
yaw,
}: {
modifiedAst: Node<Program>
pathToNode: PathToNode
roll: Expr
pitch: Expr
yaw: Expr
}): Error | { modifiedAst: Node<Program>; pathToNode: PathToNode } {
const noPercentSign = null
const call = createCallExpressionStdLibKw('rotate', noPercentSign, [
createLabeledArg('roll', roll),
createLabeledArg('pitch', pitch),
createLabeledArg('yaw', yaw),
])
const potentialPipe = getNodeFromPath<PipeExpression>(
modifiedAst,
pathToNode,
['PipeExpression']
)
if (!err(potentialPipe) && potentialPipe.node.type === 'PipeExpression') {
setTransformInPipe(potentialPipe.node, call)
} else {
const error = createPipeWithTransform(modifiedAst, pathToNode, call)
if (err(error)) {
return error
}
}
return {
modifiedAst,
pathToNode, // TODO: check if this should be updated
}
}
function setTransformInPipe(
expression: PipeExpression,
call: Node<CallExpressionKw>
) {
const existingIndex = expression.body.findIndex(
(v) =>
v.type === 'CallExpressionKw' &&
v.callee.type === 'Name' &&
v.callee.name.name === call.callee.name.name
)
if (existingIndex > -1) {
expression.body[existingIndex] = call
} else {
expression.body.push(call)
}
}
function createPipeWithTransform(
modifiedAst: Node<Program>,
pathToNode: PathToNode,
call: Node<CallExpressionKw>
) {
const existingCall = getNodeFromPath<
VariableDeclarator | ExpressionStatement
>(modifiedAst, pathToNode, ['VariableDeclarator', 'ExpressionStatement'])
if (err(existingCall)) {
return new Error('Unsupported operation type.')
}
if (existingCall.node.type === 'ExpressionStatement') {
existingCall.node.expression = createPipeExpression([
existingCall.node.expression,
call,
])
} else if (existingCall.node.type === 'VariableDeclarator') {
existingCall.node.init = createPipeExpression([
existingCall.node.init,
call,
])
} else {
return new Error('Unsupported operation type.')
}
}

View File

@ -51,6 +51,7 @@ import { Reason, err } from '@src/lib/trap'
import { getAngle, isArray } from '@src/lib/utils'
import { ARG_INDEX_FIELD, LABELED_ARG_FIELD } from '@src/lang/queryAstConstants'
import type { KclCommandValue } from '@src/lib/commandTypes'
/**
* Retrieves a node from a given path within a Program node structure, optionally stopping at a specified node type.
@ -1052,3 +1053,9 @@ export function updatePathToNodesAfterEdit(
newPath[1][0] = newIndex // Update the body index
return newPath
}
export const valueOrVariable = (variable: KclCommandValue) => {
return 'variableName' in variable
? variable.variableIdentifierAst
: variable.valueAst
}

View File

@ -22,7 +22,11 @@ import type {
KclCommandValue,
StateMachineCommandSetConfig,
} from '@src/lib/commandTypes'
import { KCL_DEFAULT_DEGREE, KCL_DEFAULT_LENGTH } from '@src/lib/constants'
import {
KCL_DEFAULT_DEGREE,
KCL_DEFAULT_LENGTH,
KCL_DEFAULT_TRANSFORM,
} from '@src/lib/constants'
import type { components } from '@src/lib/machine-api'
import type { Selections } from '@src/lib/selections'
import { codeManager, kclManager } from '@src/lib/singletons'
@ -163,6 +167,20 @@ export type ModelingCommandSchema = {
nodeToEdit?: PathToNode
color: string
}
Translate: {
nodeToEdit?: PathToNode
selection: Selections
x: KclCommandValue
y: KclCommandValue
z: KclCommandValue
}
Rotate: {
nodeToEdit?: PathToNode
selection: Selections
roll: KclCommandValue
pitch: KclCommandValue
yaw: KclCommandValue
}
'Boolean Subtract': {
target: Selections
tool: Selections
@ -1024,6 +1042,88 @@ export const modelingMachineCommandConfig: StateMachineCommandSetConfig<
// Add more fields
},
},
Translate: {
description: 'Set translation on solid or sketch.',
icon: 'dimension', // TODO: likely not the best icon
needsReview: true,
hide: DEV || IS_NIGHTLY_OR_DEBUG ? undefined : 'both',
args: {
nodeToEdit: {
description:
'Path to the node in the AST to edit. Never shown to the user.',
skip: true,
inputType: 'text',
required: false,
hidden: true,
},
selection: {
// selectionMixed allows for feature tree selection of module imports
inputType: 'selectionMixed',
multiple: false,
required: true,
skip: true,
selectionTypes: ['path'],
selectionFilter: ['object'],
hidden: (context) => Boolean(context.argumentsToSubmit.nodeToEdit),
},
x: {
inputType: 'kcl',
defaultValue: KCL_DEFAULT_TRANSFORM,
required: true,
},
y: {
inputType: 'kcl',
defaultValue: KCL_DEFAULT_TRANSFORM,
required: true,
},
z: {
inputType: 'kcl',
defaultValue: KCL_DEFAULT_TRANSFORM,
required: true,
},
},
},
Rotate: {
description: 'Set rotation on solid or sketch.',
icon: 'angle', // TODO: likely not the best icon
needsReview: true,
hide: DEV || IS_NIGHTLY_OR_DEBUG ? undefined : 'both',
args: {
nodeToEdit: {
description:
'Path to the node in the AST to edit. Never shown to the user.',
skip: true,
inputType: 'text',
required: false,
hidden: true,
},
selection: {
// selectionMixed allows for feature tree selection of module imports
inputType: 'selectionMixed',
multiple: false,
required: true,
skip: true,
selectionTypes: ['path'],
selectionFilter: ['object'],
hidden: (context) => Boolean(context.argumentsToSubmit.nodeToEdit),
},
roll: {
inputType: 'kcl',
defaultValue: KCL_DEFAULT_TRANSFORM,
required: true,
},
pitch: {
inputType: 'kcl',
defaultValue: KCL_DEFAULT_TRANSFORM,
required: true,
},
yaw: {
inputType: 'kcl',
defaultValue: KCL_DEFAULT_TRANSFORM,
required: true,
},
},
},
}
modelingMachineCommandConfig

View File

@ -55,6 +55,9 @@ export const KCL_DEFAULT_CONSTANT_PREFIXES = {
/** The default KCL length expression */
export const KCL_DEFAULT_LENGTH = `5`
/** The default KCL transform arg value that means no transform */
export const KCL_DEFAULT_TRANSFORM = `0`
/** The default KCL degree expression */
export const KCL_DEFAULT_DEGREE = `360`

View File

@ -153,7 +153,6 @@ export function kclCommands(commandProps: KclCommandConfig): Command[] {
EXECUTION_TYPE_REAL,
{ kclManager, editorManager, codeManager },
{
skipUpdateAst: true,
focusPath: [pathToImportNode, pathToInsertNode],
}
).catch(reportRejection)

View File

@ -1,6 +1,6 @@
import { executeAstMock } from '@src/lang/langHelpers'
import { parse, resultIsOk } from '@src/lang/wasm'
import type { KclExpression } from '@src/lib/commandTypes'
import { type CallExpressionKw, parse, resultIsOk } from '@src/lang/wasm'
import type { KclCommandValue, KclExpression } from '@src/lib/commandTypes'
import { rustContext } from '@src/lib/singletons'
import { err } from '@src/lib/trap'
@ -54,3 +54,23 @@ export async function stringToKclExpression(value: string) {
valueText: value,
} satisfies KclExpression
}
export async function retrieveArgFromPipedCallExpression(
callExpression: CallExpressionKw,
name: string
): Promise<KclCommandValue | undefined> {
const arg = callExpression.arguments.find(
(a) => a.label.type === 'Identifier' && a.label.name === name
)
if (
arg?.type === 'LabeledArg' &&
(arg.arg.type === 'Name' || arg.arg.type === 'Literal')
) {
const value = arg.arg.type === 'Name' ? arg.arg.name.name : arg.arg.raw
const result = await stringToKclExpression(value)
if (!(err(result) || 'errors' in result)) {
return result
}
}
return undefined
}

View File

@ -1,6 +1,7 @@
import type { OpKclValue, Operation } from '@rust/kcl-lib/bindings/Operation'
import type { CustomIconName } from '@src/components/CustomIcon'
import { getNodeFromPath } from '@src/lang/queryAst'
import { getNodePathFromSourceRange } from '@src/lang/queryAstNodePathUtils'
import type { Artifact } from '@src/lang/std/artifactGraph'
import {
@ -10,13 +11,16 @@ import {
getSweepEdgeCodeRef,
getWallCodeRef,
} from '@src/lang/std/artifactGraph'
import { sourceRangeFromRust } from '@src/lang/wasm'
import { type PipeExpression, sourceRangeFromRust } from '@src/lang/wasm'
import type {
HelixModes,
ModelingCommandSchema,
} from '@src/lib/commandBarConfigs/modelingCommandConfig'
import type { KclExpression } from '@src/lib/commandTypes'
import { stringToKclExpression } from '@src/lib/kclHelpers'
import {
stringToKclExpression,
retrieveArgFromPipedCallExpression,
} from '@src/lib/kclHelpers'
import { isDefaultPlaneStr } from '@src/lib/planes'
import type { Selection, Selections } from '@src/lib/selections'
import { codeManager, kclManager, rustContext } from '@src/lib/singletons'
@ -46,6 +50,7 @@ interface StdLibCallInfo {
| PrepareToEditCallback
| PrepareToEditFailurePayload
supportsAppearance?: boolean
supportsTransform?: boolean
}
/**
@ -1008,6 +1013,7 @@ export const stdLibMap: Record<string, StdLibCallInfo> = {
icon: 'extrude',
prepareToEdit: prepareToEditExtrude,
supportsAppearance: true,
supportsTransform: true,
},
fillet: {
label: 'Fillet',
@ -1026,19 +1032,26 @@ export const stdLibMap: Record<string, StdLibCallInfo> = {
hollow: {
label: 'Hollow',
icon: 'hollow',
supportsAppearance: true,
supportsTransform: true,
},
import: {
label: 'Import',
icon: 'import',
supportsAppearance: true,
supportsTransform: true,
},
intersect: {
label: 'Intersect',
icon: 'booleanIntersect',
supportsAppearance: true,
supportsTransform: true,
},
loft: {
label: 'Loft',
icon: 'loft',
supportsAppearance: true,
supportsTransform: true,
},
offsetPlane: {
label: 'Offset Plane',
@ -1052,6 +1065,8 @@ export const stdLibMap: Record<string, StdLibCallInfo> = {
patternCircular3d: {
label: 'Circular Pattern',
icon: 'patternCircular3d',
supportsAppearance: true,
supportsTransform: true,
},
patternLinear2d: {
label: 'Linear Pattern',
@ -1060,18 +1075,22 @@ export const stdLibMap: Record<string, StdLibCallInfo> = {
patternLinear3d: {
label: 'Linear Pattern',
icon: 'patternLinear3d',
supportsAppearance: true,
supportsTransform: true,
},
revolve: {
label: 'Revolve',
icon: 'revolve',
prepareToEdit: prepareToEditRevolve,
supportsAppearance: true,
supportsTransform: true,
},
shell: {
label: 'Shell',
icon: 'shell',
prepareToEdit: prepareToEditShell,
supportsAppearance: true,
supportsTransform: true,
},
startSketchOn: {
label: 'Sketch',
@ -1284,7 +1303,6 @@ export async function enterEditFlow({
export async function enterAppearanceFlow({
operation,
artifact,
}: EnterEditFlowProps): Promise<Error | CommandBarMachineEvent> {
if (operation.type !== 'StdLibCall' && operation.type !== 'KclStdLibCall') {
return new Error(
@ -1300,7 +1318,6 @@ export async function enterAppearanceFlow({
sourceRangeFromRust(operation.sourceRange)
),
}
console.log('argDefaultValues', argDefaultValues)
return {
type: 'Find and select command',
data: {
@ -1315,3 +1332,101 @@ export async function enterAppearanceFlow({
'Appearance setting not yet supported for this operation. Please edit in the code editor.'
)
}
export async function enterTranslateFlow({
operation,
}: EnterEditFlowProps): Promise<Error | CommandBarMachineEvent> {
const isModuleImport = operation.type === 'GroupBegin'
const isSupportedStdLibCall =
(operation.type === 'KclStdLibCall' || operation.type === 'StdLibCall') &&
stdLibMap[operation.name]?.supportsTransform
if (!isModuleImport && !isSupportedStdLibCall) {
return new Error(
'Unsupported operation type. Please edit in the code editor.'
)
}
const nodeToEdit = getNodePathFromSourceRange(
kclManager.ast,
sourceRangeFromRust(operation.sourceRange)
)
let x: KclExpression | undefined = undefined
let y: KclExpression | undefined = undefined
let z: KclExpression | undefined = undefined
const pipe = getNodeFromPath<PipeExpression>(
kclManager.ast,
nodeToEdit,
'PipeExpression'
)
if (!err(pipe) && pipe.node.body) {
const translate = pipe.node.body.find(
(n) => n.type === 'CallExpressionKw' && n.callee.name.name === 'translate'
)
if (translate?.type === 'CallExpressionKw') {
x = await retrieveArgFromPipedCallExpression(translate, 'x')
y = await retrieveArgFromPipedCallExpression(translate, 'y')
z = await retrieveArgFromPipedCallExpression(translate, 'z')
}
}
// Won't be used since we provide nodeToEdit
const selection: Selections = { graphSelections: [], otherSelections: [] }
const argDefaultValues = { nodeToEdit, selection, x, y, z }
return {
type: 'Find and select command',
data: {
name: 'Translate',
groupId: 'modeling',
argDefaultValues,
},
}
}
export async function enterRotateFlow({
operation,
}: EnterEditFlowProps): Promise<Error | CommandBarMachineEvent> {
const isModuleImport = operation.type === 'GroupBegin'
const isSupportedStdLibCall =
(operation.type === 'KclStdLibCall' || operation.type === 'StdLibCall') &&
stdLibMap[operation.name]?.supportsTransform
if (!isModuleImport && !isSupportedStdLibCall) {
return new Error(
'Unsupported operation type. Please edit in the code editor.'
)
}
const nodeToEdit = getNodePathFromSourceRange(
kclManager.ast,
sourceRangeFromRust(operation.sourceRange)
)
let roll: KclExpression | undefined = undefined
let pitch: KclExpression | undefined = undefined
let yaw: KclExpression | undefined = undefined
const pipe = getNodeFromPath<PipeExpression>(
kclManager.ast,
nodeToEdit,
'PipeExpression'
)
if (!err(pipe) && pipe.node.body) {
const rotate = pipe.node.body.find(
(n) => n.type === 'CallExpressionKw' && n.callee.name.name === 'rotate'
)
if (rotate?.type === 'CallExpressionKw') {
roll = await retrieveArgFromPipedCallExpression(rotate, 'roll')
pitch = await retrieveArgFromPipedCallExpression(rotate, 'pitch')
yaw = await retrieveArgFromPipedCallExpression(rotate, 'yaw')
}
}
// Won't be used since we provide nodeToEdit
const selection: Selections = { graphSelections: [], otherSelections: [] }
const argDefaultValues = { nodeToEdit, selection, roll, pitch, yaw }
return {
type: 'Find and select command',
data: {
name: 'Rotate',
groupId: 'modeling',
argDefaultValues,
},
}
}

View File

@ -362,17 +362,33 @@ export const toolbarConfig: Record<ToolbarModeName, ToolbarMode> = {
],
},
{
id: 'transform',
icon: 'angle',
status: 'kcl-only',
title: 'Transform',
description: 'Apply a translation and/or rotation to a module',
onClick: () => undefined,
id: 'translate',
onClick: () =>
commandBarActor.send({
type: 'Find and select command',
data: { name: 'Translate', groupId: 'modeling' },
}),
status: DEV || IS_NIGHTLY_OR_DEBUG ? 'available' : 'kcl-only',
title: 'Translate',
description: 'Apply a translation to a solid or sketch.',
links: [
{
label: 'API docs',
url: 'https://zoo.dev/docs/kcl/translate',
},
],
},
{
id: 'rotate',
onClick: () =>
commandBarActor.send({
type: 'Find and select command',
data: { name: 'Rotate', groupId: 'modeling' },
}),
status: DEV || IS_NIGHTLY_OR_DEBUG ? 'available' : 'kcl-only',
title: 'Rotate',
description: 'Apply a rotation to a solid or sketch.',
links: [
{
label: 'API docs',
url: 'https://zoo.dev/docs/kcl/rotate',

View File

@ -12,7 +12,12 @@ import type { Artifact } from '@src/lang/std/artifactGraph'
import { getArtifactFromRange } from '@src/lang/std/artifactGraph'
import type { SourceRange } from '@src/lang/wasm'
import type { EnterEditFlowProps } from '@src/lib/operations'
import { enterAppearanceFlow, enterEditFlow } from '@src/lib/operations'
import {
enterAppearanceFlow,
enterEditFlow,
enterTranslateFlow,
enterRotateFlow,
} from '@src/lib/operations'
import { kclManager } from '@src/lib/singletons'
import { err } from '@src/lib/trap'
import { commandBarActor } from '@src/machines/commandBarMachine'
@ -38,6 +43,14 @@ type FeatureTreeEvent =
type: 'enterAppearanceFlow'
data: { targetSourceRange: SourceRange; currentOperation: Operation }
}
| {
type: 'enterTranslateFlow'
data: { targetSourceRange: SourceRange; currentOperation: Operation }
}
| {
type: 'enterRotateFlow'
data: { targetSourceRange: SourceRange; currentOperation: Operation }
}
| { type: 'goToError' }
| { type: 'codePaneOpened' }
| { type: 'selected' }
@ -108,6 +121,52 @@ export const featureTreeMachine = setup({
})
}
),
prepareTranslateCommand: fromPromise(
({
input,
}: {
input: EnterEditFlowProps & {
commandBarSend: (typeof commandBarActor)['send']
}
}) => {
return new Promise((resolve, reject) => {
const { commandBarSend, ...editFlowProps } = input
enterTranslateFlow(editFlowProps)
.then((result) => {
if (err(result)) {
reject(result)
return
}
input.commandBarSend(result)
resolve(result)
})
.catch(reject)
})
}
),
prepareRotateCommand: fromPromise(
({
input,
}: {
input: EnterEditFlowProps & {
commandBarSend: (typeof commandBarActor)['send']
}
}) => {
return new Promise((resolve, reject) => {
const { commandBarSend, ...editFlowProps } = input
enterRotateFlow(editFlowProps)
.then((result) => {
if (err(result)) {
reject(result)
return
}
input.commandBarSend(result)
resolve(result)
})
.catch(reject)
})
}
),
sendDeleteCommand: fromPromise(
({
input,
@ -198,6 +257,16 @@ export const featureTreeMachine = setup({
actions: ['saveTargetSourceRange', 'saveCurrentOperation'],
},
enterTranslateFlow: {
target: 'enteringTranslateFlow',
actions: ['saveTargetSourceRange', 'saveCurrentOperation'],
},
enterRotateFlow: {
target: 'enteringRotateFlow',
actions: ['saveTargetSourceRange', 'saveCurrentOperation'],
},
deleteOperation: {
target: 'deletingOperation',
actions: ['saveTargetSourceRange'],
@ -363,6 +432,114 @@ export const featureTreeMachine = setup({
exit: ['clearContext'],
},
enteringTranslateFlow: {
states: {
selecting: {
on: {
selected: {
target: 'prepareTranslateCommand',
reenter: true,
},
},
},
done: {
always: '#featureTree.idle',
},
prepareTranslateCommand: {
invoke: {
src: 'prepareTranslateCommand',
input: ({ context }) => {
const artifact = context.targetSourceRange
? (getArtifactFromRange(
context.targetSourceRange,
kclManager.artifactGraph
) ?? undefined)
: undefined
return {
// currentOperation is guaranteed to be defined here
operation: context.currentOperation!,
artifact,
commandBarSend: commandBarActor.send,
}
},
onDone: {
target: 'done',
reenter: true,
},
onError: {
target: 'done',
reenter: true,
actions: ({ event }) => {
if ('error' in event && err(event.error)) {
toast.error(event.error.message)
}
},
},
},
},
},
initial: 'selecting',
entry: 'sendSelectionEvent',
exit: ['clearContext'],
},
enteringRotateFlow: {
states: {
selecting: {
on: {
selected: {
target: 'prepareRotateCommand',
reenter: true,
},
},
},
done: {
always: '#featureTree.idle',
},
prepareRotateCommand: {
invoke: {
src: 'prepareRotateCommand',
input: ({ context }) => {
const artifact = context.targetSourceRange
? (getArtifactFromRange(
context.targetSourceRange,
kclManager.artifactGraph
) ?? undefined)
: undefined
return {
// currentOperation is guaranteed to be defined here
operation: context.currentOperation!,
artifact,
commandBarSend: commandBarActor.send,
}
},
onDone: {
target: 'done',
reenter: true,
},
onError: {
target: 'done',
reenter: true,
actions: ({ event }) => {
if ('error' in event && err(event.error)) {
toast.error(event.error.message)
}
},
},
},
},
},
initial: 'selecting',
entry: 'sendSelectionEvent',
exit: ['clearContext'],
},
deletingOperation: {
states: {
selecting: {

View File

@ -52,6 +52,7 @@ import {
deleteNodeInExtrudePipe,
extrudeSketch,
insertNamedConstant,
insertVariableAndOffsetPathToNode,
loftSketches,
} from '@src/lang/modifyAst'
import type {
@ -72,17 +73,21 @@ import {
applyIntersectFromTargetOperatorSelections,
applySubtractFromTargetOperatorSelections,
applyUnionFromTargetOperatorSelections,
findAllChildrenAndOrderByPlaceInCode,
getLastVariable,
} from '@src/lang/modifyAst/boolean'
import {
deleteSelectionPromise,
deletionErrorMessage,
} from '@src/lang/modifyAst/deleteSelection'
import { setAppearance } from '@src/lang/modifyAst/setAppearance'
import { setTranslate, setRotate } from '@src/lang/modifyAst/setTransform'
import {
getNodeFromPath,
isNodeSafeToReplacePath,
stringifyPathToNode,
updatePathToNodesAfterEdit,
valueOrVariable,
} from '@src/lang/queryAst'
import { getNodePathFromSourceRange } from '@src/lang/queryAstNodePathUtils'
import {
@ -373,6 +378,8 @@ export type ModelingMachineEvent =
data: ModelingCommandSchema['Delete selection']
}
| { type: 'Appearance'; data: ModelingCommandSchema['Appearance'] }
| { type: 'Translate'; data: ModelingCommandSchema['Translate'] }
| { type: 'Rotate'; data: ModelingCommandSchema['Rotate'] }
| {
type:
| 'Add circle origin'
@ -2031,12 +2038,6 @@ export const modelingMachine = setup({
}
}
const valueOrVariable = (variable: KclCommandValue) => {
return 'variableName' in variable
? variable.variableIdentifierAst
: variable.valueAst
}
const { modifiedAst, pathToNode } = addHelix({
node: ast,
revolutions: valueOrVariable(revolutions),
@ -2651,6 +2652,120 @@ export const modelingMachine = setup({
)
}
),
translateAstMod: fromPromise(
async ({
input,
}: {
input: ModelingCommandSchema['Translate'] | undefined
}) => {
if (!input) return new Error('No input provided')
const ast = kclManager.ast
const modifiedAst = structuredClone(ast)
const { x, y, z, nodeToEdit, selection } = input
let pathToNode = nodeToEdit
if (!(pathToNode && typeof pathToNode[1][0] === 'number')) {
if (selection?.graphSelections[0].artifact) {
const children = findAllChildrenAndOrderByPlaceInCode(
selection?.graphSelections[0].artifact,
kclManager.artifactGraph
)
const variable = getLastVariable(children, modifiedAst)
if (!variable) {
return new Error("Couldn't find corresponding path to node")
}
pathToNode = variable.pathToNode
} else if (selection?.graphSelections[0].codeRef.pathToNode) {
pathToNode = selection?.graphSelections[0].codeRef.pathToNode
} else {
return new Error("Couldn't find corresponding path to node")
}
}
insertVariableAndOffsetPathToNode(x, modifiedAst, pathToNode)
insertVariableAndOffsetPathToNode(y, modifiedAst, pathToNode)
insertVariableAndOffsetPathToNode(z, modifiedAst, pathToNode)
const result = setTranslate({
pathToNode,
modifiedAst,
x: valueOrVariable(x),
y: valueOrVariable(y),
z: valueOrVariable(z),
})
if (err(result)) {
return err(result)
}
await updateModelingState(
result.modifiedAst,
EXECUTION_TYPE_REAL,
{
kclManager,
editorManager,
codeManager,
},
{
focusPath: [result.pathToNode],
}
)
}
),
rotateAstMod: fromPromise(
async ({
input,
}: {
input: ModelingCommandSchema['Rotate'] | undefined
}) => {
if (!input) return new Error('No input provided')
const ast = kclManager.ast
const modifiedAst = structuredClone(ast)
const { roll, pitch, yaw, nodeToEdit, selection } = input
let pathToNode = nodeToEdit
if (!(pathToNode && typeof pathToNode[1][0] === 'number')) {
if (selection?.graphSelections[0].artifact) {
const children = findAllChildrenAndOrderByPlaceInCode(
selection?.graphSelections[0].artifact,
kclManager.artifactGraph
)
const variable = getLastVariable(children, modifiedAst)
if (!variable) {
return new Error("Couldn't find corresponding path to node")
}
pathToNode = variable.pathToNode
} else if (selection?.graphSelections[0].codeRef.pathToNode) {
pathToNode = selection?.graphSelections[0].codeRef.pathToNode
} else {
return new Error("Couldn't find corresponding path to node")
}
}
insertVariableAndOffsetPathToNode(roll, modifiedAst, pathToNode)
insertVariableAndOffsetPathToNode(pitch, modifiedAst, pathToNode)
insertVariableAndOffsetPathToNode(yaw, modifiedAst, pathToNode)
const result = setRotate({
pathToNode,
modifiedAst,
roll: valueOrVariable(roll),
pitch: valueOrVariable(pitch),
yaw: valueOrVariable(yaw),
})
if (err(result)) {
return err(result)
}
await updateModelingState(
result.modifiedAst,
EXECUTION_TYPE_REAL,
{
kclManager,
editorManager,
codeManager,
},
{
focusPath: [result.pathToNode],
}
)
}
),
exportFromEngine: fromPromise(
async ({}: { input?: ModelingCommandSchema['Export'] }) => {
return undefined as Error | undefined
@ -2919,6 +3034,16 @@ export const modelingMachine = setup({
reenter: true,
},
Translate: {
target: 'Applying translate',
reenter: true,
},
Rotate: {
target: 'Applying rotate',
reenter: true,
},
'Boolean Subtract': 'Boolean subtracting',
'Boolean Union': 'Boolean uniting',
'Boolean Intersect': 'Boolean intersecting',
@ -4325,6 +4450,32 @@ export const modelingMachine = setup({
},
},
'Applying translate': {
invoke: {
src: 'translateAstMod',
id: 'translateAstMod',
input: ({ event }) => {
if (event.type !== 'Translate') return undefined
return event.data
},
onDone: ['idle'],
onError: ['idle'],
},
},
'Applying rotate': {
invoke: {
src: 'rotateAstMod',
id: 'rotateAstMod',
input: ({ event }) => {
if (event.type !== 'Rotate') return undefined
return event.data
},
onDone: ['idle'],
onError: ['idle'],
},
},
Exporting: {
invoke: {
src: 'exportFromEngine',