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 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 }
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
@ -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 }
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
@ -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"
|
||||||
|
@ -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"
|
||||||
|
@ -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
|
||||||
*/
|
*/
|
||||||
|
@ -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
|
||||||
|
@ -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'
|
||||||
|
@ -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`
|
||||||
|
@ -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',
|
||||||
|
@ -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,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -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: [
|
||||||
{
|
{
|
||||||
|
@ -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
|
||||||
) {
|
) {
|
||||||
|
@ -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: {
|
||||||
|
@ -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',
|
||||||
|
Reference in New Issue
Block a user