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:
@ -124,14 +124,34 @@ test.describe('Point-and-click assemblies tests', () => {
|
||||
await toolbar.openPane('code')
|
||||
})
|
||||
|
||||
await test.step('Insert kcl second part as module', async () => {
|
||||
await insertPartIntoAssembly(
|
||||
'bracket.kcl',
|
||||
'bracket',
|
||||
toolbar,
|
||||
cmdBar,
|
||||
page
|
||||
)
|
||||
await test.step('Insert a second part with the same name and expect error', async () => {
|
||||
await toolbar.insertButton.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('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(
|
||||
`
|
||||
import "cylinder.kcl" as cylinder
|
||||
@ -144,46 +164,13 @@ test.describe('Point-and-click assemblies tests', () => {
|
||||
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 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(
|
||||
page.getByText('This variable name is already in use')
|
||||
page.getByText('This file is already imported')
|
||||
).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 }
|
||||
)
|
||||
})
|
||||
}
|
||||
)
|
||||
})
|
||||
|
@ -4311,4 +4311,82 @@ extrude001 = extrude(profile001, length = 1)
|
||||
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 }
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
@ -869,6 +869,21 @@ const CustomIconMap = {
|
||||
/>
|
||||
</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: (
|
||||
<svg
|
||||
viewBox="0 0 20 20"
|
||||
|
@ -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() {
|
||||
if (
|
||||
props.item.type === 'StdLibCall' ||
|
||||
@ -479,6 +495,16 @@ const OperationItem = (props: {
|
||||
>
|
||||
Set rotate
|
||||
</ContextMenuItem>,
|
||||
<ContextMenuItem
|
||||
onClick={enterCloneFlow}
|
||||
data-testid="context-menu-clone"
|
||||
disabled={
|
||||
props.item.type !== 'GroupBegin' &&
|
||||
!stdLibMap[props.item.name]?.supportsTransform
|
||||
}
|
||||
>
|
||||
Clone
|
||||
</ContextMenuItem>,
|
||||
<ContextMenuItem
|
||||
onClick={deleteOperation}
|
||||
hotkey="Delete"
|
||||
|
@ -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
|
||||
*/
|
||||
|
@ -1,6 +1,8 @@
|
||||
import type { Models } from '@kittycad/lib'
|
||||
|
||||
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 { getVariableDeclaration } from '@src/lang/queryAst/getVariableDeclaration'
|
||||
import { getNodePathFromSourceRange } from '@src/lang/queryAstNodePathUtils'
|
||||
@ -22,6 +24,7 @@ import type {
|
||||
StateMachineCommandSetConfig,
|
||||
} from '@src/lib/commandTypes'
|
||||
import {
|
||||
KCL_DEFAULT_CONSTANT_PREFIXES,
|
||||
KCL_DEFAULT_DEGREE,
|
||||
KCL_DEFAULT_LENGTH,
|
||||
KCL_DEFAULT_TRANSFORM,
|
||||
@ -31,6 +34,7 @@ import type { Selections } from '@src/lib/selections'
|
||||
import { codeManager, kclManager } from '@src/lib/singletons'
|
||||
import { err } from '@src/lib/trap'
|
||||
import type { SketchTool, modelingMachine } from '@src/machines/modelingMachine'
|
||||
import { IS_NIGHTLY_OR_DEBUG } from '@src/routes/utils'
|
||||
|
||||
type OutputFormat = Models['OutputFormat3d_type']
|
||||
type OutputTypeKey = OutputFormat['type']
|
||||
@ -176,6 +180,11 @@ export type ModelingCommandSchema = {
|
||||
pitch: KclCommandValue
|
||||
yaw: KclCommandValue
|
||||
}
|
||||
Clone: {
|
||||
nodeToEdit?: PathToNode
|
||||
selection: Selections
|
||||
variableName: string
|
||||
}
|
||||
'Boolean Subtract': {
|
||||
target: 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
|
||||
|
@ -293,6 +293,13 @@ export type CommandArgument<
|
||||
commandBarContext: ContextFrom<typeof commandBarMachine>,
|
||||
machineContext?: ContextFrom<T>
|
||||
) => OutputType)
|
||||
validation?: ({
|
||||
data,
|
||||
context,
|
||||
}: {
|
||||
data: any
|
||||
context: CommandBarContext
|
||||
}) => Promise<boolean | string>
|
||||
}
|
||||
| {
|
||||
inputType: 'selection'
|
||||
|
@ -51,6 +51,7 @@ export const KCL_DEFAULT_CONSTANT_PREFIXES = {
|
||||
REVOLVE: 'revolve',
|
||||
PLANE: 'plane',
|
||||
HELIX: 'helix',
|
||||
CLONE: 'clone',
|
||||
} as const
|
||||
/** The default KCL length expression */
|
||||
export const KCL_DEFAULT_LENGTH = `5`
|
||||
|
@ -119,6 +119,20 @@ export function kclCommands(commandProps: KclCommandConfig): Command[] {
|
||||
inputType: 'options',
|
||||
required: true,
|
||||
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: {
|
||||
inputType: 'string',
|
||||
|
@ -1125,6 +1125,12 @@ export const stdLibMap: Record<string, StdLibCallInfo> = {
|
||||
label: 'Union',
|
||||
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,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
@ -11,6 +11,7 @@ import {
|
||||
isEditingExistingSketch,
|
||||
pipeHasCircle,
|
||||
} from '@src/machines/modelingMachine'
|
||||
import { IS_NIGHTLY_OR_DEBUG } from '@src/routes/utils'
|
||||
|
||||
export type ToolbarModeName = 'modeling' | 'sketching'
|
||||
|
||||
@ -399,10 +400,14 @@ export const toolbarConfig: Record<ToolbarModeName, ToolbarMode> = {
|
||||
},
|
||||
{
|
||||
id: 'clone',
|
||||
onClick: () => undefined,
|
||||
status: 'kcl-only',
|
||||
onClick: () =>
|
||||
commandBarActor.send({
|
||||
type: 'Find and select command',
|
||||
data: { name: 'Clone', groupId: 'modeling' },
|
||||
}),
|
||||
status: DEV || IS_NIGHTLY_OR_DEBUG ? 'available' : 'kcl-only',
|
||||
title: 'Clone',
|
||||
icon: 'patternLinear3d', // TODO: add a clone icon
|
||||
icon: 'clone',
|
||||
description: 'Clone a solid or sketch.',
|
||||
links: [
|
||||
{
|
||||
|
@ -304,6 +304,7 @@ export const commandBarMachine = setup({
|
||||
context.selectedCommand &&
|
||||
(argConfig?.inputType === 'selection' ||
|
||||
argConfig?.inputType === 'string' ||
|
||||
argConfig?.inputType === 'options' ||
|
||||
argConfig?.inputType === 'selectionMixed') &&
|
||||
argConfig?.validation
|
||||
) {
|
||||
|
@ -14,6 +14,7 @@ import type { SourceRange } from '@src/lang/wasm'
|
||||
import type { EnterEditFlowProps } from '@src/lib/operations'
|
||||
import {
|
||||
enterAppearanceFlow,
|
||||
enterCloneFlow,
|
||||
enterEditFlow,
|
||||
enterTranslateFlow,
|
||||
enterRotateFlow,
|
||||
@ -51,6 +52,10 @@ type FeatureTreeEvent =
|
||||
type: 'enterRotateFlow'
|
||||
data: { targetSourceRange: SourceRange; currentOperation: Operation }
|
||||
}
|
||||
| {
|
||||
type: 'enterCloneFlow'
|
||||
data: { targetSourceRange: SourceRange; currentOperation: Operation }
|
||||
}
|
||||
| { type: 'goToError' }
|
||||
| { type: 'codePaneOpened' }
|
||||
| { 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(
|
||||
({
|
||||
input,
|
||||
@ -267,6 +295,11 @@ export const featureTreeMachine = setup({
|
||||
actions: ['saveTargetSourceRange', 'saveCurrentOperation'],
|
||||
},
|
||||
|
||||
enterCloneFlow: {
|
||||
target: 'enteringCloneFlow',
|
||||
actions: ['saveTargetSourceRange', 'saveCurrentOperation'],
|
||||
},
|
||||
|
||||
deleteOperation: {
|
||||
target: 'deletingOperation',
|
||||
actions: ['saveTargetSourceRange'],
|
||||
@ -540,6 +573,60 @@ export const featureTreeMachine = setup({
|
||||
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: {
|
||||
states: {
|
||||
selecting: {
|
||||
|
@ -45,6 +45,7 @@ import { angleLengthInfo } from '@src/components/Toolbar/angleLengthInfo'
|
||||
import { createLiteral, createLocalName } from '@src/lang/create'
|
||||
import { updateModelingState } from '@src/lang/modelingWorkflows'
|
||||
import {
|
||||
addClone,
|
||||
addHelix,
|
||||
addOffsetPlane,
|
||||
addShell,
|
||||
@ -99,9 +100,11 @@ import type {
|
||||
CallExpression,
|
||||
CallExpressionKw,
|
||||
Expr,
|
||||
ExpressionStatement,
|
||||
Literal,
|
||||
Name,
|
||||
PathToNode,
|
||||
PipeExpression,
|
||||
VariableDeclaration,
|
||||
VariableDeclarator,
|
||||
} from '@src/lang/wasm'
|
||||
@ -379,6 +382,7 @@ export type ModelingMachineEvent =
|
||||
| { type: 'Appearance'; data: ModelingCommandSchema['Appearance'] }
|
||||
| { type: 'Translate'; data: ModelingCommandSchema['Translate'] }
|
||||
| { type: 'Rotate'; data: ModelingCommandSchema['Rotate'] }
|
||||
| { type: 'Clone'; data: ModelingCommandSchema['Clone'] }
|
||||
| {
|
||||
type:
|
||||
| '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(
|
||||
async ({}: { input?: ModelingCommandSchema['Export'] }) => {
|
||||
return undefined as Error | undefined
|
||||
@ -3144,6 +3238,11 @@ export const modelingMachine = setup({
|
||||
reenter: true,
|
||||
},
|
||||
|
||||
Clone: {
|
||||
target: 'Applying clone',
|
||||
reenter: true,
|
||||
},
|
||||
|
||||
'Boolean Subtract': 'Boolean subtracting',
|
||||
'Boolean Union': 'Boolean uniting',
|
||||
'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: {
|
||||
invoke: {
|
||||
src: 'exportFromEngine',
|
||||
|
Reference in New Issue
Block a user