Add bidirectional and twist optional args to point-and-click Extrude (#7496)

* WIP: Add bidirectional args to point-and-click Extrude
Will eventually close #7495

* Wire up edit flow for symmetric

* Show skip true args in header in review phase

* Add bidirectionalLength

* Make currentArg always part of header

* WIP

* Add twistAng

* Proper optional args line in review

* Labels in progress button and option arg section heading

* Clean up extrude specific changes

* Clean up to separate from #7506

* Clean up to separate from #7506

* More clean up across branches

* More UI polish

* Remove options bool icon

* Fix labels for tests

* Upgrade e2e tests to cmdBar fixtures with fixes

* More fixes

* Fixed up more tests related to sweep behavior change

* Fix nodeToEdit not having hidden: true on Shell

* Add typecheck

* WIP: footer buttons

* back to reg width

* Clean up

* Update snapshots

* Update snapshots

* Clean up

* Fix tests and remove label

* Refactor

* Fix offset plane test

* Add CommandBarDivider

* Fix step back

* Update snapshots

* Add comment

* Fix it, thanks bot

* Clean up and inline optional heading

* Little case tweak

* Update src/components/CommandBar/CommandBarReview.tsx

Co-authored-by: graphite-app[bot] <96075541+graphite-app[bot]@users.noreply.github.com>

* Rename to CommandBarHeaderFooter

* Rename to CommandBarHeaderFooter

* Clean things up and fix edit

* Add test

* Revert something quick

* Reorg args to match kcl order

* Clean up edit arg retrieval error checks

* Lint

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: graphite-app[bot] <96075541+graphite-app[bot]@users.noreply.github.com>
This commit is contained in:
Pierre Jacquier
2025-06-23 10:53:01 -04:00
committed by GitHub
parent c584d942d4
commit 599ab33e40
9 changed files with 302 additions and 15 deletions

View File

@ -187,6 +187,13 @@ export class CmdBarFixture {
return this.page.getByRole('option', options) return this.page.getByRole('option', options)
} }
/**
* Select an optional argument from the command bar during review
*/
clickOptionalArgument = async (argName: string) => {
await this.page.getByTestId(`cmd-bar-add-optional-arg-${argName}`).click()
}
/** /**
* Clicks the Create new variable button for kcl input * Clicks the Create new variable button for kcl input
*/ */

View File

@ -4923,4 +4923,154 @@ extrude001 = extrude(profile001 length = 1)`
await editor.expectEditor.toContain(badCode, { shouldNormalise: true }) await editor.expectEditor.toContain(badCode, { shouldNormalise: true })
}) })
}) })
test('Point-and-click extrude with optional args', async ({
context,
page,
homePage,
scene,
editor,
toolbar,
cmdBar,
}) => {
const squareProfileCode = `length001 = 100
sketch001 = startSketchOn(XY)
profile001 = startProfile(sketch001, at = [0, 0])
|> yLine(length = length001)
|> xLine(length = length001)
|> yLine(length = -length001)
|> line(endAbsolute = [profileStartX(%), profileStartY(%)])
|> close()
`
await context.addInitScript((initialCode) => {
localStorage.setItem('persistCode', initialCode)
}, squareProfileCode)
await homePage.goToModelingScene()
await scene.settled(cmdBar)
await test.step('Select through code', async () => {
await editor.selectText('startProfile(sketch001, at = [0, 0])')
})
await test.step('Go through command bar flow', async () => {
await toolbar.extrudeButton.click()
await cmdBar.expectState({
stage: 'arguments',
currentArgKey: 'sketches',
currentArgValue: '',
headerArguments: {
Profiles: '',
Length: '',
},
highlightedHeaderArg: 'Profiles',
commandName: 'Extrude',
})
await cmdBar.progressCmdBar()
await cmdBar.expectState({
stage: 'arguments',
currentArgKey: 'length',
currentArgValue: '5',
headerArguments: {
Profiles: '1 profile',
Length: '',
},
highlightedHeaderArg: 'length',
commandName: 'Extrude',
})
await cmdBar.progressCmdBar()
await cmdBar.expectState({
stage: 'review',
headerArguments: {
Profiles: '1 profile',
Length: '5',
},
commandName: 'Extrude',
})
await cmdBar.clickOptionalArgument('bidirectionalLength')
await cmdBar.expectState({
stage: 'arguments',
currentArgKey: 'bidirectionalLength',
currentArgValue: '',
headerArguments: {
Profiles: '1 profile',
Length: '5',
BidirectionalLength: '',
},
highlightedHeaderArg: 'bidirectionalLength',
commandName: 'Extrude',
})
await page.keyboard.insertText('10')
await cmdBar.progressCmdBar()
await cmdBar.expectState({
stage: 'review',
headerArguments: {
Profiles: '1 profile',
Length: '5',
BidirectionalLength: '10',
},
commandName: 'Extrude',
})
await cmdBar.submit()
})
await test.step('Check that the code has changed', async () => {
await scene.settled(cmdBar)
await editor.expectEditor.toContain(
`extrude001 = extrude(profile001, length = 5, bidirectionalLength = 10)`,
{ shouldNormalise: true }
)
})
await test.step('Go through the edit flow via feature tree', async () => {
await toolbar.openPane('feature-tree')
const op = await toolbar.getFeatureTreeOperation('Extrude', 0)
await op.dblclick()
await cmdBar.expectState({
stage: 'arguments',
currentArgKey: 'length',
currentArgValue: '5',
headerArguments: {
Length: '5',
BidirectionalLength: '10',
},
highlightedHeaderArg: 'length',
commandName: 'Extrude',
})
await page.keyboard.insertText('10')
await cmdBar.progressCmdBar()
await page.getByRole('button', { name: 'BidirectionalLength' }).click()
await cmdBar.expectState({
stage: 'arguments',
currentArgKey: 'bidirectionalLength',
currentArgValue: '10',
headerArguments: {
Length: '10',
BidirectionalLength: '10',
},
highlightedHeaderArg: 'bidirectionalLength',
commandName: 'Extrude',
})
await page.keyboard.insertText('20')
await cmdBar.progressCmdBar()
await cmdBar.expectState({
stage: 'review',
headerArguments: {
Length: '10',
BidirectionalLength: '20',
},
commandName: 'Extrude',
})
await cmdBar.submit()
})
await test.step('Check that the code has changed again', async () => {
await scene.settled(cmdBar)
await toolbar.closePane('feature-tree')
await toolbar.openPane('code')
await editor.expectEditor.toContain(
`extrude001 = extrude(profile001, length = 10, bidirectionalLength = 20)`,
{ shouldNormalise: true }
)
})
})
}) })

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

View File

@ -102,7 +102,7 @@ function CommandBarReview({ stepBack }: { stepBack: () => void }) {
([argName, arg]) => { ([argName, arg]) => {
return ( return (
<button <button
data-testid="cmd-bar-add-optional-arg" data-testid={`cmd-bar-add-optional-arg-${argName}`}
type="button" type="button"
onClick={() => { onClick={() => {
commandBarActor.send({ commandBarActor.send({

View File

@ -37,11 +37,17 @@ export function addExtrude({
ast, ast,
sketches, sketches,
length, length,
symmetric,
bidirectionalLength,
twistAngle,
nodeToEdit, nodeToEdit,
}: { }: {
ast: Node<Program> ast: Node<Program>
sketches: Selections sketches: Selections
length: KclCommandValue length: KclCommandValue
symmetric?: boolean
bidirectionalLength?: KclCommandValue
twistAngle?: KclCommandValue
nodeToEdit?: PathToNode nodeToEdit?: PathToNode
}): }):
| { | {
@ -63,15 +69,48 @@ export function addExtrude({
return sketchesExprList return sketchesExprList
} }
// Extra labeled args expressions
const symmetricExpr = symmetric
? [createLabeledArg('symmetric', createLiteral(symmetric))]
: []
const bidirectionalLengthExpr = bidirectionalLength
? [
createLabeledArg(
'bidirectionalLength',
valueOrVariable(bidirectionalLength)
),
]
: []
const twistAngleExpr = twistAngle
? [createLabeledArg('twistAngle', valueOrVariable(twistAngle))]
: []
const sketchesExpr = createSketchExpression(sketchesExprList) const sketchesExpr = createSketchExpression(sketchesExprList)
const call = createCallExpressionStdLibKw('extrude', sketchesExpr, [ const call = createCallExpressionStdLibKw('extrude', sketchesExpr, [
createLabeledArg('length', valueOrVariable(length)), createLabeledArg('length', valueOrVariable(length)),
...symmetricExpr,
...bidirectionalLengthExpr,
...twistAngleExpr,
]) ])
// Insert variables for labeled arguments if provided // Insert variables for labeled arguments if provided
if ('variableName' in length && length.variableName) { if ('variableName' in length && length.variableName) {
insertVariableAndOffsetPathToNode(length, modifiedAst, nodeToEdit) insertVariableAndOffsetPathToNode(length, modifiedAst, nodeToEdit)
} }
if (
bidirectionalLength &&
'variableName' in bidirectionalLength &&
bidirectionalLength.variableName
) {
insertVariableAndOffsetPathToNode(
bidirectionalLength,
modifiedAst,
nodeToEdit
)
}
if (twistAngle && 'variableName' in twistAngle && twistAngle.variableName) {
insertVariableAndOffsetPathToNode(twistAngle, modifiedAst, nodeToEdit)
}
// 3. If edit, we assign the new function call declaration to the existing node, // 3. If edit, we assign the new function call declaration to the existing node,
// otherwise just push to the end // otherwise just push to the end

View File

@ -75,6 +75,9 @@ export type ModelingCommandSchema = {
// KCL stdlib arguments // KCL stdlib arguments
sketches: Selections sketches: Selections
length: KclCommandValue length: KclCommandValue
symmetric?: boolean
bidirectionalLength?: KclCommandValue
twistAngle?: KclCommandValue
} }
Sweep: { Sweep: {
// Enables editing workflow // Enables editing workflow
@ -408,6 +411,22 @@ export const modelingMachineCommandConfig: StateMachineCommandSetConfig<
defaultValue: KCL_DEFAULT_LENGTH, defaultValue: KCL_DEFAULT_LENGTH,
required: true, required: true,
}, },
symmetric: {
inputType: 'options',
required: false,
options: [
{ name: 'False', value: false },
{ name: 'True', value: true },
],
},
bidirectionalLength: {
inputType: 'kcl',
required: false,
},
twistAngle: {
inputType: 'kcl',
required: false,
},
}, },
}, },
Sweep: { Sweep: {

View File

@ -25,7 +25,7 @@ import type {
HelixModes, HelixModes,
ModelingCommandSchema, ModelingCommandSchema,
} from '@src/lib/commandBarConfigs/modelingCommandConfig' } from '@src/lib/commandBarConfigs/modelingCommandConfig'
import type { KclExpression } from '@src/lib/commandTypes' import type { KclCommandValue, KclExpression } from '@src/lib/commandTypes'
import { import {
stringToKclExpression, stringToKclExpression,
retrieveArgFromPipedCallExpression, retrieveArgFromPipedCallExpression,
@ -134,12 +134,63 @@ const prepareToEditExtrude: PrepareToEditCallback = async ({ operation }) => {
return { reason: "Couldn't retrieve length argument" } return { reason: "Couldn't retrieve length argument" }
} }
// symmetric argument from a string to boolean
let symmetric: boolean | undefined
if ('symmetric' in operation.labeledArgs && operation.labeledArgs.symmetric) {
symmetric =
codeManager.code.slice(
operation.labeledArgs.symmetric.sourceRange[0],
operation.labeledArgs.symmetric.sourceRange[1]
) === 'true'
}
// bidirectionalLength argument from a string to a KCL expression
let bidirectionalLength: KclCommandValue | undefined
if (
'bidirectionalLength' in operation.labeledArgs &&
operation.labeledArgs.bidirectionalLength
) {
const result = await stringToKclExpression(
codeManager.code.slice(
operation.labeledArgs.bidirectionalLength.sourceRange[0],
operation.labeledArgs.bidirectionalLength.sourceRange[1]
)
)
if (err(result) || 'errors' in result) {
return { reason: "Couldn't retrieve bidirectionalLength argument" }
}
bidirectionalLength = result
}
// twistAngle argument from a string to a KCL expression
let twistAngle: KclCommandValue | undefined
if (
'twistAngle' in operation.labeledArgs &&
operation.labeledArgs.twistAngle
) {
const result = await stringToKclExpression(
codeManager.code.slice(
operation.labeledArgs.twistAngle.sourceRange[0],
operation.labeledArgs.twistAngle.sourceRange[1]
)
)
if (err(result) || 'errors' in result) {
return { reason: "Couldn't retrieve twistAngle argument" }
}
twistAngle = result
}
// 3. Assemble the default argument values for the command, // 3. Assemble the default argument values for the command,
// with `nodeToEdit` set, which will let the actor know // with `nodeToEdit` set, which will let the actor know
// to edit the node that corresponds to the StdLibCall. // to edit the node that corresponds to the StdLibCall.
const argDefaultValues: ModelingCommandSchema['Extrude'] = { const argDefaultValues: ModelingCommandSchema['Extrude'] = {
sketches, sketches,
length, length,
symmetric,
bidirectionalLength,
twistAngle,
nodeToEdit: pathToNodeFromRustNodePath(operation.nodePath), nodeToEdit: pathToNodeFromRustNodePath(operation.nodePath),
} }
return { return {

View File

@ -143,6 +143,7 @@ export const commandBarMachine = setup({
: !a[1].hidden : !a[1].hidden
) )
let argIndex = 0 let argIndex = 0
let lastRequiredArg: CommandArgumentWithName<unknown> | undefined
while (argIndex < nonHiddenArgs.length) { while (argIndex < nonHiddenArgs.length) {
const [argName, argConfig] = nonHiddenArgs[argIndex] const [argName, argConfig] = nonHiddenArgs[argIndex]
@ -150,13 +151,14 @@ export const commandBarMachine = setup({
typeof argConfig.required === 'function' typeof argConfig.required === 'function'
? argConfig.required(context) ? argConfig.required(context)
: argConfig.required : argConfig.required
/**
* TODO: we need to think harder about the relationship between if (argIsRequired) {
* `required`, `skip`, and `hidden`. lastRequiredArg = {
* This bit of logic essentially makes "skip false" arguments required. ...argConfig,
* We may need a bit of state to mark an argument as "visited" for "skip false" args name: argName,
* to truly not require any value to continue. }
*/ }
const mustNotSkipArg = const mustNotSkipArg =
(argIsRequired || argConfig.skip === false) && (argIsRequired || argConfig.skip === false) &&
(!context.argumentsToSubmit.hasOwnProperty(argName) || (!context.argumentsToSubmit.hasOwnProperty(argName) ||
@ -166,12 +168,21 @@ export const commandBarMachine = setup({
'name' in rejectedArg && 'name' in rejectedArg &&
rejectedArg.name === argName)) rejectedArg.name === argName))
if ( if (mustNotSkipArg) {
mustNotSkipArg === true || return {
...selectedCommand.args[argName],
name: argName,
}
}
const reachedEndOfArgs =
argIndex + 1 === Object.keys(nonHiddenArgs).length argIndex + 1 === Object.keys(nonHiddenArgs).length
) { if (reachedEndOfArgs) {
// If we have reached the end of the arguments and none are skippable, if (lastRequiredArg) {
// return the last argument. return lastRequiredArg
}
// Default to the last argument that is not hidden
return { return {
...selectedCommand.args[argName], ...selectedCommand.args[argName],
name: argName, name: argName,

View File

@ -2435,12 +2435,22 @@ export const modelingMachine = setup({
return Promise.reject(new Error(NO_INPUT_PROVIDED_MESSAGE)) return Promise.reject(new Error(NO_INPUT_PROVIDED_MESSAGE))
} }
const { nodeToEdit, sketches, length } = input const {
nodeToEdit,
sketches,
length,
symmetric,
bidirectionalLength,
twistAngle,
} = input
const { ast } = kclManager const { ast } = kclManager
const astResult = addExtrude({ const astResult = addExtrude({
ast, ast,
sketches, sketches,
length, length,
symmetric,
bidirectionalLength,
twistAngle,
nodeToEdit, nodeToEdit,
}) })
if (err(astResult)) { if (err(astResult)) {