Add edit flows for extrude and offset plane operations (#5045)

* Start implementing a "prepareToEdit" callback for extrude

* Start of generic edit flow for operations

* Actually invoking command bar send generically on double-click

* Refactor: break out non-React hook helper to calculate Kcl expression value

* Add unit tests, fmt

* Integrate helper to get calculated KclExpression

* Clean up unused imports, simplify use of `programMemoryFromVariables`

* Implement basic extrude editing

* Refactor: move DefaultPlanesStr to its own lib file

* Add support for editing offset planes

* Add Edit right-click menu option

* Turn off edit flow for sketch for now

* Add e2e tests for sketch and offset plane editing, fix bug found with offset plane editing

* Add failing e2e extrude edit test

* Remove action version of extrude AST mod

* Fix behavior when adding a constant while editing operation, fixing e2e test

* Patch in changes from 61b02b5703

* Remove shell's prepareToEdit

* Add other Surface types to `artifactIsPlaneWithPaths`

* refactor: rename `item` to `operation`

* Allow `prepareToEdit` to fail with a toast, signal sketch-on-offset is unimplemented

* Rework sketch e2e test to test several working and failing cases

* Fix tsc errors related to making `codeRef` optional

* Make basic error messages more friendly

* fmt

* Reset modifyAst.ts to main

* Fix broken artifactGraph unit test

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* Remove unused import

* Look at this (photo)Graph *in the voice of Nickelback*

* Make the offset plane insert at the end, not one before

* Fix bug caught by e2e test failure with "Command needs review" logic

* Update src/machines/modelingMachine.ts

Co-authored-by: Pierre Jacquier <pierrejacquier39@gmail.com>

* Remove console logs per @pierremtb

* Update src/components/CommandBar/CommandBarHeader.tsx

Co-authored-by: Jonathan Tran <jonnytran@gmail.com>

* Use better programMemory init thanks @jtran

* Fix tsc post merge of #5068

* Fix logic for `artifactIsPlaneWithPaths` post-merge

* Need to disable the sketch-on-face case now that artifactGraph is in Rust. Will active in a future PR (cc @jtran)

* Re-run CI after snapshots

* Update FeatureTreePane to not use `useCommandsContext`, missed during merge

* Fix merge issue, import location change on edited file

* fix click test step, which I believe is waiting for context scripts to load

* Convert toolbarFixture.exeIndicator to getter

We need to convert all these selectors on fixtures to getters, because
they can go stale if called on the fixture constructor.

* Missed a dumb little thing in toolbarFixture.ts

* Fix goof with merge

* fmt

* Another dumb missed thing during merge

I gotta get used to the LazyGit merge tool I'm not good at it yet

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* Conver sceneFixture's exeIndicator to a getter

Locators on fixtures will be frozen from the time of the fixture's
initialization, I'm increasingly convinced

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* Post-kwargs E2E test cleanup

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Pierre Jacquier <pierrejacquier39@gmail.com>
Co-authored-by: Jonathan Tran <jonnytran@gmail.com>
This commit is contained in:
Frank Noirot
2025-02-05 19:33:40 -05:00
committed by GitHub
parent eb4048cd16
commit 9008fb636f
28 changed files with 1054 additions and 318 deletions

View File

@ -2392,8 +2392,6 @@ export class SceneEntities {
}
}
export type DefaultPlaneStr = 'XY' | 'XZ' | 'YZ' | '-XY' | '-XZ' | '-YZ'
// calculations/pure-functions/easy to test so no excuse not to
function prepareTruncatedMemoryAndAst(

View File

@ -156,6 +156,7 @@ function CommandBarHeader({ children }: React.PropsWithChildren<{}>) {
)}
{arg.inputType === 'kcl' &&
!!argValue &&
typeof argValue === 'object' &&
'variableName' in (argValue as KclCommandValue) && (
<>
<CustomIcon

View File

@ -11,8 +11,14 @@ import {
filterOperations,
getOperationIcon,
getOperationLabel,
stdLibMap,
} from 'lib/operations'
import { editorManager, engineCommandManager, kclManager } from 'lib/singletons'
import {
codeManager,
editorManager,
engineCommandManager,
kclManager,
} from 'lib/singletons'
import { ComponentProps, useEffect, useMemo, useRef, useState } from 'react'
import { Operation } from 'wasm-lib/kcl/bindings/Operation'
import { Actor, Prop } from 'xstate'
@ -60,6 +66,7 @@ export const FeatureTreePane = () => {
engineCommandManager.artifactGraph
)
: null
if (!artifact || !('codeRef' in artifact)) {
modelingSend({
type: 'Set selection',
@ -311,14 +318,12 @@ const OperationItem = (props: {
* TODO: https://github.com/KittyCAD/modeling-app/issues/4442
*/
function enterEditFlow() {
if (
props.item.type === 'StdLibCall' &&
props.item.name === 'startSketchOn'
) {
if (props.item.type === 'StdLibCall') {
props.send({
type: 'enterEditFlow',
data: {
targetSourceRange: sourceRangeFromRust(props.item.sourceRange),
currentOperation: props.item,
},
})
}
@ -364,6 +369,14 @@ const OperationItem = (props: {
</ContextMenuItem>,
]
: []),
...(props.item.type === 'StdLibCall' &&
stdLibMap[props.item.name]?.prepareToEdit
? [
<ContextMenuItem onClick={enterEditFlow}>
Edit {name}
</ContextMenuItem>,
]
: []),
],
[props.item, props.send]
)

View File

@ -16,7 +16,8 @@ import {
SegmentArtifact,
} from 'lang/std/artifactGraph'
import { err, reportRejection } from 'lib/trap'
import { DefaultPlaneStr, getFaceDetails } from 'clientSideScene/sceneEntities'
import { getFaceDetails } from 'clientSideScene/sceneEntities'
import { DefaultPlaneStr } from 'lib/planes'
import { getNodeFromPath } from 'lang/queryAst'
import { getNodePathFromSourceRange } from 'lang/queryAstNodePathUtils'
import { CallExpression, CallExpressionKw, defaultSourceRange } from 'lang/wasm'

View File

@ -456,7 +456,7 @@ export class KclManager {
// problem this solves, but either way we should strive to remove it.
Array.from(this.engineCommandManager.artifactGraph).forEach(
([commandId, artifact]) => {
if (!('codeRef' in artifact)) return
if (!('codeRef' in artifact && artifact.codeRef)) return
const _node1 = getNodeFromPath<Node<CallExpression | CallExpressionKw>>(
this.ast,
artifact.codeRef.pathToNode,

View File

@ -47,7 +47,7 @@ import {
removeSingleConstraint,
transformAstSketchLines,
} from './std/sketchcombos'
import { DefaultPlaneStr } from 'clientSideScene/sceneEntities'
import { DefaultPlaneStr } from 'lib/planes'
import { isOverlap, roundOff } from 'lib/utils'
import { KCL_DEFAULT_CONSTANT_PREFIXES } from 'lib/constants'
import { SimplifiedArgDetails } from './std/stdTypes'
@ -284,12 +284,19 @@ export function mutateObjExpProp(
return false
}
export function extrudeSketch(
node: Node<Program>,
pathToNode: PathToNode,
export function extrudeSketch({
node,
pathToNode,
shouldPipe = false,
distance: Expr = createLiteral(4)
):
distance = createLiteral(4),
extrudeName,
}: {
node: Node<Program>
pathToNode: PathToNode
shouldPipe?: boolean
distance: Expr
extrudeName?: string
}):
| {
modifiedAst: Node<Program>
pathToNode: PathToNode
@ -357,7 +364,8 @@ export function extrudeSketch(
// We're not creating a pipe expression,
// but rather a separate constant for the extrusion
const name = findUniqueName(node, KCL_DEFAULT_CONSTANT_PREFIXES.EXTRUDE)
const name =
extrudeName ?? findUniqueName(node, KCL_DEFAULT_CONSTANT_PREFIXES.EXTRUDE)
const VariableDeclaration = createVariableDeclaration(name, extrudeCall)
const sketchIndexInPathToNode =
@ -629,14 +637,19 @@ export function sketchOnExtrudedFace(
export function addOffsetPlane({
node,
defaultPlane,
insertIndex,
offset,
planeName,
}: {
node: Node<Program>
defaultPlane: DefaultPlaneStr
insertIndex?: number
offset: Expr
planeName?: string
}): { modifiedAst: Node<Program>; pathToNode: PathToNode } {
const modifiedAst = structuredClone(node)
const newPlaneName = findUniqueName(node, KCL_DEFAULT_CONSTANT_PREFIXES.PLANE)
const newPlaneName =
planeName ?? findUniqueName(node, KCL_DEFAULT_CONSTANT_PREFIXES.PLANE)
const newPlane = createVariableDeclaration(
newPlaneName,
@ -646,10 +659,19 @@ export function addOffsetPlane({
])
)
modifiedAst.body.push(newPlane)
const insertAt =
insertIndex !== undefined
? insertIndex
: modifiedAst.body.length
? modifiedAst.body.length
: 0
modifiedAst.body.length
? modifiedAst.body.splice(insertAt, 0, newPlane)
: modifiedAst.body.push(newPlane)
const pathToNode: PathToNode = [
['body', ''],
[modifiedAst.body.length - 1, 'index'],
[insertAt, 'index'],
['declaration', 'VariableDeclaration'],
['init', 'VariableDeclarator'],
['arguments', 'CallExpression'],

View File

@ -293,7 +293,7 @@ const runModifyAstCloneWithEdgeTreatmentAndTag = async (
const selection: Selections = {
graphSelections: segmentRanges.map((segmentRange) => {
const maybeArtifact = [...artifactGraph].find(([, a]) => {
if (!('codeRef' in a)) return false
if (!('codeRef' in a && a.codeRef)) return false
return isOverlap(a.codeRef.range, segmentRange)
})
return {

View File

@ -18,6 +18,7 @@ import {
import { Models } from '@kittycad/lib'
import { getNodePathFromSourceRange } from 'lang/queryAstNodePathUtils'
import { err } from 'lib/trap'
import { codeManager } from 'lib/singletons'
export type { Artifact, ArtifactId, SegmentArtifact } from 'lang/wasm'

Binary file not shown.

Before

Width:  |  Height:  |  Size: 568 KiB

After

Width:  |  Height:  |  Size: 569 KiB

View File

@ -26,9 +26,11 @@ import {
MAKE_TOAST_MESSAGES,
} from 'lib/constants'
import { KclManager } from 'lang/KclSingleton'
import { reportRejection } from 'lib/trap'
import { err, reportRejection } from 'lib/trap'
import { markOnce } from 'lib/performance'
import { MachineManager } from 'components/MachineManagerProvider'
import { DefaultPlaneStr } from 'lib/planes'
import { defaultPlaneStrToKey } from 'lib/planes'
// TODO(paultag): This ought to be tweakable.
const pingIntervalMs = 5_000
@ -2170,6 +2172,16 @@ export class EngineCommandManager extends EventTarget {
)
}
getDefaultPlaneId(name: DefaultPlaneStr): string | Error {
const key = defaultPlaneStrToKey(name)
if (!this.defaultPlanes) {
return new Error('Default planes not initialized')
} else if (err(key)) {
return key
}
return this.defaultPlanes[key]
}
async setPlaneHidden(id: string, hidden: boolean) {
if (this.engineConnection === undefined) return
@ -2228,7 +2240,11 @@ export class EngineCommandManager extends EventTarget {
commandTypeToTarget: string
): string | undefined {
for (const [artifactId, artifact] of this.artifactGraph) {
if ('codeRef' in artifact && isOverlap(range, artifact.codeRef.range)) {
if (
'codeRef' in artifact &&
artifact.codeRef &&
isOverlap(range, artifact.codeRef.range)
) {
if (commandTypeToTarget === artifact.type) return artifactId
}
}

View File

@ -38,6 +38,8 @@ export type ModelingCommandSchema = {
machine: components['schemas']['MachineInfoResponse']
}
Extrude: {
// Enables editing workflow
nodeToEdit?: PathToNode
selection: Selections // & { type: 'face' } would be cool to lock that down
// result: (typeof EXTRUSION_RESULTS)[number]
distance: KclCommandValue
@ -69,6 +71,8 @@ export type ModelingCommandSchema = {
length: KclCommandValue
}
'Offset plane': {
// Enables editing workflow
nodeToEdit?: PathToNode
plane: Selections
distance: KclCommandValue
}
@ -279,6 +283,13 @@ export const modelingMachineCommandConfig: StateMachineCommandSetConfig<
icon: 'extrude',
needsReview: true,
args: {
nodeToEdit: {
description:
'Path to the node in the AST to edit. Never shown to the user.',
skip: true,
inputType: 'text',
required: false,
},
selection: {
inputType: 'selection',
selectionTypes: ['solid2d', 'segment'],
@ -415,6 +426,13 @@ export const modelingMachineCommandConfig: StateMachineCommandSetConfig<
description: 'Offset a plane.',
icon: 'plane',
args: {
nodeToEdit: {
description:
'Path to the node in the AST to edit. Never shown to the user.',
skip: true,
inputType: 'text',
required: false,
},
plane: {
inputType: 'selection',
selectionTypes: ['plane'],

View File

@ -0,0 +1,69 @@
import { ParseResult, ProgramMemory } from 'lang/wasm'
import { getCalculatedKclExpressionValue } from './kclHelpers'
describe('KCL expression calculations', () => {
it('calculates a simple expression', async () => {
const actual = await getCalculatedKclExpressionValue({
value: '1 + 2',
programMemory: ProgramMemory.empty(),
})
const coercedActual = actual as Exclude<typeof actual, Error | ParseResult>
expect(coercedActual).not.toHaveProperty('errors')
expect(coercedActual.valueAsString).toEqual('3')
expect(coercedActual?.astNode).toBeDefined()
})
it('calculates a simple expression with a variable', async () => {
const programMemory = ProgramMemory.empty()
programMemory.set('x', {
type: 'Number',
value: 2,
__meta: [],
})
const actual = await getCalculatedKclExpressionValue({
value: '1 + x',
programMemory,
})
const coercedActual = actual as Exclude<typeof actual, Error | ParseResult>
expect(coercedActual.valueAsString).toEqual('3')
expect(coercedActual.astNode).toBeDefined()
})
it('returns NAN for an invalid expression', async () => {
const actual = await getCalculatedKclExpressionValue({
value: '1 + x',
programMemory: ProgramMemory.empty(),
})
const coercedActual = actual as Exclude<typeof actual, Error | ParseResult>
expect(coercedActual.valueAsString).toEqual('NAN')
expect(coercedActual.astNode).toBeDefined()
})
it('returns NAN for an expression with an invalid variable', async () => {
const programMemory = ProgramMemory.empty()
programMemory.set('y', {
type: 'Number',
value: 2,
__meta: [],
})
const actual = await getCalculatedKclExpressionValue({
value: '1 + x',
programMemory,
})
const coercedActual = actual as Exclude<typeof actual, Error | ParseResult>
expect(coercedActual.valueAsString).toEqual('NAN')
expect(coercedActual.astNode).toBeDefined()
})
it('calculates a more complex expression with a variable', async () => {
const programMemory = ProgramMemory.empty()
programMemory.set('x', {
type: 'Number',
value: 2,
__meta: [],
})
const actual = await getCalculatedKclExpressionValue({
value: '(1 + x * x) * 2',
programMemory,
})
const coercedActual = actual as Exclude<typeof actual, Error | ParseResult>
expect(coercedActual.valueAsString).toEqual('10')
expect(coercedActual.astNode).toBeDefined()
})
})

98
src/lib/kclHelpers.ts Normal file
View File

@ -0,0 +1,98 @@
import { err } from './trap'
import { engineCommandManager } from 'lib/singletons'
import { parse, ProgramMemory, programMemoryInit, resultIsOk } from 'lang/wasm'
import { PrevVariable } from 'lang/queryAst'
import { executeAst } from 'lang/langHelpers'
import { KclExpression } from './commandTypes'
const DUMMY_VARIABLE_NAME = '__result__'
export function programMemoryFromVariables(
variables: PrevVariable<string | number>[]
): ProgramMemory | Error {
const memory = programMemoryInit()
if (err(memory)) return memory
for (const { key, value } of variables) {
const error = memory.set(
key,
typeof value === 'number'
? {
type: 'Number',
value,
__meta: [],
}
: {
type: 'String',
value,
__meta: [],
}
)
if (err(error)) return error
}
return memory
}
/**
* Calculate the value of the KCL expression,
* given the value and the variables that are available
*/
export async function getCalculatedKclExpressionValue({
value,
programMemory,
}: {
value: string
programMemory: ProgramMemory
}) {
// Create a one-line program that assigns the value to a variable
const dummyProgramCode = `const ${DUMMY_VARIABLE_NAME} = ${value}`
const pResult = parse(dummyProgramCode)
if (err(pResult) || !resultIsOk(pResult)) return pResult
const ast = pResult.program
// Execute the program without hitting the engine
const { execState } = await executeAst({
ast,
engineCommandManager,
programMemoryOverride: programMemory,
})
// Find the variable declaration for the result
const resultDeclaration = ast.body.find(
(a) =>
a.type === 'VariableDeclaration' &&
a.declaration.id?.name === DUMMY_VARIABLE_NAME
)
const variableDeclaratorAstNode =
resultDeclaration?.type === 'VariableDeclaration' &&
resultDeclaration?.declaration.init
const resultRawValue = execState.memory?.get(DUMMY_VARIABLE_NAME)?.value
return {
astNode: variableDeclaratorAstNode,
valueAsString:
typeof resultRawValue === 'number' ? String(resultRawValue) : 'NAN',
}
}
export async function stringToKclExpression({
value,
programMemory,
}: {
value: string
programMemory: ProgramMemory
}) {
const calculatedResult = await getCalculatedKclExpressionValue({
value,
programMemory,
})
if (err(calculatedResult) || 'errors' in calculatedResult) {
return calculatedResult
} else if (!calculatedResult.astNode) {
return new Error('Failed to calculate KCL expression')
}
return {
valueAst: calculatedResult.astNode,
valueCalculated: calculatedResult.valueAsString,
valueText: value,
} satisfies KclExpression
}

View File

@ -1,19 +1,212 @@
import { CustomIconName } from 'components/CustomIcon'
import { Artifact, getArtifactOfTypes } from 'lang/std/artifactGraph'
import { Operation } from 'wasm-lib/kcl/bindings/Operation'
import { codeManager, engineCommandManager, kclManager } from './singletons'
import { err } from './trap'
import { getNodePathFromSourceRange } from 'lang/queryAstNodePathUtils'
import { sourceRangeFromRust } from 'lang/wasm'
import { CommandBarMachineEvent } from 'machines/commandBarMachine'
import { stringToKclExpression } from './kclHelpers'
import { ModelingCommandSchema } from './commandBarConfigs/modelingCommandConfig'
import { isDefaultPlaneStr } from './planes'
import { Selections } from './selections'
type ExecuteCommandEvent = CommandBarMachineEvent & {
type: 'Find and select command'
}
type ExecuteCommandEventPayload = ExecuteCommandEvent['data']
type PrepareToEditFailurePayload = { reason: string }
type PrepareToEditCallback = (
props: Omit<EnterEditFlowProps, 'commandBarSend'>
) =>
| ExecuteCommandEventPayload
| Promise<ExecuteCommandEventPayload | PrepareToEditFailurePayload>
interface StdLibCallInfo {
label: string
icon: CustomIconName
/**
* There are operations which are honored by the feature tree
* that do not yet have a corresponding modeling command.
*/
prepareToEdit?:
| ExecuteCommandEventPayload
| PrepareToEditCallback
| PrepareToEditFailurePayload
}
const stdLibMap: Record<string, StdLibCallInfo> = {
/**
* Gather up the argument values for the Extrude command
* to be used in the command bar edit flow.
*/
const prepareToEditExtrude: PrepareToEditCallback =
async function prepareToEditExtrude({ operation, artifact }) {
const baseCommand = {
name: 'Extrude',
groupId: 'modeling',
}
if (
!artifact ||
!('pathId' in artifact) ||
operation.type !== 'StdLibCall'
) {
return baseCommand
}
// We have to go a little roundabout to get from the original artifact
// to the solid2DId that we need to pass to the Extrude command.
const pathArtifact = getArtifactOfTypes(
{
key: artifact.pathId,
types: ['path'],
},
engineCommandManager.artifactGraph
)
if (
err(pathArtifact) ||
pathArtifact.type !== 'path' ||
!pathArtifact.solid2dId
)
return baseCommand
const solid2DArtifact = getArtifactOfTypes(
{
key: pathArtifact.solid2dId,
types: ['solid2d'],
},
engineCommandManager.artifactGraph
)
if (err(solid2DArtifact) || solid2DArtifact.type !== 'solid2d') {
return baseCommand
}
// Convert the length argument from a string to a KCL expression
const distanceResult = await stringToKclExpression({
value: codeManager.code.slice(
operation.labeledArgs?.['length']?.sourceRange[0],
operation.labeledArgs?.['length']?.sourceRange[1]
),
programMemory: kclManager.programMemory.clone(),
})
if (err(distanceResult) || 'errors' in distanceResult) {
return baseCommand
}
// Assemble the default argument values for the Extrude command,
// with `nodeToEdit` set, which will let the Extrude actor know
// to edit the node that corresponds to the StdLibCall.
const argDefaultValues: ModelingCommandSchema['Extrude'] = {
selection: {
graphSelections: [
{
artifact: solid2DArtifact,
codeRef: pathArtifact.codeRef,
},
],
otherSelections: [],
},
distance: distanceResult,
nodeToEdit: getNodePathFromSourceRange(
kclManager.ast,
sourceRangeFromRust(operation.sourceRange)
),
}
return {
...baseCommand,
argDefaultValues,
}
}
const prepareToEditOffsetPlane: PrepareToEditCallback = async ({
operation,
}) => {
const baseCommand = {
name: 'Offset plane',
groupId: 'modeling',
}
if (
operation.type !== 'StdLibCall' ||
!operation.labeledArgs ||
!('std_plane' in operation.labeledArgs) ||
!operation.labeledArgs.std_plane ||
!('offset' in operation.labeledArgs) ||
!operation.labeledArgs.offset
) {
return baseCommand
}
// TODO: Implement conversion to arbitrary plane selection
// once the Offset Plane command supports it.
const planeName = codeManager.code
.slice(
operation.labeledArgs.std_plane.sourceRange[0],
operation.labeledArgs.std_plane.sourceRange[1]
)
.replaceAll(`'`, ``)
if (!isDefaultPlaneStr(planeName)) {
// TODO: error handling
return baseCommand
}
const planeId = engineCommandManager.getDefaultPlaneId(planeName)
if (err(planeId)) {
// TODO: error handling
return baseCommand
}
const plane: Selections = {
graphSelections: [],
otherSelections: [
{
name: planeName,
id: planeId,
},
],
}
// Convert the distance argument from a string to a KCL expression
const distanceResult = await stringToKclExpression({
value: codeManager.code.slice(
operation.labeledArgs.offset.sourceRange[0],
operation.labeledArgs.offset.sourceRange[1]
),
programMemory: kclManager.programMemory.clone(),
})
if (err(distanceResult) || 'errors' in distanceResult) {
return baseCommand
}
// Assemble the default argument values for the Offset Plane command,
// with `nodeToEdit` set, which will let the Offset Plane actor know
// to edit the node that corresponds to the StdLibCall.
const argDefaultValues: ModelingCommandSchema['Offset plane'] = {
distance: distanceResult,
plane,
nodeToEdit: getNodePathFromSourceRange(
kclManager.ast,
sourceRangeFromRust(operation.sourceRange)
),
}
return {
...baseCommand,
argDefaultValues,
}
}
/**
* A map of standard library calls to their corresponding information
* for use in the feature tree UI.
*/
export const stdLibMap: Record<string, StdLibCallInfo> = {
chamfer: {
label: 'Chamfer',
icon: 'chamfer3d',
// modelingEvent: 'Chamfer',
},
extrude: {
label: 'Extrude',
icon: 'extrude',
prepareToEdit: prepareToEditExtrude,
},
fillet: {
label: 'Fillet',
@ -42,6 +235,7 @@ const stdLibMap: Record<string, StdLibCallInfo> = {
offsetPlane: {
label: 'Offset Plane',
icon: 'plane',
prepareToEdit: prepareToEditOffsetPlane,
},
patternCircular2d: {
label: 'Circular Pattern',
@ -70,6 +264,21 @@ const stdLibMap: Record<string, StdLibCallInfo> = {
startSketchOn: {
label: 'Sketch',
icon: 'sketch',
// TODO: fix matching sketches-on-faces and offset planes back to their
// original plane artifacts in order to edit them.
async prepareToEdit({ artifact }) {
if (artifact) {
return {
name: 'Enter sketch',
groupId: 'modeling',
}
} else {
return {
reason:
'Editing sketches on faces or offset planes through the feature tree is not yet supported. Please double-click the path in the scene for now.',
}
}
},
},
sweep: {
label: 'Sweep',
@ -182,3 +391,47 @@ function isNotUserFunctionWithNoOperations(
function isNotUserFunctionReturn(ops: Operation[]): Operation[] {
return ops.filter((op) => op.type !== 'UserDefinedFunctionReturn')
}
export interface EnterEditFlowProps {
operation: Operation
artifact?: Artifact
}
export async function enterEditFlow({
operation,
artifact,
}: EnterEditFlowProps): Promise<Error | CommandBarMachineEvent> {
if (operation.type !== 'StdLibCall') {
return new Error(
'Feature tree editing not yet supported for user-defined functions. Please edit in the code editor.'
)
}
const stdLibInfo = stdLibMap[operation.name]
if (stdLibInfo && stdLibInfo.prepareToEdit) {
if (typeof stdLibInfo.prepareToEdit === 'function') {
const eventPayload = await stdLibInfo.prepareToEdit({
operation,
artifact,
})
if ('reason' in eventPayload) {
return new Error(eventPayload.reason)
}
return {
type: 'Find and select command',
data: eventPayload,
}
} else {
return 'reason' in stdLibInfo.prepareToEdit
? new Error(stdLibInfo.prepareToEdit.reason)
: {
type: 'Find and select command',
data: stdLibInfo.prepareToEdit,
}
}
}
return new Error(
'Feature tree editing not yet supported for this operation. Please edit in the code editor.'
)
}

29
src/lib/planes.ts Normal file
View File

@ -0,0 +1,29 @@
import { DefaultPlanes } from 'wasm-lib/kcl/bindings/DefaultPlanes'
// KCL string representation of default planes
export type DefaultPlaneStr = 'XY' | 'XZ' | 'YZ' | '-XY' | '-XZ' | '-YZ'
export function defaultPlaneStrToKey(
plane: DefaultPlaneStr
): keyof DefaultPlanes | Error {
switch (plane) {
case 'XY':
return 'xy'
case 'XZ':
return 'xz'
case 'YZ':
return 'yz'
case '-XY':
return 'negXy'
case '-XZ':
return 'negXz'
case '-YZ':
return 'negYz'
default:
return new Error(`Invalid plane string: ${plane}`)
}
}
export function isDefaultPlaneStr(plane: string): plane is DefaultPlaneStr {
return ['XY', 'XZ', 'YZ', '-XY', '-XZ', '-YZ'].includes(plane)
}

View File

@ -22,7 +22,6 @@ import { getNodeFromPath, isSingleCursorInPipe } from 'lang/queryAst'
import { getNodePathFromSourceRange } from 'lang/queryAstNodePathUtils'
import { CommandArgument } from './commandTypes'
import {
DefaultPlaneStr,
getParentGroup,
SEGMENT_BODIES_PLUS_PROFILE_START,
} from 'clientSideScene/sceneEntities'
@ -43,6 +42,7 @@ import {
ArtifactId,
} from 'lang/std/artifactGraph'
import { Node } from 'wasm-lib/kcl/bindings/Node'
import { DefaultPlaneStr } from './planes'
export const X_AXIS_UUID = 'ad792545-7fd3-482a-a602-a93924e3055b'
export const Y_AXIS_UUID = '680fd157-266f-4b8a-984f-cdf46b8bdf01'
@ -614,7 +614,7 @@ export function codeToIdSelections(
// TODO #868: loops over all artifacts will become inefficient at a large scale
const overlappingEntries = Array.from(engineCommandManager.artifactGraph)
.map(([id, artifact]) => {
if (!('codeRef' in artifact)) return null
if (!('codeRef' in artifact && artifact.codeRef)) return null
return isOverlap(artifact.codeRef.range, selection.range)
? {
artifact,

View File

@ -1,12 +1,14 @@
import { useModelingContext } from 'hooks/useModelingContext'
import { kclManager, engineCommandManager } from 'lib/singletons'
import { kclManager } from 'lib/singletons'
import { useKclContext } from 'lang/KclProvider'
import { findUniqueName } from 'lang/modifyAst'
import { PrevVariable, findAllPreviousVariables } from 'lang/queryAst'
import { ProgramMemory, Expr, parse, resultIsOk } from 'lang/wasm'
import { Expr } from 'lang/wasm'
import { useEffect, useRef, useState } from 'react'
import { executeAst } from 'lang/langHelpers'
import { err, trap } from 'lib/trap'
import {
getCalculatedKclExpressionValue,
programMemoryFromVariables,
} from './kclHelpers'
const isValidVariableName = (name: string) =>
/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)
@ -86,37 +88,25 @@ export function useCalculateKclExpression({
useEffect(() => {
const execAstAndSetResult = async () => {
const _code = `const __result__ = ${value}`
const pResult = parse(_code)
if (err(pResult) || !resultIsOk(pResult)) return
const ast = pResult.program
const _programMem: ProgramMemory = ProgramMemory.empty()
for (const { key, value } of availableVarInfo.variables) {
const error = _programMem.set(key, {
type: 'String',
value,
__meta: [],
})
if (trap(error, { suppress: true })) return
}
const { execState } = await executeAst({
ast,
engineCommandManager,
// We make sure to send an empty program memory to denote we mean mock mode.
programMemoryOverride: kclManager.programMemory.clone(),
})
const resultDeclaration = ast.body.find(
(a) =>
a.type === 'VariableDeclaration' &&
a.declaration.id?.name === '__result__'
const programMemory = programMemoryFromVariables(
availableVarInfo.variables
)
const init =
resultDeclaration?.type === 'VariableDeclaration' &&
resultDeclaration?.declaration.init
const result = execState.memory?.get('__result__')?.value
setCalcResult(typeof result === 'number' ? String(result) : 'NAN')
init && setValueNode(init)
if (programMemory instanceof Error) {
setCalcResult('NAN')
setValueNode(null)
return
}
const result = await getCalculatedKclExpressionValue({
value,
programMemory,
})
if (result instanceof Error || 'errors' in result) {
setCalcResult('NAN')
setValueNode(null)
return
}
setCalcResult(result?.valueAsString || 'NAN')
result?.astNode && setValueNode(result.astNode)
}
if (!value) return
execAstAndSetResult().catch(() => {

View File

@ -249,7 +249,10 @@ export const commandBarMachine = setup({
},
guards: {
'Command needs review': ({ context }) =>
context.selectedCommand?.needsReview || false,
context.selectedCommand?.needsReview ||
('nodeToEdit' in context.argumentsToSubmit &&
context.argumentsToSubmit.nodeToEdit !== undefined) ||
false,
'Command has no arguments': ({ context }) => {
return (
!context.selectedCommand?.args ||

View File

@ -1,5 +1,12 @@
import { getArtifactFromRange } from 'lang/std/artifactGraph'
import { SourceRange } from 'lang/wasm'
import { assign, setup } from 'xstate'
import { enterEditFlow, EnterEditFlowProps } from 'lib/operations'
import { engineCommandManager } from 'lib/singletons'
import { err } from 'lib/trap'
import toast from 'react-hot-toast'
import { Operation } from 'wasm-lib/kcl/bindings/Operation'
import { assign, fromPromise, setup } from 'xstate'
import { commandBarActor } from './commandBarMachine'
type FeatureTreeEvent =
| {
@ -12,27 +19,67 @@ type FeatureTreeEvent =
}
| {
type: 'enterEditFlow'
data: { targetSourceRange: SourceRange }
data: { targetSourceRange: SourceRange; currentOperation: Operation }
}
| { type: 'goToError' }
| { type: 'codePaneOpened' }
| { type: 'selected' }
| { type: 'done' }
| { type: 'xstate.error.actor.prepareEditCommand'; error: Error }
type FeatureTreeContext = {
targetSourceRange?: SourceRange
currentOperation?: Operation
}
export const featureTreeMachine = setup({
types: {
context: {} as { targetSourceRange?: SourceRange },
input: {} as FeatureTreeContext,
context: {} as FeatureTreeContext,
events: {} as FeatureTreeEvent,
},
guards: {
codePaneIsOpen: () => false,
},
actors: {
prepareEditCommand: fromPromise(
({
input,
}: {
input: EnterEditFlowProps & {
commandBarSend: (typeof commandBarActor)['send']
}
}) => {
return new Promise((resolve, reject) => {
const { commandBarSend, ...editFlowProps } = input
enterEditFlow(editFlowProps)
.then((result) => {
if (err(result)) {
reject(result)
return
}
input.commandBarSend(result)
resolve(result)
})
.catch(reject)
})
}
),
},
actions: {
saveTargetSourceRange: assign({
targetSourceRange: ({ event }) =>
'data' in event ? event.data.targetSourceRange : undefined,
'data' in event && !err(event.data)
? event.data.targetSourceRange
: undefined,
}),
clearTargetSourceRange: assign({
saveCurrentOperation: assign({
currentOperation: ({ event }) =>
'data' in event && 'currentOperation' in event.data
? event.data.currentOperation
: undefined,
}),
clearContext: assign({
targetSourceRange: undefined,
}),
sendSelectionEvent: () => {},
@ -41,9 +88,10 @@ export const featureTreeMachine = setup({
scrollToError: () => {},
},
}).createMachine({
/** @xstate-layout N4IgpgJg5mDOIC5QDMwEMAuBXATmAKnmAHQCWEANmAMRQD2+dA0gMYUDKduLYA2gAwBdRKAAOdWKQyk6AOxEgAHogCMAFn7EAbAFYAHAHYAnGoOm9OgMw6tAGhABPRBsvF+a6wYBM-A-0uWepYGAL4h9qiYuAREZJQ0sGBULBgA8qJgOJgysgLCSCDiktJyCsoIKipabmo6-Co2Wip+-DoG9k4IWpp+Ae6W-N16KpZeYRHo2HiEYCTkVNRgshiZAKIQUgBiFHQA7nkKRVI5ZapergN6el46515GVdYdiN4GbjpGXiNGV2pan+MQJEpjFZsR6KRZFBGKwOFwcDxiIlktIodRkWAUpADgUjiV5AVyipWipiGZupYVCY1D5+F5ngg-jpiJ8DFo1OoDA13GNwkDJtEZiQIVCYWxONwSBA5DQcWIJMdSoTVG0yTodGoPAZKfwjP52o5EHVNIYvNd9M0zJZAcDBbERdDmOL4Yi6BlZJCoABhOgQMAABTQshoLF9AaDYHSS2xQkOCvxpy6lWIbS0Bm8JkM+ksDKZLK8bL0-S8NOCNoF01iGJSnqRSUxqKg6PrWIgcsK8ZOyq6aj0avTbU1Hg0KgZzVcrU+Xn+9Q+dPLUUrYOrjeI0uD1HbeK7oCJBj7xkafhMX28agZJeqHz0RhstTUD21WgXIKFxCWKxwnvWWx2uzrKKes2KIxvk8rFDuShGpY1RaKMRiHr4va9gyegPsQQRmiYVK+GYL52mCH6ZN+GwYNsexrjKm6xrinZKruqhaLBmr8PUvj6F4nGoeoxDEpYCFMVSowfPhS7CnQnqMKsOA4HQODEG6Syej6fqBhuoaqRGUbBm2NHgYqBIMV0-EproxLpoE7hWGOnFeGSxgaPwei6EYmaiaCczxLQDB0NJsk4FudGGVBFRpsQwRGGmgx6n4cH0oaFSVH21i6LqD5mtYejuW+DpSTJcmURugUQfRIX6MQfwcpqDxpUETwJSoXxqMQ04PjoSXspYtRhHyshhvABS2mJcYlcF5QALR2Al43TtoVJDD46ZfJS1p8kNHlxFQI0GYmNJjn8c21PuIzjuq2X2hJopOnCkrbQm3ZdXZNhsl1fzOSW1kJdYzKeK5VQqJh+7nWCuXXRKCIkCunp3ZB5RFq46ofOq6geDeo4JZqdlaEWt66l8jXfcD4mSWDLpSjKMOlUSzSwXS2qztVfy5jS2g43UnyVOcZ1rRWG2g7C4Ouu6ylhmpYCU2NiD8Zoz1wZq2NaB9OYYyz2O6gE3TtR8XVEwBDbQ7Ro2JtYT1pnLb2K7UyudIrFU3h8ZoamafhZTzi4bVDUJ6zWUIS7trGmS98vvVb+1GBhjUPtqer3KxOi657UCFeLhs7d2FmHe1ARmFr54NdqLUFrZnLBFUutEV+UI-mRf5+w9NgYZSNyBHB6rxTbcHhTY5wmDVMGrRM7tvhXJG-hRid10ZGjNUEjVWM533t4gaHh8aFgaOc+7XOXyzEVXpHkf+64p-p91T744VBEE7h0oEGpTZ0Dx2Sbhi9r4ozNLroN+XJk8hTBV51SRQGE7JiS9ErJjgnSRWC0bCu0Hq+C6JMf7yUUh6KEKlwzBj-uUKqKYgFQNAYrMcVI+yVFcqxfwFhAhf0uo6FByccHLyaC1JygRp5FkagaTo5CyFUj1KxO+gReRhCAA */
/** @xstate-layout N4IgpgJg5mDOIC5QDMwEMAuBXATmAKnmAHQCWEANmAMRQD2+dA0gMYUDKduLYA2gAwBdRKAAOdWKQyk6AOxEgAHogCMAFn7EAbAFYAHAHYAnGoOm9OgMw6tAGhABPRBsvF+a6wYBM-A-0uWepYGAL4h9qiYuAREZJQ0sGBULBgA8qJgOJgysgLCSCDiktJyCsoIKipabmo6-Co2Wip+-DoG9k4IWpp+Ae6W-N16KpZeYRHo2HiEYCTkVNRgshiZAKIQUgBiFHQA7nkKRVI5ZapergN6el46515GVdYdiN4GbjpGXiNGV2pan+MQJEpjFZnEFvRGKscDg6DgDgUjiV5AVylU3sEjFo-P9fINRs8KpU9MRrLp+CZPkF9IDgdEZiR6KRZFBGKwOFwcDxiIlktIWdReWAUpAEWIJMdSqjVK0VMQzN1LCoTGofPwvIS-jpiJ8DFo1OoDA13GNwkDJvTYkyWWy2JxuCQIHIaGLChLkacKm15TodGoPAYlRT-O1HIg6ppDF5rvpmmZLLSLdMrXRmazmHbOdy6BlZGmAMJ0CBgAAKaFkNBYRdL5bA6SWoqEh3dJ2lXUqxDa2O8JkM+ksmt0Oq8er0-S8quCiaiybBQpSaZ5SWF-KgguXIogrqRrdA5X1JIMbSPZn9lg0KkJzVcrU+XlxDSM6unIIZS75i6dFeo25bUr3qgGIeRiNH4JhfN4aiEhO1QfHoIH6n6DyBloL6WmCSwrDgabrFsOy7O+K5puufKNvk4rFLuSjhv62hBKY1h-PRUFhggwxeJ2Pa6D4hj1KEZp0rOJCYZkOEbBg2x7MQX4uk2iJ-iiAEVFo1T6ho9S+PoXjaYSwxqMQKj+EYepNEYowfGhQnECJ2EsrhEn4cQoh4KIaB4PZhYALaeeWEDUDJZCyAAbnQADWJCCaCwnLKJdniZJBHOWArnueJXk+bIEAIMyIUsNkch5L+lH-tRCAGtqxlaFcNgqGO3YsZ0ZlGDq+ggWO7j+NYllRdZMW2VA9kJU5LluWAHl0N5vmLDCcJORQmDIHCnnEJFb42WJeFSUlKVjWlE0ZVlOV0HlyKFXJFGSoppXlTqo4WE0dUGGYhJGD8OrKhYwwgYx-ETDOPXWum0KwjgxA5ksBbVmW35VsW0N1rmZHNsVV37pYzU2DohlPYE7hWFe2kcU9Jj8Pwei6K9ah6N1b6A1CM2gzJP7nW6KOevoxB-Aa-oPBSVMBDoBPqMQ95qCBxL6ueOhhGasjVvABSrUQyOXZ6AC0disWrfjaFVpPWNc8FfAmAlJj18xgCrHptqqV5-No6htMMSqBr6NMpmmtocg6VtUeU54cTYernkxWgTvjrHWNqYvBOegbaX4v3mv9tOpjaGbe1yJDzquvsleUY6uL6Hy+uoHjwZerH+hxesgRSXwqOcbumynHvp+y9pZ9Jzp56jqjNKp6qBvUfpi38mqqnRFJ1J8lRN9LLevm36Yd1mJDg3mLKFnDta9566OaEHWgh1VYe1AOVeT7XATdFjHznu7c4brn8ls221iB9ix-+qf4cX50YdtA3HOIMEY5MqaP2zs-RcOc0x7xtqTTsX8T7kz-nbZq7ExaBifPcUmC8-pLyfh+Fk3cKzwKUjjB2tQRjBGoX6K8gYRYjkJoaYIVRIG9SwhtByexyGlUsPqYgVxmimBwU+BooYAG3FJMMCk9EqoTiTkrDCfVuFDVgSyPh5QDQcWEWYYw6pxFtF0o3EWGhPh1ANEaCyi90LRS4XFTaBEZJaOcA0Tm55Rj+isdYdwukjxuE+CYLStwY4cPWo4nhiURqpSkOlXyriECBjlIGM8A9DJkw1KxYY1RAh3gCIEbx1NbFWTpnQYGcJEkCNgr6LEAw-T3jDleSomgzI3F0AbLsHCykVNBhvSGO8yGv1VjbYWxc6mtFVCpLJnRKhvTmU+fWFhAjdLTkDBmpDLbDOtkpcmcoeKFPcGORukj+7zOVIs-wyzrgyxCEAA */
id: 'featureTree',
description: 'Workflows for interacting with the feature tree pane',
context: ({ input }) => input,
states: {
idle: {
on: {
@ -59,7 +107,7 @@ export const featureTreeMachine = setup({
enterEditFlow: {
target: 'enteringEditFlow',
actions: 'saveTargetSourceRange',
actions: ['saveTargetSourceRange', 'saveCurrentOperation'],
},
goToError: 'goingToError',
@ -79,7 +127,7 @@ export const featureTreeMachine = setup({
},
done: {
entry: ['clearTargetSourceRange'],
entry: ['clearContext'],
always: '#featureTree.idle',
},
@ -107,7 +155,7 @@ export const featureTreeMachine = setup({
done: {
always: '#featureTree.idle',
entry: 'clearTargetSourceRange',
entry: 'clearContext',
},
},
@ -118,18 +166,54 @@ export const featureTreeMachine = setup({
states: {
selecting: {
on: {
selected: 'done',
selected: {
target: 'prepareEditCommand',
reenter: true,
},
},
},
done: {
always: '#featureTree.idle',
},
prepareEditCommand: {
invoke: {
src: 'prepareEditCommand',
input: ({ context }) => {
const artifact = context.targetSourceRange
? getArtifactFromRange(
context.targetSourceRange,
engineCommandManager.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: ['clearTargetSourceRange', 'sendEditFlowStart'],
exit: ['clearContext'],
},
goingToError: {

View File

@ -70,7 +70,8 @@ import {
} from 'components/Toolbar/SetAbsDistance'
import { ModelingCommandSchema } from 'lib/commandBarConfigs/modelingCommandConfig'
import { err, reportRejection, trap } from 'lib/trap'
import { DefaultPlaneStr, getFaceDetails } from 'clientSideScene/sceneEntities'
import { getFaceDetails } from 'clientSideScene/sceneEntities'
import { DefaultPlaneStr } from 'lib/planes'
import { uuidv4 } from 'lib/utils'
import { Coords2d } from 'lang/std/sketch'
import { deleteSegment } from 'clientSideScene/ClientSideSceneComp'
@ -1452,24 +1453,49 @@ export const modelingMachine = setup({
unknown,
ModelingCommandSchema['Extrude'] | undefined
>(async ({ input }) => {
if (!input) return Promise.reject('No input provided')
const { selection, distance } = input
if (!input) return new Error('No input provided')
const { selection, distance, nodeToEdit } = input
const isEditing =
nodeToEdit !== undefined && typeof nodeToEdit[1][0] === 'number'
let ast = structuredClone(kclManager.ast)
let extrudeName: string | undefined = undefined
// If this is an edit flow, first we're going to remove the old extrusion
if (isEditing) {
// Extract the plane name from the node to edit
const extrudeNameNode = getNodeFromPath<VariableDeclaration>(
ast,
nodeToEdit,
'VariableDeclaration'
)
if (err(extrudeNameNode)) {
console.error('Error extracting plane name')
} else {
extrudeName = extrudeNameNode.node.declaration.id.name
}
// Removing the old extrusion statement
const newBody = [...ast.body]
newBody.splice(nodeToEdit[1][0] as number, 1)
ast.body = newBody
}
const pathToNode = getNodePathFromSourceRange(
ast,
selection.graphSelections[0]?.codeRef.range
)
// Add an extrude statement to the AST
const extrudeSketchRes = extrudeSketch(
ast,
const extrudeSketchRes = extrudeSketch({
node: ast,
pathToNode,
false,
'variableName' in distance
? distance.variableIdentifierAst
: distance.valueAst
)
if (err(extrudeSketchRes)) return Promise.reject(extrudeSketchRes)
shouldPipe: false,
distance:
'variableName' in distance
? distance.variableIdentifierAst
: distance.valueAst,
extrudeName,
})
if (err(extrudeSketchRes)) return extrudeSketchRes
const { modifiedAst, pathToExtrudeArg } = extrudeSketchRes
// Insert the distance variable if the user has provided a variable name
@ -1513,30 +1539,37 @@ export const modelingMachine = setup({
if (!input) return new Error('No input provided')
// Extract inputs
const ast = kclManager.ast
const { plane: selection, distance } = input
const { plane: selection, distance, nodeToEdit } = input
let insertIndex: number | undefined = undefined
let planeName: string | undefined = undefined
// If this is an edit flow, first we're going to remove the old plane
if (nodeToEdit && typeof nodeToEdit[1][0] === 'number') {
// Extract the plane name from the node to edit
const planeNameNode = getNodeFromPath<VariableDeclaration>(
ast,
nodeToEdit,
'VariableDeclaration'
)
if (err(planeNameNode)) {
console.error('Error extracting plane name')
} else {
planeName = planeNameNode.node.declaration.id.name
}
const newBody = [...ast.body]
newBody.splice(nodeToEdit[1][0], 1)
ast.body = newBody
insertIndex = nodeToEdit[1][0]
}
// Extract the default plane from selection
const plane = selection.otherSelections[0]
if (!(plane && plane instanceof Object && 'name' in plane))
return trap('No plane selected')
// Insert the distance variable if it exists
if (
'variableName' in distance &&
distance.variableName &&
distance.insertIndex !== undefined
) {
const newBody = [...ast.body]
newBody.splice(
distance.insertIndex,
0,
distance.variableDeclarationAst
)
ast.body = newBody
}
// Get the default plane name from the selection
const offsetPlaneResult = addOffsetPlane({
node: ast,
defaultPlane: plane.name,
@ -1544,8 +1577,27 @@ export const modelingMachine = setup({
'variableName' in distance
? distance.variableIdentifierAst
: distance.valueAst,
insertIndex,
planeName,
})
// Insert the distance variable if the user has provided a variable name
if (
'variableName' in distance &&
distance.variableName &&
typeof offsetPlaneResult.pathToNode[1][0] === 'number'
) {
const insertIndex = Math.min(
offsetPlaneResult.pathToNode[1][0],
distance.insertIndex
)
const newBody = [...offsetPlaneResult.modifiedAst.body]
newBody.splice(insertIndex, 0, distance.variableDeclarationAst)
offsetPlaneResult.modifiedAst.body = newBody
// Since we inserted a new variable, we need to update the path to the extrude argument
offsetPlaneResult.pathToNode[1][0]++
}
const updateAstResult = await kclManager.updateAst(
offsetPlaneResult.modifiedAst,
true,