WIP: global optional arg for all point-and-click transforms and add Scale

Closes #7615 and #7634
This commit is contained in:
Pierre Jacquier
2025-06-27 15:26:52 -04:00
parent fe66310f2d
commit 74d73ece7b
7 changed files with 461 additions and 11 deletions

View File

@ -409,6 +409,18 @@ const OperationItem = (props: {
}
}
function enterScaleFlow() {
if (props.item.type === 'StdLibCall' || props.item.type === 'GroupBegin') {
props.send({
type: 'enterScaleFlow',
data: {
targetSourceRange: sourceRangeFromRust(props.item.sourceRange),
currentOperation: props.item,
},
})
}
}
function enterCloneFlow() {
if (props.item.type === 'StdLibCall' || props.item.type === 'GroupBegin') {
props.send({
@ -527,6 +539,16 @@ const OperationItem = (props: {
>
Set rotate
</ContextMenuItem>,
<ContextMenuItem
onClick={enterScaleFlow}
data-testid="context-menu-set-scale"
disabled={
props.item.type !== 'GroupBegin' &&
!stdLibMap[props.item.name]?.supportsTransform
}
>
Set scale
</ContextMenuItem>,
<ContextMenuItem
onClick={enterCloneFlow}
data-testid="context-menu-clone"

View File

@ -4,6 +4,7 @@ import {
createCallExpressionStdLibKw,
createExpressionStatement,
createLabeledArg,
createLiteral,
createLocalName,
createPipeExpression,
} from '@src/lang/create'
@ -31,18 +32,25 @@ export function setTranslate({
x,
y,
z,
global,
}: {
modifiedAst: Node<Program>
pathToNode: PathToNode
x: Expr
y: Expr
z: Expr
global?: boolean
}): Error | { modifiedAst: Node<Program>; pathToNode: PathToNode } {
// Extra labeled args expression
const globalExpr = global
? [createLabeledArg('global', createLiteral(global))]
: []
const noPercentSign = null
const call = createCallExpressionStdLibKw('translate', noPercentSign, [
createLabeledArg('x', x),
createLabeledArg('y', y),
createLabeledArg('z', z),
...globalExpr,
])
const potentialPipe = getNodeFromPath<PipeExpression>(
@ -71,18 +79,72 @@ export function setRotate({
roll,
pitch,
yaw,
global,
}: {
modifiedAst: Node<Program>
pathToNode: PathToNode
roll: Expr
pitch: Expr
yaw: Expr
global?: boolean
}): Error | { modifiedAst: Node<Program>; pathToNode: PathToNode } {
// Extra labeled args expression
const globalExpr = global
? [createLabeledArg('global', createLiteral(global))]
: []
const noPercentSign = null
const call = createCallExpressionStdLibKw('rotate', noPercentSign, [
createLabeledArg('roll', roll),
createLabeledArg('pitch', pitch),
createLabeledArg('yaw', yaw),
...globalExpr,
])
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,
}
}
export function setScale({
modifiedAst,
pathToNode,
x,
y,
z,
global,
}: {
modifiedAst: Node<Program>
pathToNode: PathToNode
x: Expr
y: Expr
z: Expr
global?: boolean
}): Error | { modifiedAst: Node<Program>; pathToNode: PathToNode } {
// Extra labeled args expression
const globalExpr = global
? [createLabeledArg('global', createLiteral(global))]
: []
const noPercentSign = null
const call = createCallExpressionStdLibKw('scale', noPercentSign, [
createLabeledArg('x', x),
createLabeledArg('y', y),
createLabeledArg('z', z),
...globalExpr,
])
const potentialPipe = getNodeFromPath<PipeExpression>(

View File

@ -190,6 +190,7 @@ export type ModelingCommandSchema = {
x: KclCommandValue
y: KclCommandValue
z: KclCommandValue
global?: boolean
}
Rotate: {
nodeToEdit?: PathToNode
@ -197,6 +198,15 @@ export type ModelingCommandSchema = {
roll: KclCommandValue
pitch: KclCommandValue
yaw: KclCommandValue
global?: boolean
}
Scale: {
nodeToEdit?: PathToNode
selection: Selections
x: KclCommandValue
y: KclCommandValue
z: KclCommandValue
global?: boolean
}
Clone: {
nodeToEdit?: PathToNode
@ -750,14 +760,8 @@ export const modelingMachineCommandConfig: StateMachineCommandSetConfig<
required: false,
displayName: 'CounterClockWise',
options: [
{
name: 'False',
value: false,
},
{
name: 'True',
value: true,
},
{ name: 'False', value: false },
{ name: 'True', value: true },
],
},
},
@ -1104,6 +1108,14 @@ export const modelingMachineCommandConfig: StateMachineCommandSetConfig<
defaultValue: KCL_DEFAULT_TRANSFORM,
required: true,
},
global: {
inputType: 'options',
required: false,
options: [
{ name: 'False', value: false },
{ name: 'True', value: true },
],
},
},
},
Rotate: {
@ -1139,6 +1151,57 @@ export const modelingMachineCommandConfig: StateMachineCommandSetConfig<
defaultValue: KCL_DEFAULT_TRANSFORM,
required: true,
},
global: {
inputType: 'options',
required: false,
options: [
{ name: 'False', value: false },
{ name: 'True', value: true },
],
},
},
},
Scale: {
description: 'Set scale on solid or sketch.',
icon: 'scale',
needsReview: true,
args: {
nodeToEdit: {
...nodeToEditProps,
},
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,
},
global: {
inputType: 'options',
required: false,
options: [
{ name: 'False', value: false },
{ name: 'True', value: true },
],
},
},
},
Clone: {

View File

@ -1179,6 +1179,8 @@ export const stdLibMap: Record<string, StdLibCallInfo> = {
scale: {
label: 'Scale',
icon: 'scale',
prepareToEdit: prepareToEditScale,
supportsTransform: true,
},
shell: {
label: 'Shell',
@ -1540,6 +1542,7 @@ async function prepareToEditTranslate({ operation }: EnterEditFlowProps) {
let x: KclExpression | undefined = undefined
let y: KclExpression | undefined = undefined
let z: KclExpression | undefined = undefined
let global: boolean | undefined
const pipeLookupFromOperation = getNodeFromPath<PipeExpression>(
kclManager.ast,
nodeToEdit,
@ -1566,12 +1569,21 @@ async function prepareToEditTranslate({ operation }: EnterEditFlowProps) {
x = await retrieveArgFromPipedCallExpression(translate, 'x')
y = await retrieveArgFromPipedCallExpression(translate, 'y')
z = await retrieveArgFromPipedCallExpression(translate, 'z')
// optional global argument
const result = await retrieveArgFromPipedCallExpression(
translate,
'global'
)
if (result) {
global = result.valueText === 'true'
}
}
}
// Won't be used since we provide nodeToEdit
const selection: Selections = { graphSelections: [], otherSelections: [] }
const argDefaultValues = { nodeToEdit, selection, x, y, z }
const argDefaultValues = { nodeToEdit, selection, x, y, z, global }
return {
...baseCommand,
argDefaultValues,
@ -1592,6 +1604,84 @@ export async function enterTranslateFlow({
}
}
async function prepareToEditScale({ operation }: EnterEditFlowProps) {
const baseCommand = {
name: 'Scale',
groupId: 'modeling',
}
const isModuleImport = operation.type === 'GroupBegin'
const isSupportedStdLibCall =
operation.type === 'StdLibCall' &&
stdLibMap[operation.name]?.supportsTransform
if (!isModuleImport && !isSupportedStdLibCall) {
return {
reason: 'Unsupported operation type. Please edit in the code editor.',
}
}
const nodeToEdit = pathToNodeFromRustNodePath(operation.nodePath)
let x: KclExpression | undefined = undefined
let y: KclExpression | undefined = undefined
let z: KclExpression | undefined = undefined
let global: boolean | undefined
const pipeLookupFromOperation = getNodeFromPath<PipeExpression>(
kclManager.ast,
nodeToEdit,
'PipeExpression'
)
let pipe: PipeExpression | undefined
const ast = kclManager.ast
if (
err(pipeLookupFromOperation) ||
pipeLookupFromOperation.node.type !== 'PipeExpression'
) {
// Look for the last pipe with the import alias and a call to scale
const pipes = findPipesWithImportAlias(ast, nodeToEdit, 'scale')
pipe = pipes.at(-1)?.expression
} else {
pipe = pipeLookupFromOperation.node
}
if (pipe) {
const scale = pipe.body.find(
(n) => n.type === 'CallExpressionKw' && n.callee.name.name === 'scale'
)
if (scale?.type === 'CallExpressionKw') {
x = await retrieveArgFromPipedCallExpression(scale, 'x')
y = await retrieveArgFromPipedCallExpression(scale, 'y')
z = await retrieveArgFromPipedCallExpression(scale, 'z')
// optional global argument
const result = await retrieveArgFromPipedCallExpression(scale, 'global')
if (result) {
global = result.valueText === 'true'
}
}
}
// Won't be used since we provide nodeToEdit
const selection: Selections = { graphSelections: [], otherSelections: [] }
const argDefaultValues = { nodeToEdit, selection, x, y, z, global }
return {
...baseCommand,
argDefaultValues,
}
}
export async function enterScaleFlow({
operation,
}: EnterEditFlowProps): Promise<Error | CommandBarMachineEvent> {
const data = await prepareToEditScale({ operation })
if ('reason' in data) {
return new Error(data.reason)
}
return {
type: 'Find and select command',
data,
}
}
async function prepareToEditRotate({ operation }: EnterEditFlowProps) {
const baseCommand = {
name: 'Rotate',

View File

@ -432,6 +432,24 @@ export const toolbarConfig: Record<ToolbarModeName, ToolbarMode> = {
},
],
},
{
id: 'scale',
onClick: () =>
commandBarActor.send({
type: 'Find and select command',
data: { name: 'Scale', groupId: 'modeling' },
}),
icon: 'scale',
status: 'available',
title: 'Scale',
description: 'Apply scaling to a solid or sketch.',
links: [
{
label: 'API docs',
url: 'https://zoo.dev/docs/kcl-std/functions/std-transform-scale',
},
],
},
{
id: 'clone',
onClick: () =>

View File

@ -18,6 +18,7 @@ import {
enterEditFlow,
enterTranslateFlow,
enterRotateFlow,
enterScaleFlow,
} from '@src/lib/operations'
import { kclManager } from '@src/lib/singletons'
import { err } from '@src/lib/trap'
@ -52,6 +53,10 @@ type FeatureTreeEvent =
type: 'enterRotateFlow'
data: { targetSourceRange: SourceRange; currentOperation: Operation }
}
| {
type: 'enterScaleFlow'
data: { targetSourceRange: SourceRange; currentOperation: Operation }
}
| {
type: 'enterCloneFlow'
data: { targetSourceRange: SourceRange; currentOperation: Operation }
@ -172,6 +177,29 @@ export const featureTreeMachine = setup({
})
}
),
prepareScaleCommand: fromPromise(
({
input,
}: {
input: EnterEditFlowProps & {
commandBarSend: (typeof commandBarActor)['send']
}
}) => {
return new Promise((resolve, reject) => {
const { commandBarSend, ...editFlowProps } = input
enterScaleFlow(editFlowProps)
.then((result) => {
if (err(result)) {
reject(result)
return
}
input.commandBarSend(result)
resolve(result)
})
.catch(reject)
})
}
),
prepareCloneCommand: fromPromise(
({
input,
@ -293,6 +321,11 @@ export const featureTreeMachine = setup({
actions: ['saveTargetSourceRange', 'saveCurrentOperation'],
},
enterScaleFlow: {
target: 'enteringScaleFlow',
actions: ['saveTargetSourceRange', 'saveCurrentOperation'],
},
enterCloneFlow: {
target: 'enteringCloneFlow',
actions: ['saveTargetSourceRange', 'saveCurrentOperation'],
@ -571,6 +604,60 @@ export const featureTreeMachine = setup({
exit: ['clearContext'],
},
enteringScaleFlow: {
states: {
selecting: {
on: {
selected: {
target: 'prepareScaleCommand',
reenter: true,
},
},
},
done: {
always: '#featureTree.idle',
},
prepareScaleCommand: {
invoke: {
src: 'prepareScaleCommand',
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'],
},
enteringCloneFlow: {
states: {
selecting: {

View File

@ -88,6 +88,7 @@ import {
setRotate,
insertExpressionNode,
retrievePathToNodeFromTransformSelection,
setScale,
} from '@src/lang/modifyAst/setTransform'
import {
getNodeFromPath,
@ -403,6 +404,7 @@ export type ModelingMachineEvent =
| { type: 'Appearance'; data: ModelingCommandSchema['Appearance'] }
| { type: 'Translate'; data: ModelingCommandSchema['Translate'] }
| { type: 'Rotate'; data: ModelingCommandSchema['Rotate'] }
| { type: 'Scale'; data: ModelingCommandSchema['Scale'] }
| { type: 'Clone'; data: ModelingCommandSchema['Clone'] }
| {
type:
@ -3318,7 +3320,7 @@ export const modelingMachine = setup({
const ast = kclManager.ast
const modifiedAst = structuredClone(ast)
const { x, y, z, nodeToEdit, selection } = input
const { x, y, z, global, nodeToEdit, selection } = input
let pathToNode = nodeToEdit
if (!(pathToNode && typeof pathToNode[1][0] === 'number')) {
const result = retrievePathToNodeFromTransformSelection(
@ -3368,6 +3370,7 @@ export const modelingMachine = setup({
x: valueOrVariable(x),
y: valueOrVariable(y),
z: valueOrVariable(z),
global,
})
if (err(result)) {
return Promise.reject(result)
@ -3399,7 +3402,7 @@ export const modelingMachine = setup({
const ast = kclManager.ast
const modifiedAst = structuredClone(ast)
const { roll, pitch, yaw, nodeToEdit, selection } = input
const { roll, pitch, yaw, global, nodeToEdit, selection } = input
let pathToNode = nodeToEdit
if (!(pathToNode && typeof pathToNode[1][0] === 'number')) {
const result = retrievePathToNodeFromTransformSelection(
@ -3449,6 +3452,89 @@ export const modelingMachine = setup({
roll: valueOrVariable(roll),
pitch: valueOrVariable(pitch),
yaw: valueOrVariable(yaw),
global,
})
if (err(result)) {
return Promise.reject(result)
}
await updateModelingState(
result.modifiedAst,
EXECUTION_TYPE_REAL,
{
kclManager,
editorManager,
codeManager,
},
{
focusPath: [result.pathToNode],
}
)
}
),
scaleAstMod: fromPromise(
async ({
input,
}: {
input: ModelingCommandSchema['Scale'] | undefined
}) => {
if (!input) {
return Promise.reject(new Error(NO_INPUT_PROVIDED_MESSAGE))
}
const ast = kclManager.ast
const modifiedAst = structuredClone(ast)
const { x, y, z, global, nodeToEdit, selection } = input
let pathToNode = nodeToEdit
if (!(pathToNode && typeof pathToNode[1][0] === 'number')) {
const result = retrievePathToNodeFromTransformSelection(
selection,
kclManager.artifactGraph,
ast
)
if (err(result)) {
return Promise.reject(result)
}
pathToNode = result
}
// Look for the last pipe with the import alias and a call to translate, with a fallback to rotate.
// Otherwise create one
const importNodeAndAlias = findImportNodeAndAlias(ast, pathToNode)
if (importNodeAndAlias) {
const pipes = findPipesWithImportAlias(ast, pathToNode, 'translate')
const lastPipe = pipes.at(-1)
if (lastPipe && lastPipe.pathToNode) {
pathToNode = lastPipe.pathToNode
} else {
const otherRelevantPipes = findPipesWithImportAlias(
ast,
pathToNode,
'rotate'
)
const lastRelevantPipe = otherRelevantPipes.at(-1)
if (lastRelevantPipe && lastRelevantPipe.pathToNode) {
pathToNode = lastRelevantPipe.pathToNode
} else {
pathToNode = insertExpressionNode(
modifiedAst,
importNodeAndAlias.alias
)
}
}
}
insertVariableAndOffsetPathToNode(x, modifiedAst, pathToNode)
insertVariableAndOffsetPathToNode(y, modifiedAst, pathToNode)
insertVariableAndOffsetPathToNode(z, modifiedAst, pathToNode)
const result = setScale({
pathToNode,
modifiedAst,
x: valueOrVariable(x),
y: valueOrVariable(y),
z: valueOrVariable(z),
global,
})
if (err(result)) {
return Promise.reject(result)
@ -3863,6 +3949,12 @@ export const modelingMachine = setup({
guard: 'no kcl errors',
},
Scale: {
target: 'Applying scale',
reenter: true,
guard: 'no kcl errors',
},
Clone: {
target: 'Applying clone',
reenter: true,
@ -5345,6 +5437,22 @@ export const modelingMachine = setup({
},
},
'Applying scale': {
invoke: {
src: 'scaleAstMod',
id: 'scaleAstMod',
input: ({ event }) => {
if (event.type !== 'Scale') return undefined
return event.data
},
onDone: ['idle'],
onError: {
target: 'idle',
actions: 'toastError',
},
},
},
'Applying clone': {
invoke: {
src: 'cloneAstMod',