Add edge and segment selection in point-and-click Helix flow

Fixes #5393
This commit is contained in:
Pierre Jacquier
2025-03-19 13:07:57 -04:00
26 changed files with 686 additions and 55 deletions

View File

@ -4,7 +4,7 @@ KCL_WASM_LIB_FILES := $(wildcard rust/**/*.rs)
TS_SRC := $(wildcard src/**/*.tsx) $(wildcard src/**/*.ts) TS_SRC := $(wildcard src/**/*.tsx) $(wildcard src/**/*.ts)
XSTATE_TYPEGENS := $(wildcard src/machines/*.typegen.ts) XSTATE_TYPEGENS := $(wildcard src/machines/*.typegen.ts)
dev: node_modules public/wasm_lib_bg.wasm $(XSTATE_TYPEGENS) dev: node_modules public/kcl_wasm_lib_bg.wasm $(XSTATE_TYPEGENS)
yarn start yarn start
# I'm sorry this is so specific to my setup you may as well ignore this. # I'm sorry this is so specific to my setup you may as well ignore this.

View File

@ -2,7 +2,7 @@ import { test, expect } from './zoo-test'
import * as fsp from 'fs/promises' import * as fsp from 'fs/promises'
import { executorInputPath, getUtils } from './test-utils' import { executorInputPath, getUtils } from './test-utils'
import { KCL_DEFAULT_LENGTH } from 'lib/constants' import { KCL_DEFAULT_LENGTH } from 'lib/constants'
import path from 'path' import path, { join } from 'path'
test.describe('Command bar tests', { tag: ['@skipWin'] }, () => { test.describe('Command bar tests', { tag: ['@skipWin'] }, () => {
test('Extrude from command bar selects extrude line after', async ({ test('Extrude from command bar selects extrude line after', async ({
@ -487,4 +487,53 @@ test.describe('Command bar tests', { tag: ['@skipWin'] }, () => {
await toolbar.expectFileTreeState(['main.kcl', 'test.kcl']) await toolbar.expectFileTreeState(['main.kcl', 'test.kcl'])
}) })
}) })
test(`Can add a named parameter or constant`, async ({
page,
homePage,
context,
cmdBar,
scene,
editor,
}) => {
const projectName = 'test'
const beforeKclCode = `a = 5
b = a * a
c = 3 + a`
await context.folderSetupFn(async (dir) => {
const testProject = join(dir, projectName)
await fsp.mkdir(testProject, { recursive: true })
await fsp.writeFile(join(testProject, 'main.kcl'), beforeKclCode, 'utf-8')
})
await homePage.openProject(projectName)
// TODO: you probably shouldn't need an engine connection to add a parameter,
// but you do because all modeling commands have that requirement
await scene.settled(cmdBar)
await test.step(`Go through the command palette flow`, async () => {
await cmdBar.cmdBarOpenBtn.click()
await cmdBar.chooseCommand('create parameter')
await cmdBar.expectState({
stage: 'arguments',
commandName: 'Create parameter',
currentArgKey: 'value',
currentArgValue: '5',
headerArguments: {
Value: '',
},
highlightedHeaderArg: 'value',
})
await cmdBar.argumentInput.locator('[contenteditable]').fill(`b - 5`)
// TODO: we have no loading indicator for the KCL argument input calculation
await page.waitForTimeout(100)
await cmdBar.progressCmdBar()
await cmdBar.expectState({
stage: 'commandBarClosed',
})
})
await editor.expectEditor.toContain(
`a = 5b = a * amyParameter001 = b - 5c = 3 + a`
)
})
}) })

View File

@ -231,9 +231,9 @@ test.describe('Feature Tree pane', () => {
|> circle(center = [0, 0], radius = 5) |> circle(center = [0, 0], radius = 5)
renamedExtrude = extrude(sketch001, length = ${initialInput})` renamedExtrude = extrude(sketch001, length = ${initialInput})`
const newConstantName = 'distance001' const newConstantName = 'distance001'
const expectedCode = `sketch001 = startSketchOn('XZ') const expectedCode = `${newConstantName} = 23
sketch001 = startSketchOn('XZ')
|> circle(center = [0, 0], radius = 5) |> circle(center = [0, 0], radius = 5)
${newConstantName} = 23
renamedExtrude = extrude(sketch001, length = ${newConstantName})` renamedExtrude = extrude(sketch001, length = ${newConstantName})`
await context.folderSetupFn(async (dir) => { await context.folderSetupFn(async (dir) => {

View File

@ -1879,6 +1879,119 @@ fillet04 = fillet(extrude001, radius = 5, tags = [getOppositeEdge(seg02)])
}) })
}) })
test(`Fillet with large radius should update code even if engine fails`, async ({
context,
page,
homePage,
scene,
editor,
toolbar,
cmdBar,
}) => {
// Create a cube with small edges that will cause some fillets to fail
const initialCode = `sketch001 = startSketchOn('XY')
profile001 = startProfileAt([0, 0], sketch001)
|> yLine(length = -1)
|> xLine(length = -10)
|> yLine(length = 10)
|> line(endAbsolute = [profileStartX(%), profileStartY(%)])
|> close()
extrude001 = extrude(profile001, length = 5)
`
const taggedSegment = `yLine(length = -1, tag = $seg01)`
const filletExpression = `fillet(radius = 1000, tags = [getNextAdjacentEdge(seg01)])`
// Locators
const edgeLocation = { x: 659, y: 313 }
const bodyLocation = { x: 594, y: 313 }
// Colors
const edgeColorWhite: [number, number, number] = [248, 248, 248]
const edgeColorYellow: [number, number, number] = [251, 251, 120] // Mac:B=251,251,90 Ubuntu:240,241,180, Windows:240,241,180
const backgroundColor: [number, number, number] = [30, 30, 30]
const bodyColor: [number, number, number] = [155, 155, 155]
const lowTolerance = 20
const highTolerance = 70
// Setup
await test.step(`Initial test setup`, async () => {
await context.addInitScript((initialCode) => {
localStorage.setItem('persistCode', initialCode)
}, initialCode)
await page.setBodyDimensions({ width: 1000, height: 500 })
await homePage.goToModelingScene()
// verify modeling scene is loaded
await scene.expectPixelColor(backgroundColor, edgeLocation, lowTolerance)
// wait for stream to load
await scene.expectPixelColor(bodyColor, bodyLocation, highTolerance)
})
// Test
await test.step('Select edges and apply oversized fillet', async () => {
await test.step(`Select the edge`, async () => {
await scene.expectPixelColor(edgeColorWhite, edgeLocation, lowTolerance)
const [clickOnTheEdge] = scene.makeMouseHelpers(
edgeLocation.x,
edgeLocation.y
)
await clickOnTheEdge()
await scene.expectPixelColor(
edgeColorYellow,
edgeLocation,
highTolerance // Ubuntu color mismatch can require high tolerance
)
})
await test.step(`Apply fillet`, async () => {
await page.waitForTimeout(100)
await toolbar.filletButton.click()
await cmdBar.expectState({
commandName: 'Fillet',
highlightedHeaderArg: 'selection',
currentArgKey: 'selection',
currentArgValue: '',
headerArguments: {
Selection: '',
Radius: '',
},
stage: 'arguments',
})
await cmdBar.progressCmdBar()
await cmdBar.expectState({
commandName: 'Fillet',
highlightedHeaderArg: 'radius',
currentArgKey: 'radius',
currentArgValue: '5',
headerArguments: {
Selection: '1 sweepEdge',
Radius: '',
},
stage: 'arguments',
})
// Set a large radius (1000)
await cmdBar.currentArgumentInput.locator('.cm-content').fill('1000')
await cmdBar.progressCmdBar()
await cmdBar.expectState({
commandName: 'Fillet',
headerArguments: {
Selection: '1 sweepEdge',
Radius: '1000',
},
stage: 'review',
})
// Apply fillet with large radius
await cmdBar.progressCmdBar()
})
})
await test.step('Verify code is updated regardless of execution errors', async () => {
await editor.expectEditor.toContain(taggedSegment)
await editor.expectEditor.toContain(filletExpression)
})
})
test(`Chamfer point-and-click`, async ({ test(`Chamfer point-and-click`, async ({
context, context,
page, page,

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 74 KiB

View File

@ -114,7 +114,7 @@ export const CommandBar = () => {
leaveTo="opacity-0 scale-95" leaveTo="opacity-0 scale-95"
> >
<WrapperComponent.Panel <WrapperComponent.Panel
className="relative z-50 pointer-events-auto w-full max-w-xl py-2 mx-auto border rounded rounded-tl-none shadow-lg bg-chalkboard-10 dark:bg-chalkboard-100 dark:border-chalkboard-70" className="relative z-50 pointer-events-auto w-full max-w-xl pt-2 mx-auto border rounded rounded-tl-none shadow-lg bg-chalkboard-10 dark:bg-chalkboard-100 dark:border-chalkboard-70"
as="div" as="div"
data-testid="command-bar" data-testid="command-bar"
> >

View File

@ -81,7 +81,8 @@ function CommandBarKclInput({
const [value, setValue] = useState(initialValue) const [value, setValue] = useState(initialValue)
const [createNewVariable, setCreateNewVariable] = useState( const [createNewVariable, setCreateNewVariable] = useState(
(previouslySetValue && 'variableName' in previouslySetValue) || (previouslySetValue && 'variableName' in previouslySetValue) ||
arg.createVariableByDefault || arg.createVariable === 'byDefault' ||
arg.createVariable === 'force' ||
false false
) )
const [canSubmit, setCanSubmit] = useState(true) const [canSubmit, setCanSubmit] = useState(true)
@ -248,7 +249,7 @@ function CommandBarKclInput({
</span> </span>
</label> </label>
{createNewVariable ? ( {createNewVariable ? (
<div className="flex items-baseline gap-4 mx-4 border-solid border-0 border-b border-chalkboard-50"> <div className="flex mb-2 items-baseline gap-4 mx-4 border-solid border-0 border-b border-chalkboard-50">
<label <label
htmlFor="variable-name" htmlFor="variable-name"
className="text-base text-chalkboard-80 dark:text-chalkboard-20" className="text-base text-chalkboard-80 dark:text-chalkboard-20"
@ -269,7 +270,11 @@ function CommandBarKclInput({
autoFocus autoFocus
onChange={(e) => setNewVariableName(e.target.value)} onChange={(e) => setNewVariableName(e.target.value)}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.currentTarget.value === '' && e.key === 'Backspace') { if (
e.currentTarget.value === '' &&
e.key === 'Backspace' &&
arg.createVariable !== 'force'
) {
setCreateNewVariable(false) setCreateNewVariable(false)
} }
}} }}
@ -290,6 +295,7 @@ function CommandBarKclInput({
</span> </span>
</div> </div>
) : ( ) : (
arg.createVariable !== 'disallow' && (
<div className="flex justify-between gap-2 px-4"> <div className="flex justify-between gap-2 px-4">
<button <button
onClick={() => setCreateNewVariable(true)} onClick={() => setCreateNewVariable(true)}
@ -299,6 +305,7 @@ function CommandBarKclInput({
Create new variable Create new variable
</button> </button>
</div> </div>
)
)} )}
</form> </form>
) )

View File

@ -0,0 +1,51 @@
import { Menu } from '@headlessui/react'
import { PropsWithChildren } from 'react'
import { ActionIcon } from 'components/ActionIcon'
import styles from './KclEditorMenu.module.css'
import { commandBarActor } from 'machines/commandBarMachine'
export const FeatureTreeMenu = ({ children }: PropsWithChildren) => {
return (
<Menu>
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
<div
className="relative"
onClick={(e) => {
const target = e.target as HTMLElement
if (e.eventPhase === 3 && target.closest('a') === null) {
e.stopPropagation()
e.preventDefault()
}
}}
>
<Menu.Button className="!p-0 !bg-transparent hover:text-primary border-transparent dark:!border-transparent hover:!border-primary dark:hover:!border-chalkboard-70 ui-open:!border-primary dark:ui-open:!border-chalkboard-70 !outline-none">
<ActionIcon
icon="three-dots"
className="p-1"
size="sm"
bgClassName="bg-transparent dark:bg-transparent"
iconClassName={'!text-chalkboard-90 dark:!text-chalkboard-40'}
/>
</Menu.Button>
<Menu.Items className="absolute right-0 left-auto w-72 flex flex-col gap-1 divide-y divide-chalkboard-20 dark:divide-chalkboard-70 align-stretch px-0 py-1 bg-chalkboard-10 dark:bg-chalkboard-100 rounded-sm shadow-lg border border-solid border-chalkboard-20/50 dark:border-chalkboard-80/50">
<Menu.Item>
<button
onClick={() =>
commandBarActor.send({
type: 'Find and select command',
data: {
groupId: 'modeling',
name: 'event.parameter.create',
},
})
}
className={styles.button}
>
<span>Create parameter</span>
</button>
</Menu.Item>
</Menu.Items>
</div>
</Menu>
)
}

View File

@ -19,6 +19,7 @@ import { ContextFrom } from 'xstate'
import { settingsMachine } from 'machines/settingsMachine' import { settingsMachine } from 'machines/settingsMachine'
import { FeatureTreePane } from './FeatureTreePane' import { FeatureTreePane } from './FeatureTreePane'
import { kclErrorsByFilename } from 'lang/errors' import { kclErrorsByFilename } from 'lang/errors'
import { FeatureTreeMenu } from './FeatureTreeMenu'
export type SidebarType = export type SidebarType =
| 'code' | 'code'
@ -85,6 +86,7 @@ export const sidebarPanes: SidebarPane[] = [
id={props.id} id={props.id}
icon="model" icon="model"
title="Feature Tree" title="Feature Tree"
Menu={FeatureTreeMenu}
onClose={props.onClose} onClose={props.onClose}
/> />
<FeatureTreePane /> <FeatureTreePane />

View File

@ -0,0 +1,83 @@
/**
* Modeling Workflows
*
* This module contains higher-level CAD operation workflows that
* coordinate between different subsystems in the modeling app:
* AST, code editor, file system and 3D engine.
*/
import { Node } from '@rust/kcl-lib/bindings/Node'
import { KclManager } from 'lang/KclSingleton'
import { PathToNode, Program, SourceRange } from 'lang/wasm'
import EditorManager from 'editor/manager'
import CodeManager from 'lang/codeManager'
/**
* Updates the complete modeling state:
* AST, code editor, file, and 3D scene.
*
* Steps:
* 1. Updates the AST and internal state
* 2. Updates the code editor and writes to file
* 3. Sets focus in the editor if needed
* 4. Attempts to execute the model in the 3D engine
*
* This function follows common CAD application patterns where:
*
* - The feature tree reflects user's actions
* - The engine does its best to visualize what's possible
* - Invalid operations appear in feature tree but may not render fully
*
* This ensures the user can edit the feature tree,
* regardless of geometric validity issues.
*
* @param ast - AST to commit
* @param dependencies - Required system components
* @param options - Optional parameters for focus, zoom, etc.
*/
export async function updateModelingState(
ast: Node<Program>,
dependencies: {
kclManager: KclManager
editorManager: EditorManager
codeManager: CodeManager
},
options?: {
focusPath?: Array<PathToNode>
zoomToFit?: boolean
zoomOnRangeAndType?: {
range: SourceRange
type: string
}
}
): Promise<void> {
// Step 1: Update AST without executing (prepare selections)
const updatedAst = await dependencies.kclManager.updateAst(
ast,
false, // Execution handled separately for error resilience
options
)
// Step 2: Update the code editor and save file
await dependencies.codeManager.updateEditorWithAstAndWriteToFile(
updatedAst.newAst
)
// Step 3: Set focus on the newly added code if needed
if (updatedAst.selections) {
dependencies.editorManager.selectRange(updatedAst.selections)
}
// Step 4: Try to execute the new code in the engine
// and continue regardless of errors
try {
await dependencies.kclManager.executeAst({
ast: updatedAst.newAst,
zoomToFit: options?.zoomToFit,
zoomOnRangeAndType: options?.zoomOnRangeAndType,
})
} catch (e) {
console.error('Engine execution error (UI is still updated):', e)
}
}

View File

@ -223,8 +223,8 @@ export function findUniqueName(
if (!nameIsInString) return name if (!nameIsInString) return name
// recursive case: name is not unique and ends in digits // recursive case: name is not unique and ends in digits
const newPad = nameEndsInDigits[1].length const newPad = nameEndsInDigits[0].length
const newIndex = parseInt(nameEndsInDigits[1]) + 1 const newIndex = parseInt(nameEndsInDigits[0]) + 1
const nameWithoutDigits = name.replace(endingDigitsMatcher, '') const nameWithoutDigits = name.replace(endingDigitsMatcher, '')
return findUniqueName(searchStr, nameWithoutDigits, newPad, newIndex) return findUniqueName(searchStr, nameWithoutDigits, newPad, newIndex)

View File

@ -43,6 +43,7 @@ import { KclManager } from 'lang/KclSingleton'
import { EngineCommandManager } from 'lang/std/engineConnection' import { EngineCommandManager } from 'lang/std/engineConnection'
import EditorManager from 'editor/manager' import EditorManager from 'editor/manager'
import CodeManager from 'lang/codeManager' import CodeManager from 'lang/codeManager'
import { updateModelingState } from 'lang/modelingWorkflows'
// Edge Treatment Types // Edge Treatment Types
export enum EdgeTreatmentType { export enum EdgeTreatmentType {
@ -83,7 +84,17 @@ export async function applyEdgeTreatmentToSelection(
const { modifiedAst, pathToEdgeTreatmentNode } = result const { modifiedAst, pathToEdgeTreatmentNode } = result
// 2. update ast // 2. update ast
await updateAstAndFocus(modifiedAst, pathToEdgeTreatmentNode, dependencies) await updateModelingState(
modifiedAst,
{
kclManager: dependencies.kclManager,
editorManager: dependencies.editorManager,
codeManager: dependencies.codeManager,
},
{
focusPath: pathToEdgeTreatmentNode,
}
)
} }
export function modifyAstWithEdgeTreatmentAndTag( export function modifyAstWithEdgeTreatmentAndTag(
@ -294,33 +305,6 @@ export function getPathToExtrudeForSegmentSelection(
return { pathToSegmentNode, pathToExtrudeNode } return { pathToSegmentNode, pathToExtrudeNode }
} }
async function updateAstAndFocus(
modifiedAst: Node<Program>,
pathToEdgeTreatmentNode: Array<PathToNode>,
dependencies: {
kclManager: KclManager
engineCommandManager: EngineCommandManager
editorManager: EditorManager
codeManager: CodeManager
}
): Promise<void> {
const updatedAst = await dependencies.kclManager.updateAst(
modifiedAst,
true,
{
focusPath: pathToEdgeTreatmentNode,
}
)
await dependencies.codeManager.updateEditorWithAstAndWriteToFile(
updatedAst.newAst
)
if (updatedAst?.selections) {
dependencies.editorManager.selectRange(updatedAst?.selections)
}
}
export function mutateAstWithTagForSketchSegment( export function mutateAstWithTagForSketchSegment(
astClone: Node<Program>, astClone: Node<Program>,
pathToSegmentNode: PathToNode pathToSegmentNode: PathToNode

View File

@ -201,6 +201,11 @@ export function traverse(
]) ])
} else if (_node.type === 'VariableDeclarator') { } else if (_node.type === 'VariableDeclarator') {
_traverse(_node.init, [...pathToNode, ['init', '']]) _traverse(_node.init, [...pathToNode, ['init', '']])
} else if (_node.type === 'ExpressionStatement') {
_traverse(_node.expression, [
...pathToNode,
['expression', 'ExpressionStatement'],
])
} else if (_node.type === 'PipeExpression') { } else if (_node.type === 'PipeExpression') {
_node.body.forEach((expression, index) => _node.body.forEach((expression, index) =>
_traverse(expression, [ _traverse(expression, [

View File

@ -0,0 +1,44 @@
import { assertParse, initPromise } from 'lang/wasm'
import { getIdentifiersInProgram } from './getIndentifiersInProgram'
function identifier(name: string, start: number, end: number) {
return {
type: 'Identifier',
name,
start,
end,
}
}
beforeAll(async () => {
await initPromise
})
describe(`getIdentifiersInProgram`, () => {
it(`finds no identifiers in an empty program`, () => {
const identifiers = getIdentifiersInProgram(assertParse(''))
expect(identifiers).toEqual([])
})
it(`finds a single identifier in an expression`, () => {
const identifiers = getIdentifiersInProgram(assertParse('55 + a'))
expect(identifiers).toEqual([identifier('a', 5, 6)])
})
it(`finds multiple identifiers in an expression`, () => {
const identifiers = getIdentifiersInProgram(assertParse('a + b + c'))
expect(identifiers).toEqual([
identifier('a', 0, 1),
identifier('b', 4, 5),
identifier('c', 8, 9),
])
})
it(`finds all the identifiers in a normal program`, () => {
const program = assertParse(`x = 5 + 2
y = x * 2
z = y + 1`)
const identifiers = getIdentifiersInProgram(program)
expect(identifiers).toEqual([
identifier('x', 14, 15),
identifier('y', 24, 25),
])
})
})

View File

@ -0,0 +1,21 @@
import { traverse } from 'lang/queryAst'
import { Expr, Identifier, Program } from 'lang/wasm'
import { Node } from '@rust/kcl-lib/bindings/Node'
/**
* Given an AST `Program`, return an array of
* all the `Identifier` nodes within.
*/
export function getIdentifiersInProgram(
program: Node<Program | Expr>
): Identifier[] {
const identifiers: Identifier[] = []
traverse(program, {
enter(node) {
if (node.type === 'Identifier') {
identifiers.push(node)
}
},
})
return identifiers
}

View File

@ -0,0 +1,64 @@
import { assertParse, initPromise } from 'lang/wasm'
import { getSafeInsertIndex } from './getSafeInsertIndex'
beforeAll(async () => {
await initPromise
})
describe(`getSafeInsertIndex`, () => {
it(`expression with no identifiers`, () => {
const baseProgram = assertParse(`x = 5 + 2
y = 2
z = x + y`)
const targetExpr = assertParse(`5`)
expect(getSafeInsertIndex(targetExpr, baseProgram)).toBe(0)
})
it(`expression with no identifiers in longer program`, () => {
const baseProgram = assertParse(`x = 5 + 2
profile001 = startProfileAt([0.07, 0], sketch001)
|> angledLine([0, x], %, $a)
|> angledLine([segAng(a) + 90, 5], %)
|> angledLine([segAng(a), -segLen(a)], %)
|> line(endAbsolute = [profileStartX(%), profileStartY(%)])
|> close()`)
const targetExpr = assertParse(`5`)
expect(getSafeInsertIndex(targetExpr, baseProgram)).toBe(0)
})
it(`expression with an identifier in the middle`, () => {
const baseProgram = assertParse(`x = 5 + 2
y = 2
z = x + y`)
const targetExpr = assertParse(`5 + y`)
expect(getSafeInsertIndex(targetExpr, baseProgram)).toBe(2)
})
it(`expression with an identifier at the end`, () => {
const baseProgram = assertParse(`x = 5 + 2
y = 2
z = x + y`)
const targetExpr = assertParse(`z * z`)
expect(getSafeInsertIndex(targetExpr, baseProgram)).toBe(3)
})
it(`expression with a tag declarator add to end`, () => {
const baseProgram = assertParse(`x = 5 + 2
profile001 = startProfileAt([0.07, 0], sketch001)
|> angledLine([0, x], %, $a)
|> angledLine([segAng(a) + 90, 5], %)
|> angledLine([segAng(a), -segLen(a)], %)
|> line(endAbsolute = [profileStartX(%), profileStartY(%)])
|> close()`)
const targetExpr = assertParse(`5 + segAng(a)`)
expect(getSafeInsertIndex(targetExpr, baseProgram)).toBe(2)
})
it(`expression with a tag declarator and variable in the middle`, () => {
const baseProgram = assertParse(`x = 5 + 2
profile001 = startProfileAt([0.07, 0], sketch001)
|> angledLine([0, x], %, $a)
|> angledLine([segAng(a) + 90, 5], %)
|> angledLine([segAng(a), -segLen(a)], %)
|> line(endAbsolute = [profileStartX(%), profileStartY(%)])
|> close()
y = x + x`)
const targetExpr = assertParse(`x + segAng(a)`)
expect(getSafeInsertIndex(targetExpr, baseProgram)).toBe(2)
})
})

View File

@ -0,0 +1,36 @@
import { getIdentifiersInProgram } from './getIndentifiersInProgram'
import { Program, Expr } from 'lang/wasm'
import { Node } from '@rust/kcl-lib/bindings/Node'
import { getTagDeclaratorsInProgram } from './getTagDeclaratorsInProgram'
/**
* Given a target expression, return the body index of the last-used variable
* or tag declaration within the provided program.
*/
export function getSafeInsertIndex(
targetExpr: Node<Program | Expr>,
program: Node<Program>
) {
const identifiers = getIdentifiersInProgram(targetExpr)
const safeIdentifierIndex = identifiers.reduce((acc, curr) => {
const bodyIndex = program.body.findIndex(
(a) =>
a.type === 'VariableDeclaration' && a.declaration.id?.name === curr.name
)
return Math.max(acc, bodyIndex + 1)
}, 0)
const tagDeclarators = getTagDeclaratorsInProgram(program)
console.log('FRANK tagDeclarators', {
identifiers,
tagDeclarators,
targetExpr,
})
const safeTagIndex = tagDeclarators.reduce((acc, curr) => {
return identifiers.findIndex((a) => a.name === curr.tag.value) === -1
? acc
: Math.max(acc, curr.bodyIndex + 1)
}, 0)
return Math.max(safeIdentifierIndex, safeTagIndex)
}

View File

@ -0,0 +1,68 @@
import { assertParse, initPromise } from 'lang/wasm'
import { getTagDeclaratorsInProgram } from './getTagDeclaratorsInProgram'
function tagDeclaratorWithIndex(
value: string,
start: number,
end: number,
bodyIndex: number
) {
return {
tag: {
type: 'TagDeclarator',
value,
start,
end,
},
bodyIndex,
}
}
beforeAll(async () => {
await initPromise
})
describe(`getTagDeclaratorsInProgram`, () => {
it(`finds no tag declarators in an empty program`, () => {
const tagDeclarators = getTagDeclaratorsInProgram(assertParse(''))
expect(tagDeclarators).toEqual([])
})
it(`finds a single tag declarators in a small program`, () => {
const tagDeclarators = getTagDeclaratorsInProgram(
assertParse(`sketch001 = startSketchOn('XZ')
profile001 = startProfileAt([0, 0], sketch001)
|> angledLine([0, 11], %, $a)`)
)
expect(tagDeclarators).toEqual([tagDeclaratorWithIndex('a', 107, 109, 1)])
})
it(`finds multiple tag declarators in a small program`, () => {
const program = `sketch001 = startSketchOn('XZ')
profile001 = startProfileAt([0.07, 0], sketch001)
|> angledLine([0, 11], %, $a)
|> angledLine([segAng(a) + 90, 11.17], %, $b)
|> angledLine([segAng(a), -segLen(a)], %, $c)
|> line(endAbsolute = [profileStartX(%), profileStartY(%)])
|> close()`
const tagDeclarators = getTagDeclaratorsInProgram(assertParse(program))
expect(tagDeclarators).toEqual([
tagDeclaratorWithIndex('a', 110, 112, 1),
tagDeclaratorWithIndex('b', 158, 160, 1),
tagDeclaratorWithIndex('c', 206, 208, 1),
])
})
it(`finds tag declarators at different indices`, () => {
const program = `sketch001 = startSketchOn('XZ')
profile001 = startProfileAt([0.07, 0], sketch001)
|> angledLine([0, 11], %, $a)
profile002 = angledLine([segAng(a) + 90, 11.17], profile001, $b)
|> angledLine([segAng(a), -segLen(a)], %, $c)
|> line(endAbsolute = [profileStartX(%), profileStartY(%)])
|> close()`
const tagDeclarators = getTagDeclaratorsInProgram(assertParse(program))
expect(tagDeclarators).toEqual([
tagDeclaratorWithIndex('a', 110, 112, 1),
tagDeclaratorWithIndex('b', 175, 177, 2),
tagDeclaratorWithIndex('c', 223, 225, 2),
])
})
})

View File

@ -0,0 +1,28 @@
import { getBodyIndex, traverse } from 'lang/queryAst'
import { Expr, Program } from 'lang/wasm'
import { Node } from '@rust/kcl-lib/bindings/Node'
import { TagDeclarator } from '@rust/kcl-lib/bindings/TagDeclarator'
import { err } from 'lib/trap'
type TagWithBodyIndex = { tag: TagDeclarator; bodyIndex: number }
/**
* Given an AST `Program`, return an array of
* TagDeclarator nodes within, and the body index of their source expression.
*/
export function getTagDeclaratorsInProgram(
program: Node<Program | Expr>
): TagWithBodyIndex[] {
const tagLocations: TagWithBodyIndex[] = []
traverse(program, {
enter(node, pathToNode) {
if (node.type === 'TagDeclarator') {
// Get the body index of the declarator's farthest ancestor
const bodyIndex = getBodyIndex(pathToNode)
if (err(bodyIndex)) return
tagLocations.push({ tag: node, bodyIndex })
}
},
})
return tagLocations
}

View File

@ -95,6 +95,9 @@ export type ModelingCommandSchema = {
radius: KclCommandValue radius: KclCommandValue
length: KclCommandValue length: KclCommandValue
} }
'event.parameter.create': {
value: KclCommandValue
}
'change tool': { 'change tool': {
tool: SketchTool tool: SketchTool
} }
@ -607,6 +610,22 @@ export const modelingMachineCommandConfig: StateMachineCommandSetConfig<
}, },
}, },
}, },
'event.parameter.create': {
displayName: 'Create parameter',
description: 'Add a named constant to use in geometry',
icon: 'make-variable',
status: 'development',
needsReview: false,
args: {
value: {
inputType: 'kcl',
required: true,
createVariable: 'force',
variableName: 'myParameter',
defaultValue: '5',
},
},
},
'Constrain length': { 'Constrain length': {
description: 'Constrain the length of one or more segments.', description: 'Constrain the length of one or more segments.',
icon: 'dimension', icon: 'dimension',
@ -621,7 +640,7 @@ export const modelingMachineCommandConfig: StateMachineCommandSetConfig<
length: { length: {
inputType: 'kcl', inputType: 'kcl',
required: true, required: true,
createVariableByDefault: true, createVariable: 'byDefault',
defaultValue(_, machineContext) { defaultValue(_, machineContext) {
const selectionRanges = machineContext?.selectionRanges const selectionRanges = machineContext?.selectionRanges
if (!selectionRanges) return KCL_DEFAULT_LENGTH if (!selectionRanges) return KCL_DEFAULT_LENGTH
@ -661,7 +680,7 @@ export const modelingMachineCommandConfig: StateMachineCommandSetConfig<
namedValue: { namedValue: {
inputType: 'kcl', inputType: 'kcl',
required: true, required: true,
createVariableByDefault: true, createVariable: 'byDefault',
variableName(commandBarContext, machineContext) { variableName(commandBarContext, machineContext) {
const { currentValue } = commandBarContext.argumentsToSubmit const { currentValue } = commandBarContext.argumentsToSubmit
if ( if (

View File

@ -186,7 +186,7 @@ export type CommandArgumentConfig<
} }
| { | {
inputType: 'kcl' inputType: 'kcl'
createVariableByDefault?: boolean createVariable?: 'byDefault' | 'force' | 'disallow'
variableName?: variableName?:
| string | string
| (( | ((
@ -306,7 +306,7 @@ export type CommandArgument<
} }
| { | {
inputType: 'kcl' inputType: 'kcl'
createVariableByDefault?: boolean createVariable?: 'byDefault' | 'force' | 'disallow'
variableName?: variableName?:
| string | string
| (( | ((

View File

@ -201,7 +201,7 @@ export function buildCommandArgument<
} else if (arg.inputType === 'kcl') { } else if (arg.inputType === 'kcl') {
return { return {
inputType: arg.inputType, inputType: arg.inputType,
createVariableByDefault: arg.createVariableByDefault, createVariable: arg.createVariable,
variableName: arg.variableName, variableName: arg.variableName,
defaultValue: arg.defaultValue, defaultValue: arg.defaultValue,
...baseCommandArgument, ...baseCommandArgument,

View File

@ -8,6 +8,7 @@ import { useEffect, useRef, useState } from 'react'
import { getCalculatedKclExpressionValue } from './kclHelpers' import { getCalculatedKclExpressionValue } from './kclHelpers'
import { parse, resultIsOk } from 'lang/wasm' import { parse, resultIsOk } from 'lang/wasm'
import { err } from 'lib/trap' import { err } from 'lib/trap'
import { getSafeInsertIndex } from 'lang/queryAst/getSafeInsertIndex'
const isValidVariableName = (name: string) => const isValidVariableName = (name: string) =>
/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name) /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)
@ -48,6 +49,7 @@ export function useCalculateKclExpression({
insertIndex: 0, insertIndex: 0,
bodyPath: [], bodyPath: [],
}) })
const [insertIndex, setInsertIndex] = useState(0)
const [valueNode, setValueNode] = useState<Expr | null>(null) const [valueNode, setValueNode] = useState<Expr | null>(null)
// Gotcha: If we do not attempt to parse numeric literals instantly it means that there is an async action to verify // Gotcha: If we do not attempt to parse numeric literals instantly it means that there is an async action to verify
// the value is good. This means all E2E tests have a race condition on when they can hit "next" in the command bar. // the value is good. This means all E2E tests have a race condition on when they can hit "next" in the command bar.
@ -101,11 +103,13 @@ export function useCalculateKclExpression({
useEffect(() => { useEffect(() => {
const execAstAndSetResult = async () => { const execAstAndSetResult = async () => {
const result = await getCalculatedKclExpressionValue(value) const result = await getCalculatedKclExpressionValue(value)
if (result instanceof Error || 'errors' in result) { if (result instanceof Error || 'errors' in result || !result.astNode) {
setCalcResult('NAN') setCalcResult('NAN')
setValueNode(null) setValueNode(null)
return return
} }
const newInsertIndex = getSafeInsertIndex(result.astNode, kclManager.ast)
setInsertIndex(newInsertIndex)
setCalcResult(result?.valueAsString || 'NAN') setCalcResult(result?.valueAsString || 'NAN')
result?.astNode && setValueNode(result.astNode) result?.astNode && setValueNode(result.astNode)
} }
@ -120,7 +124,7 @@ export function useCalculateKclExpression({
valueNode, valueNode,
calcResult, calcResult,
prevVariables: availableVarInfo.variables, prevVariables: availableVarInfo.variables,
newVariableInsertIndex: availableVarInfo.insertIndex, newVariableInsertIndex: insertIndex,
newVariableName, newVariableName,
isNewVariableNameUnique, isNewVariableNameUnique,
setNewVariableName, setNewVariableName,

View File

@ -53,6 +53,7 @@ import {
createIdentifier, createIdentifier,
createLiteral, createLiteral,
extrudeSketch, extrudeSketch,
insertNamedConstant,
loftSketches, loftSketches,
} from 'lang/modifyAst' } from 'lang/modifyAst'
import { import {
@ -309,6 +310,10 @@ export type ModelingMachineEvent =
| { type: 'Constrain parallel' } | { type: 'Constrain parallel' }
| { type: 'Constrain remove constraints'; data?: PathToNode } | { type: 'Constrain remove constraints'; data?: PathToNode }
| { type: 'Re-execute' } | { type: 'Re-execute' }
| {
type: 'event.parameter.create'
data: ModelingCommandSchema['event.parameter.create']
}
| { type: 'Export'; data: ModelingCommandSchema['Export'] } | { type: 'Export'; data: ModelingCommandSchema['Export'] }
| { type: 'Make'; data: ModelingCommandSchema['Make'] } | { type: 'Make'; data: ModelingCommandSchema['Make'] }
| { type: 'Extrude'; data?: ModelingCommandSchema['Extrude'] } | { type: 'Extrude'; data?: ModelingCommandSchema['Extrude'] }
@ -2313,6 +2318,32 @@ export const modelingMachine = setup({
if (err(filletResult)) return filletResult if (err(filletResult)) return filletResult
} }
), ),
'actor.parameter.create': fromPromise(
async ({
input,
}: {
input: ModelingCommandSchema['event.parameter.create'] | undefined
}) => {
if (!input) return new Error('No input provided')
const { value } = input
if (!('variableName' in value)) {
return new Error('variable name is required')
}
const newAst = insertNamedConstant({
node: kclManager.ast,
newExpression: value,
})
const updateAstResult = await kclManager.updateAst(newAst, true)
await codeManager.updateEditorWithAstAndWriteToFile(
updateAstResult.newAst
)
if (updateAstResult?.selections) {
editorManager.selectRange(updateAstResult?.selections)
}
}
),
'set-up-draft-circle': fromPromise( 'set-up-draft-circle': fromPromise(
async (_: { async (_: {
input: Pick<ModelingMachineContext, 'sketchDetails'> & { input: Pick<ModelingMachineContext, 'sketchDetails'> & {
@ -2561,6 +2592,10 @@ export const modelingMachine = setup({
reenter: true, reenter: true,
}, },
'event.parameter.create': {
target: '#Modeling.parameter.creating',
},
Export: { Export: {
target: 'Exporting', target: 'Exporting',
guard: 'Has exportable geometry', guard: 'Has exportable geometry',
@ -3861,6 +3896,24 @@ export const modelingMachine = setup({
}, },
}, },
parameter: {
type: 'parallel',
states: {
creating: {
invoke: {
src: 'actor.parameter.create',
id: 'actor.parameter.create',
input: ({ event }) => {
if (event.type !== 'event.parameter.create') return undefined
return event.data
},
onDone: ['#Modeling.idle'],
onError: ['#Modeling.idle'],
},
},
},
},
'Applying Prompt-to-edit': { 'Applying Prompt-to-edit': {
invoke: { invoke: {
src: 'submit-prompt-edit', src: 'submit-prompt-edit',