Assemblies: Point-and-click Clone (#6478)

* WIP: Assemblies: Point-and-click Clone
Fixes #6209

* Make assemblies commands available for release
Fixes #6497

* Break insert out of the group, new icon, add Clone disabled

* Add rotate thanks to @franknoirot

* Update relevant snapshots

* Fix pathToNode

* Add clone to stdlibmap

* Cleaned more things

* Add custom icon

* Add variable name for clone

* Add e2e test for import and translated import

* Remove stale comment

* Add test for selection based extrude clone

* First batch of suggestions from @lee-at-zoo-corp, tysm!

* Clean up test names

* Second batch of @lee-at-zoo-corp's suggestion for modelingMachine error handling
This commit is contained in:
Pierre Jacquier
2025-04-26 18:26:39 -04:00
committed by GitHub
parent 25bb95a66e
commit d7e80b3cc7
14 changed files with 691 additions and 46 deletions

View File

@ -124,14 +124,34 @@ test.describe('Point-and-click assemblies tests', () => {
await toolbar.openPane('code') await toolbar.openPane('code')
}) })
await test.step('Insert kcl second part as module', async () => { await test.step('Insert a second part with the same name and expect error', async () => {
await insertPartIntoAssembly( await toolbar.insertButton.click()
'bracket.kcl', await cmdBar.selectOption({ name: 'bracket.kcl' }).click()
'bracket', await cmdBar.expectState({
toolbar, stage: 'arguments',
cmdBar, currentArgKey: 'localName',
page currentArgValue: '',
) headerArguments: { Path: 'bracket.kcl', LocalName: '' },
highlightedHeaderArg: 'localName',
commandName: 'Insert',
})
await page.keyboard.insertText('cylinder')
await cmdBar.progressCmdBar()
await expect(
page.getByText('This variable name is already in use')
).toBeVisible()
})
await test.step('Fix the name and expect the second part inserted', async () => {
await cmdBar.argumentInput.clear()
await page.keyboard.insertText('bracket')
await cmdBar.progressCmdBar()
await cmdBar.expectState({
stage: 'review',
headerArguments: { Path: 'bracket.kcl', LocalName: 'bracket' },
commandName: 'Insert',
})
await cmdBar.progressCmdBar()
await editor.expectEditor.toContain( await editor.expectEditor.toContain(
` `
import "cylinder.kcl" as cylinder import "cylinder.kcl" as cylinder
@ -144,46 +164,13 @@ test.describe('Point-and-click assemblies tests', () => {
await scene.settled(cmdBar) await scene.settled(cmdBar)
}) })
await test.step('Insert a second time with the same name and expect error', async () => { await test.step('Insert a second time and expect error', async () => {
await toolbar.insertButton.click() await toolbar.insertButton.click()
await cmdBar.selectOption({ name: 'bracket.kcl' }).click() await cmdBar.selectOption({ name: 'bracket.kcl' }).click()
await cmdBar.expectState({
stage: 'arguments',
currentArgKey: 'localName',
currentArgValue: '',
headerArguments: { Path: 'bracket.kcl', LocalName: '' },
highlightedHeaderArg: 'localName',
commandName: 'Insert',
})
await page.keyboard.insertText('bracket')
await cmdBar.progressCmdBar()
await expect( await expect(
page.getByText('This variable name is already in use') page.getByText('This file is already imported')
).toBeVisible() ).toBeVisible()
}) })
await test.step('Insert a second time with a different name and expect error', async () => {
await page.keyboard.insertText('2')
await cmdBar.progressCmdBar()
await cmdBar.expectState({
stage: 'review',
headerArguments: { Path: 'bracket.kcl', LocalName: 'bracket2' },
commandName: 'Insert',
})
await cmdBar.progressCmdBar()
await editor.expectEditor.toContain(
`
import "cylinder.kcl" as cylinder
import "bracket.kcl" as bracket
import "bracket.kcl" as bracket2
cylinder
bracket
bracket2
`,
{ shouldNormalise: true }
)
// TODO: update once we have clone() with #6209
})
} }
) )
@ -621,4 +608,190 @@ foreign
}) })
} }
) )
test(
'Point-and-click Clone on assembly parts',
{ tag: ['@electron'] },
async ({
context,
page,
homePage,
scene,
toolbar,
cmdBar,
tronApp,
editor,
}) => {
if (!tronApp) {
fail()
}
const projectName = 'assembly'
await test.step('Setup parts and expect imported model', async () => {
await context.folderSetupFn(async (dir) => {
const projectDir = path.join(dir, projectName)
await fsp.mkdir(projectDir, { recursive: true })
await Promise.all([
fsp.copyFile(
path.join('public', 'kcl-samples', 'washer', 'main.kcl'),
path.join(projectDir, 'washer.kcl')
),
fsp.copyFile(
path.join(
'public',
'kcl-samples',
'socket-head-cap-screw',
'main.kcl'
),
path.join(projectDir, 'screw.kcl')
),
fsp.writeFile(
path.join(projectDir, 'main.kcl'),
`
import "washer.kcl" as washer
import "screw.kcl" as screw
screw
washer
|> rotate(roll = 90, pitch = 0, yaw = 0)`
),
])
})
await page.setBodyDimensions({ width: 1000, height: 500 })
await homePage.openProject(projectName)
await scene.settled(cmdBar)
await toolbar.closePane('code')
})
await test.step('Clone the part using the feature tree', async () => {
await toolbar.openPane('feature-tree')
const op = await toolbar.getFeatureTreeOperation('washer', 0)
await op.click({ button: 'right' })
await page.getByTestId('context-menu-clone').click()
await cmdBar.expectState({
stage: 'arguments',
currentArgKey: 'variableName',
currentArgValue: '',
headerArguments: {
VariableName: '',
},
highlightedHeaderArg: 'variableName',
commandName: 'Clone',
})
await cmdBar.progressCmdBar()
await cmdBar.expectState({
stage: 'review',
headerArguments: {
VariableName: 'clone001',
},
commandName: 'Clone',
})
await cmdBar.progressCmdBar()
await scene.settled(cmdBar)
await toolbar.closePane('feature-tree')
// Expect changes
await toolbar.openPane('code')
await editor.expectEditor.toContain(
`
washer
|> rotate(roll = 90, pitch = 0, yaw = 0)
clone001 = clone(washer)
`,
{ shouldNormalise: true }
)
await toolbar.closePane('code')
})
await test.step('Set translate on clone', async () => {
await toolbar.openPane('feature-tree')
const op = await toolbar.getFeatureTreeOperation('Clone', 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 cmdBar.progressCmdBar()
await page.keyboard.insertText('-3')
await cmdBar.progressCmdBar()
await cmdBar.progressCmdBar()
await cmdBar.expectState({
stage: 'review',
headerArguments: {
X: '0',
Y: '-3',
Z: '0',
},
commandName: 'Translate',
})
await cmdBar.progressCmdBar()
await scene.settled(cmdBar)
await toolbar.closePane('feature-tree')
// Expect changes
await toolbar.openPane('code')
await editor.expectEditor.toContain(
`
screw
washer
|> rotate(roll = 90, pitch = 0, yaw = 0)
clone001 = clone(washer)
|> translate(x = 0, y = -3, z = 0)
`,
{ shouldNormalise: true }
)
})
await test.step('Clone the translated clone', async () => {
await toolbar.openPane('feature-tree')
const op = await toolbar.getFeatureTreeOperation('Clone', 0)
await op.click({ button: 'right' })
await page.getByTestId('context-menu-clone').click()
await cmdBar.expectState({
stage: 'arguments',
currentArgKey: 'variableName',
currentArgValue: '',
headerArguments: {
VariableName: '',
},
highlightedHeaderArg: 'variableName',
commandName: 'Clone',
})
await cmdBar.progressCmdBar()
await cmdBar.expectState({
stage: 'review',
headerArguments: {
VariableName: 'clone002',
},
commandName: 'Clone',
})
await cmdBar.progressCmdBar()
await scene.settled(cmdBar)
await toolbar.closePane('feature-tree')
// Expect changes
await toolbar.openPane('code')
await editor.expectEditor.toContain(
`
screw
washer
|> rotate(roll = 90, pitch = 0, yaw = 0)
clone001 = clone(washer)
|> translate(x = 0, y = -3, z = 0)
clone002 = clone(clone001)
`,
{ shouldNormalise: true }
)
})
}
)
}) })

View File

@ -4311,4 +4311,82 @@ extrude001 = extrude(profile001, length = 1)
await scene.expectPixelColor(partColor, moreToTheRightPoint, tolerance) await scene.expectPixelColor(partColor, moreToTheRightPoint, tolerance)
}) })
}) })
test('Point-and-click Clone 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 [clickMidPoint] = scene.makeMouseHelpers(midPoint.x, midPoint.y)
await test.step('Clone through command bar flow', async () => {
await toolbar.closePane('code')
await cmdBar.openCmdBar()
await cmdBar.chooseCommand('Clone a solid or sketch')
await cmdBar.expectState({
stage: 'arguments',
currentArgKey: 'selection',
currentArgValue: '',
headerArguments: {
Selection: '',
VariableName: '',
},
highlightedHeaderArg: 'selection',
commandName: 'Clone',
})
await clickMidPoint()
await cmdBar.progressCmdBar()
await cmdBar.expectState({
stage: 'arguments',
currentArgKey: 'variableName',
currentArgValue: '',
headerArguments: {
Selection: '1 path',
VariableName: '',
},
highlightedHeaderArg: 'variableName',
commandName: 'Clone',
})
await page.keyboard.insertText('yoyoyo')
await cmdBar.progressCmdBar()
await cmdBar.expectState({
stage: 'review',
headerArguments: {
Selection: '1 path',
VariableName: 'yoyoyo',
},
commandName: 'Clone',
})
await cmdBar.progressCmdBar()
// Expect changes
await toolbar.openPane('code')
await editor.expectEditor.toContain(
`
sketch001 = startSketchOn(XZ)
profile001 = circle(sketch001, center = [0, 0], radius = 1)
extrude001 = extrude(profile001, length = 1)
yoyoyo = clone(extrude001)
`,
{ shouldNormalise: true }
)
})
})
}) })

View File

@ -869,6 +869,21 @@ const CustomIconMap = {
/> />
</svg> </svg>
), ),
clone: (
<svg
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-label="move"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M17 17H6V6H17V17ZM7 16H16V7H7V16ZM12 11H14V12H12V14H11V12H9V11H11V9H12V11ZM13 5H12V4H4V12H5V13H3V3H13V5Z"
fill="currentColor"
/>
</svg>
),
network: ( network: (
<svg <svg
viewBox="0 0 20 20" viewBox="0 0 20 20"

View File

@ -376,6 +376,22 @@ const OperationItem = (props: {
} }
} }
function enterCloneFlow() {
if (
props.item.type === 'StdLibCall' ||
props.item.type === 'KclStdLibCall' ||
props.item.type === 'GroupBegin'
) {
props.send({
type: 'enterCloneFlow',
data: {
targetSourceRange: sourceRangeFromRust(props.item.sourceRange),
currentOperation: props.item,
},
})
}
}
function deleteOperation() { function deleteOperation() {
if ( if (
props.item.type === 'StdLibCall' || props.item.type === 'StdLibCall' ||
@ -479,6 +495,16 @@ const OperationItem = (props: {
> >
Set rotate Set rotate
</ContextMenuItem>, </ContextMenuItem>,
<ContextMenuItem
onClick={enterCloneFlow}
data-testid="context-menu-clone"
disabled={
props.item.type !== 'GroupBegin' &&
!stdLibMap[props.item.name]?.supportsTransform
}
>
Clone
</ContextMenuItem>,
<ContextMenuItem <ContextMenuItem
onClick={deleteOperation} onClick={deleteOperation}
hotkey="Delete" hotkey="Delete"

View File

@ -940,6 +940,39 @@ export function addHelix({
} }
} }
/**
* Add clone statement
*/
export function addClone({
ast,
geometryName,
variableName,
}: {
ast: Node<Program>
geometryName: string
variableName: string
}): { modifiedAst: Node<Program>; pathToNode: PathToNode } {
const modifiedAst = structuredClone(ast)
const variable = createVariableDeclaration(
variableName,
createCallExpressionStdLibKw('clone', createLocalName(geometryName), [])
)
modifiedAst.body.push(variable)
const insertAt = modifiedAst.body.length - 1
const pathToNode: PathToNode = [
['body', ''],
[insertAt, 'index'],
['declaration', 'VariableDeclaration'],
['init', 'VariableDeclarator'],
]
return {
modifiedAst,
pathToNode,
}
}
/** /**
* Return a modified clone of an AST with a named constant inserted into the body * Return a modified clone of an AST with a named constant inserted into the body
*/ */

View File

@ -1,6 +1,8 @@
import type { Models } from '@kittycad/lib' import type { Models } from '@kittycad/lib'
import { angleLengthInfo } from '@src/components/Toolbar/angleLengthInfo' import { angleLengthInfo } from '@src/components/Toolbar/angleLengthInfo'
import { DEV } from '@src/env'
import { findUniqueName } from '@src/lang/create'
import { getNodeFromPath } from '@src/lang/queryAst' import { getNodeFromPath } from '@src/lang/queryAst'
import { getVariableDeclaration } from '@src/lang/queryAst/getVariableDeclaration' import { getVariableDeclaration } from '@src/lang/queryAst/getVariableDeclaration'
import { getNodePathFromSourceRange } from '@src/lang/queryAstNodePathUtils' import { getNodePathFromSourceRange } from '@src/lang/queryAstNodePathUtils'
@ -22,6 +24,7 @@ import type {
StateMachineCommandSetConfig, StateMachineCommandSetConfig,
} from '@src/lib/commandTypes' } from '@src/lib/commandTypes'
import { import {
KCL_DEFAULT_CONSTANT_PREFIXES,
KCL_DEFAULT_DEGREE, KCL_DEFAULT_DEGREE,
KCL_DEFAULT_LENGTH, KCL_DEFAULT_LENGTH,
KCL_DEFAULT_TRANSFORM, KCL_DEFAULT_TRANSFORM,
@ -31,6 +34,7 @@ import type { Selections } from '@src/lib/selections'
import { codeManager, kclManager } from '@src/lib/singletons' import { codeManager, kclManager } from '@src/lib/singletons'
import { err } from '@src/lib/trap' import { err } from '@src/lib/trap'
import type { SketchTool, modelingMachine } from '@src/machines/modelingMachine' import type { SketchTool, modelingMachine } from '@src/machines/modelingMachine'
import { IS_NIGHTLY_OR_DEBUG } from '@src/routes/utils'
type OutputFormat = Models['OutputFormat3d_type'] type OutputFormat = Models['OutputFormat3d_type']
type OutputTypeKey = OutputFormat['type'] type OutputTypeKey = OutputFormat['type']
@ -176,6 +180,11 @@ export type ModelingCommandSchema = {
pitch: KclCommandValue pitch: KclCommandValue
yaw: KclCommandValue yaw: KclCommandValue
} }
Clone: {
nodeToEdit?: PathToNode
selection: Selections
variableName: string
}
'Boolean Subtract': { 'Boolean Subtract': {
target: Selections target: Selections
tool: Selections tool: Selections
@ -1102,6 +1111,50 @@ export const modelingMachineCommandConfig: StateMachineCommandSetConfig<
}, },
}, },
}, },
Clone: {
description: 'Clone a solid or sketch.',
icon: 'clone',
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),
},
variableName: {
inputType: 'string',
required: true,
defaultValue: () => {
return findUniqueName(
kclManager.ast,
KCL_DEFAULT_CONSTANT_PREFIXES.CLONE
)
},
validation: async ({ data }: { data: string }) => {
const variableExists = kclManager.variables[data]
if (variableExists) {
return 'This variable name is already in use.'
}
return true
},
},
},
},
} }
modelingMachineCommandConfig modelingMachineCommandConfig

View File

@ -293,6 +293,13 @@ export type CommandArgument<
commandBarContext: ContextFrom<typeof commandBarMachine>, commandBarContext: ContextFrom<typeof commandBarMachine>,
machineContext?: ContextFrom<T> machineContext?: ContextFrom<T>
) => OutputType) ) => OutputType)
validation?: ({
data,
context,
}: {
data: any
context: CommandBarContext
}) => Promise<boolean | string>
} }
| { | {
inputType: 'selection' inputType: 'selection'

View File

@ -51,6 +51,7 @@ export const KCL_DEFAULT_CONSTANT_PREFIXES = {
REVOLVE: 'revolve', REVOLVE: 'revolve',
PLANE: 'plane', PLANE: 'plane',
HELIX: 'helix', HELIX: 'helix',
CLONE: 'clone',
} as const } as const
/** The default KCL length expression */ /** The default KCL length expression */
export const KCL_DEFAULT_LENGTH = `5` export const KCL_DEFAULT_LENGTH = `5`

View File

@ -119,6 +119,20 @@ export function kclCommands(commandProps: KclCommandConfig): Command[] {
inputType: 'options', inputType: 'options',
required: true, required: true,
options: commandProps.specialPropsForInsertCommand.providedOptions, options: commandProps.specialPropsForInsertCommand.providedOptions,
validation: async ({ data }) => {
const importExists = kclManager.ast.body.find(
(n) =>
n.type === 'ImportStatement' &&
((n.path.type === 'Kcl' && n.path.filename === data.path) ||
(n.path.type === 'Foreign' && n.path.path === data.path))
)
if (importExists) {
return 'This file is already imported, use the Clone command instead.'
// TODO: see if we can transition to the clone command, see #6515
}
return true
},
}, },
localName: { localName: {
inputType: 'string', inputType: 'string',

View File

@ -1125,6 +1125,12 @@ export const stdLibMap: Record<string, StdLibCallInfo> = {
label: 'Union', label: 'Union',
icon: 'booleanUnion', icon: 'booleanUnion',
}, },
clone: {
label: 'Clone',
icon: 'clone',
supportsAppearance: true,
supportsTransform: true,
},
} }
/** /**
@ -1430,3 +1436,34 @@ export async function enterRotateFlow({
}, },
} }
} }
export async function enterCloneFlow({
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)
)
// Won't be used since we provide nodeToEdit
const selection: Selections = { graphSelections: [], otherSelections: [] }
const argDefaultValues = { nodeToEdit, selection }
return {
type: 'Find and select command',
data: {
name: 'Clone',
groupId: 'modeling',
argDefaultValues,
},
}
}

View File

@ -11,6 +11,7 @@ import {
isEditingExistingSketch, isEditingExistingSketch,
pipeHasCircle, pipeHasCircle,
} from '@src/machines/modelingMachine' } from '@src/machines/modelingMachine'
import { IS_NIGHTLY_OR_DEBUG } from '@src/routes/utils'
export type ToolbarModeName = 'modeling' | 'sketching' export type ToolbarModeName = 'modeling' | 'sketching'
@ -399,10 +400,14 @@ export const toolbarConfig: Record<ToolbarModeName, ToolbarMode> = {
}, },
{ {
id: 'clone', id: 'clone',
onClick: () => undefined, onClick: () =>
status: 'kcl-only', commandBarActor.send({
type: 'Find and select command',
data: { name: 'Clone', groupId: 'modeling' },
}),
status: DEV || IS_NIGHTLY_OR_DEBUG ? 'available' : 'kcl-only',
title: 'Clone', title: 'Clone',
icon: 'patternLinear3d', // TODO: add a clone icon icon: 'clone',
description: 'Clone a solid or sketch.', description: 'Clone a solid or sketch.',
links: [ links: [
{ {

View File

@ -304,6 +304,7 @@ export const commandBarMachine = setup({
context.selectedCommand && context.selectedCommand &&
(argConfig?.inputType === 'selection' || (argConfig?.inputType === 'selection' ||
argConfig?.inputType === 'string' || argConfig?.inputType === 'string' ||
argConfig?.inputType === 'options' ||
argConfig?.inputType === 'selectionMixed') && argConfig?.inputType === 'selectionMixed') &&
argConfig?.validation argConfig?.validation
) { ) {

View File

@ -14,6 +14,7 @@ import type { SourceRange } from '@src/lang/wasm'
import type { EnterEditFlowProps } from '@src/lib/operations' import type { EnterEditFlowProps } from '@src/lib/operations'
import { import {
enterAppearanceFlow, enterAppearanceFlow,
enterCloneFlow,
enterEditFlow, enterEditFlow,
enterTranslateFlow, enterTranslateFlow,
enterRotateFlow, enterRotateFlow,
@ -51,6 +52,10 @@ type FeatureTreeEvent =
type: 'enterRotateFlow' type: 'enterRotateFlow'
data: { targetSourceRange: SourceRange; currentOperation: Operation } data: { targetSourceRange: SourceRange; currentOperation: Operation }
} }
| {
type: 'enterCloneFlow'
data: { targetSourceRange: SourceRange; currentOperation: Operation }
}
| { type: 'goToError' } | { type: 'goToError' }
| { type: 'codePaneOpened' } | { type: 'codePaneOpened' }
| { type: 'selected' } | { type: 'selected' }
@ -167,6 +172,29 @@ export const featureTreeMachine = setup({
}) })
} }
), ),
prepareCloneCommand: fromPromise(
({
input,
}: {
input: EnterEditFlowProps & {
commandBarSend: (typeof commandBarActor)['send']
}
}) => {
return new Promise((resolve, reject) => {
const { commandBarSend, ...editFlowProps } = input
enterCloneFlow(editFlowProps)
.then((result) => {
if (err(result)) {
reject(result)
return
}
input.commandBarSend(result)
resolve(result)
})
.catch(reject)
})
}
),
sendDeleteCommand: fromPromise( sendDeleteCommand: fromPromise(
({ ({
input, input,
@ -267,6 +295,11 @@ export const featureTreeMachine = setup({
actions: ['saveTargetSourceRange', 'saveCurrentOperation'], actions: ['saveTargetSourceRange', 'saveCurrentOperation'],
}, },
enterCloneFlow: {
target: 'enteringCloneFlow',
actions: ['saveTargetSourceRange', 'saveCurrentOperation'],
},
deleteOperation: { deleteOperation: {
target: 'deletingOperation', target: 'deletingOperation',
actions: ['saveTargetSourceRange'], actions: ['saveTargetSourceRange'],
@ -540,6 +573,60 @@ export const featureTreeMachine = setup({
exit: ['clearContext'], exit: ['clearContext'],
}, },
enteringCloneFlow: {
states: {
selecting: {
on: {
selected: {
target: 'prepareCloneCommand',
reenter: true,
},
},
},
done: {
always: '#featureTree.idle',
},
prepareCloneCommand: {
invoke: {
src: 'prepareCloneCommand',
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: { deletingOperation: {
states: { states: {
selecting: { selecting: {

View File

@ -45,6 +45,7 @@ import { angleLengthInfo } from '@src/components/Toolbar/angleLengthInfo'
import { createLiteral, createLocalName } from '@src/lang/create' import { createLiteral, createLocalName } from '@src/lang/create'
import { updateModelingState } from '@src/lang/modelingWorkflows' import { updateModelingState } from '@src/lang/modelingWorkflows'
import { import {
addClone,
addHelix, addHelix,
addOffsetPlane, addOffsetPlane,
addShell, addShell,
@ -99,9 +100,11 @@ import type {
CallExpression, CallExpression,
CallExpressionKw, CallExpressionKw,
Expr, Expr,
ExpressionStatement,
Literal, Literal,
Name, Name,
PathToNode, PathToNode,
PipeExpression,
VariableDeclaration, VariableDeclaration,
VariableDeclarator, VariableDeclarator,
} from '@src/lang/wasm' } from '@src/lang/wasm'
@ -379,6 +382,7 @@ export type ModelingMachineEvent =
| { type: 'Appearance'; data: ModelingCommandSchema['Appearance'] } | { type: 'Appearance'; data: ModelingCommandSchema['Appearance'] }
| { type: 'Translate'; data: ModelingCommandSchema['Translate'] } | { type: 'Translate'; data: ModelingCommandSchema['Translate'] }
| { type: 'Rotate'; data: ModelingCommandSchema['Rotate'] } | { type: 'Rotate'; data: ModelingCommandSchema['Rotate'] }
| { type: 'Clone'; data: ModelingCommandSchema['Clone'] }
| { | {
type: type:
| 'Add circle origin' | 'Add circle origin'
@ -2865,6 +2869,96 @@ export const modelingMachine = setup({
) )
} }
), ),
cloneAstMod: fromPromise(
async ({
input,
}: {
input: ModelingCommandSchema['Clone'] | undefined
}) => {
if (!input) return Promise.reject(new Error('No input provided'))
const ast = kclManager.ast
const { nodeToEdit, selection, variableName } = 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, ast)
if (!variable) {
return Promise.reject(
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 Promise.reject(
new Error("Couldn't find corresponding path to node")
)
}
}
const returnEarly = true
const geometryNode = getNodeFromPath<
VariableDeclaration | ExpressionStatement | PipeExpression
>(
ast,
pathToNode,
['VariableDeclaration', 'ExpressionStatement', 'PipeExpression'],
returnEarly
)
if (err(geometryNode)) {
return Promise.reject(
new Error("Couldn't find corresponding path to node")
)
}
let geometryName: string | undefined
if (geometryNode.node.type === 'VariableDeclaration') {
geometryName = geometryNode.node.declaration.id.name
} else if (
geometryNode.node.type === 'ExpressionStatement' &&
geometryNode.node.expression.type === 'Name'
) {
geometryName = geometryNode.node.expression.name.name
} else if (
geometryNode.node.type === 'ExpressionStatement' &&
geometryNode.node.expression.type === 'PipeExpression' &&
geometryNode.node.expression.body[0].type === 'Name'
) {
geometryName = geometryNode.node.expression.body[0].name.name
} else {
return Promise.reject(
new Error("Couldn't find corresponding geometry")
)
}
const result = addClone({
ast,
geometryName,
variableName,
})
if (err(result)) {
return Promise.reject(err(result))
}
await updateModelingState(
result.modifiedAst,
EXECUTION_TYPE_REAL,
{
kclManager,
editorManager,
codeManager,
},
{
focusPath: [result.pathToNode],
}
)
}
),
exportFromEngine: fromPromise( exportFromEngine: fromPromise(
async ({}: { input?: ModelingCommandSchema['Export'] }) => { async ({}: { input?: ModelingCommandSchema['Export'] }) => {
return undefined as Error | undefined return undefined as Error | undefined
@ -3144,6 +3238,11 @@ export const modelingMachine = setup({
reenter: true, reenter: true,
}, },
Clone: {
target: 'Applying clone',
reenter: true,
},
'Boolean Subtract': 'Boolean subtracting', 'Boolean Subtract': 'Boolean subtracting',
'Boolean Union': 'Boolean uniting', 'Boolean Union': 'Boolean uniting',
'Boolean Intersect': 'Boolean intersecting', 'Boolean Intersect': 'Boolean intersecting',
@ -4582,6 +4681,22 @@ export const modelingMachine = setup({
}, },
}, },
'Applying clone': {
invoke: {
src: 'cloneAstMod',
id: 'cloneAstMod',
input: ({ event }) => {
if (event.type !== 'Clone') return undefined
return event.data
},
onDone: ['idle'],
onError: {
target: 'idle',
actions: 'toastError',
},
},
},
Exporting: { Exporting: {
invoke: { invoke: {
src: 'exportFromEngine', src: 'exportFromEngine',