diff --git a/e2e/playwright/fixtures/toolbarFixture.ts b/e2e/playwright/fixtures/toolbarFixture.ts index e181c4651a..c2f26f3dec 100644 --- a/e2e/playwright/fixtures/toolbarFixture.ts +++ b/e2e/playwright/fixtures/toolbarFixture.ts @@ -14,6 +14,7 @@ export class ToolbarFixture { extrudeButton!: Locator loftButton!: Locator + sweepButton!: Locator shellButton!: Locator offsetPlaneButton!: Locator startSketchBtn!: Locator @@ -40,6 +41,7 @@ export class ToolbarFixture { this.page = page this.extrudeButton = page.getByTestId('extrude') this.loftButton = page.getByTestId('loft') + this.sweepButton = page.getByTestId('sweep') this.shellButton = page.getByTestId('shell') this.offsetPlaneButton = page.getByTestId('plane-offset') this.startSketchBtn = page.getByTestId('sketch') diff --git a/e2e/playwright/point-click.spec.ts b/e2e/playwright/point-click.spec.ts index 9e40f75937..51fba04285 100644 --- a/e2e/playwright/point-click.spec.ts +++ b/e2e/playwright/point-click.spec.ts @@ -934,6 +934,104 @@ loft001 = loft([sketch001, sketch002]) }) }) +test(`Sweep point-and-click`, async ({ + context, + page, + homePage, + scene, + editor, + toolbar, + cmdBar, +}) => { + const initialCode = `sketch001 = startSketchOn('YZ') + |> circle({ + center = [0, 0], + radius = 500 + }, %) +sketch002 = startSketchOn('XZ') + |> startProfileAt([0, 0], %) + |> xLine(-500, %) + |> tangentialArcTo([-2000, 500], %) +` + await context.addInitScript((initialCode) => { + localStorage.setItem('persistCode', initialCode) + }, initialCode) + await page.setBodyDimensions({ width: 1000, height: 500 }) + await homePage.goToModelingScene() + await scene.waitForExecutionDone() + + // One dumb hardcoded screen pixel value + const testPoint = { x: 700, y: 250 } + const [clickOnSketch1] = scene.makeMouseHelpers(testPoint.x, testPoint.y) + const [clickOnSketch2] = scene.makeMouseHelpers(testPoint.x - 50, testPoint.y) + const sweepDeclaration = 'sweep001 = sweep({ path = sketch002 }, sketch001)' + + await test.step(`Look for sketch001`, async () => { + await toolbar.closePane('code') + await scene.expectPixelColor([53, 53, 53], testPoint, 15) + }) + + await test.step(`Go through the command bar flow`, async () => { + await toolbar.sweepButton.click() + await cmdBar.expectState({ + commandName: 'Sweep', + currentArgKey: 'profile', + currentArgValue: '', + headerArguments: { + Path: '', + Profile: '', + }, + highlightedHeaderArg: 'profile', + stage: 'arguments', + }) + await clickOnSketch1() + await cmdBar.expectState({ + commandName: 'Sweep', + currentArgKey: 'path', + currentArgValue: '', + headerArguments: { + Path: '', + Profile: '1 face', + }, + highlightedHeaderArg: 'path', + stage: 'arguments', + }) + await clickOnSketch2() + await cmdBar.expectState({ + commandName: 'Sweep', + headerArguments: { + Path: '1 face', + Profile: '1 face', + }, + stage: 'review', + }) + await cmdBar.progressCmdBar() + }) + + await test.step(`Confirm code is added to the editor, scene has changed`, async () => { + await scene.expectPixelColor([135, 64, 73], testPoint, 15) + await toolbar.openPane('code') + await editor.expectEditor.toContain(sweepDeclaration) + await editor.expectState({ + diagnostics: [], + activeLines: [sweepDeclaration], + highlightedCode: '', + }) + await toolbar.closePane('code') + }) + + await test.step('Delete sweep via feature tree selection', async () => { + await toolbar.openPane('feature-tree') + await page.waitForTimeout(500) + const operationButton = await toolbar.getFeatureTreeOperation('Sweep', 0) + await operationButton.click({ button: 'left' }) + await page.keyboard.press('Backspace') + await page.waitForTimeout(500) + await toolbar.closePane('feature-tree') + await scene.expectPixelColor([53, 53, 53], testPoint, 15) + }) +}) + const shellPointAndClickCapCases = [ { shouldPreselect: true }, { shouldPreselect: false }, diff --git a/src/lang/modifyAst.ts b/src/lang/modifyAst.ts index dc346c61ab..19638417b3 100644 --- a/src/lang/modifyAst.ts +++ b/src/lang/modifyAst.ts @@ -374,6 +374,37 @@ export function loftSketches( } } +export function addSweep( + node: Node, + profileDeclarator: VariableDeclarator, + pathDeclarator: VariableDeclarator +): { + modifiedAst: Node + pathToNode: PathToNode +} { + const modifiedAst = structuredClone(node) + const name = findUniqueName(node, KCL_DEFAULT_CONSTANT_PREFIXES.SWEEP) + const sweep = createCallExpressionStdLib('sweep', [ + createObjectExpression({ path: createIdentifier(pathDeclarator.id.name) }), + createIdentifier(profileDeclarator.id.name), + ]) + const declaration = createVariableDeclaration(name, sweep) + modifiedAst.body.push(declaration) + const pathToNode: PathToNode = [ + ['body', ''], + [modifiedAst.body.length - 1, 'index'], + ['declaration', 'VariableDeclaration'], + ['init', 'VariableDeclarator'], + ['arguments', 'CallExpression'], + [0, 'index'], + ] + + return { + modifiedAst, + pathToNode, + } +} + export function revolveSketch( node: Node, pathToNode: PathToNode, diff --git a/src/lang/std/artifactGraph.ts b/src/lang/std/artifactGraph.ts index 9ce7e3763f..7292a4a7a3 100644 --- a/src/lang/std/artifactGraph.ts +++ b/src/lang/std/artifactGraph.ts @@ -77,7 +77,7 @@ interface SegmentArtifactRich extends BaseArtifact { /** A Sweep is a more generic term for extrude, revolve, loft and sweep*/ interface SweepArtifact extends BaseArtifact { type: 'sweep' - subType: 'extrusion' | 'revolve' | 'loft' + subType: 'extrusion' | 'revolve' | 'loft' | 'sweep' pathId: string surfaceIds: Array edgeIds: Array @@ -85,7 +85,7 @@ interface SweepArtifact extends BaseArtifact { } interface SweepArtifactRich extends BaseArtifact { type: 'sweep' - subType: 'extrusion' | 'revolve' | 'loft' + subType: 'extrusion' | 'revolve' | 'loft' | 'sweep' path: PathArtifact surfaces: Array edges: Array @@ -377,7 +377,11 @@ export function getArtifactsToUpdate({ }) } return returnArr - } else if (cmd.type === 'extrude' || cmd.type === 'revolve') { + } else if ( + cmd.type === 'extrude' || + cmd.type === 'revolve' || + cmd.type === 'sweep' + ) { const subType = cmd.type === 'extrude' ? 'extrusion' : cmd.type returnArr.push({ id, diff --git a/src/lib/commandBarConfigs/modelingCommandConfig.ts b/src/lib/commandBarConfigs/modelingCommandConfig.ts index e336d6c8cd..d2aa753bbf 100644 --- a/src/lib/commandBarConfigs/modelingCommandConfig.ts +++ b/src/lib/commandBarConfigs/modelingCommandConfig.ts @@ -37,6 +37,10 @@ export type ModelingCommandSchema = { // result: (typeof EXTRUSION_RESULTS)[number] distance: KclCommandValue } + Sweep: { + path: Selections + profile: Selections + } Loft: { selection: Selections } @@ -292,6 +296,33 @@ export const modelingMachineCommandConfig: StateMachineCommandSetConfig< }, }, }, + Sweep: { + description: + 'Create a 3D body by moving a sketch region along an arbitrary path.', + icon: 'sweep', + status: 'development', + needsReview: true, + args: { + profile: { + inputType: 'selection', + selectionTypes: ['solid2D'], + required: true, + skip: true, + multiple: false, + // TODO: add dry-run validation + warningMessage: + 'The sweep workflow is new and under tested. Please break it and report issues.', + }, + path: { + inputType: 'selection', + selectionTypes: ['segment', 'path'], + required: true, + skip: true, + multiple: false, + // TODO: add dry-run validation + }, + }, + }, Loft: { description: 'Create a 3D body by blending between two or more sketches', icon: 'loft', diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 40417c262d..ed62bc3a80 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -53,6 +53,7 @@ export const KCL_DEFAULT_CONSTANT_PREFIXES = { SKETCH: 'sketch', EXTRUDE: 'extrude', LOFT: 'loft', + SWEEP: 'sweep', SHELL: 'shell', SEGMENT: 'seg', REVOLVE: 'revolve', diff --git a/src/lib/toolbar.ts b/src/lib/toolbar.ts index ac68ff6783..7736cc838b 100644 --- a/src/lib/toolbar.ts +++ b/src/lib/toolbar.ts @@ -119,17 +119,21 @@ export const toolbarConfig: Record = { }, { id: 'sweep', - onClick: () => console.error('Sweep not yet implemented'), + onClick: ({ commandBarSend }) => + commandBarSend({ + type: 'Find and select command', + data: { name: 'Sweep', groupId: 'modeling' }, + }), icon: 'sweep', - status: 'unavailable', + status: DEV || IS_NIGHTLY_OR_DEBUG ? 'available' : 'kcl-only', title: 'Sweep', hotkey: 'W', description: 'Create a 3D body by moving a sketch region along an arbitrary path.', links: [ { - label: 'GitHub discussion', - url: 'https://github.com/KittyCAD/modeling-app/discussions/498', + label: 'KCL docs', + url: 'https://zoo.dev/docs/kcl/sweep', }, ], }, diff --git a/src/machines/modelingMachine.ts b/src/machines/modelingMachine.ts index aa8726772b..cfde08e9c0 100644 --- a/src/machines/modelingMachine.ts +++ b/src/machines/modelingMachine.ts @@ -45,6 +45,7 @@ import { import { revolveSketch } from 'lang/modifyAst/addRevolve' import { addOffsetPlane, + addSweep, deleteFromSelection, extrudeSketch, loftSketches, @@ -266,6 +267,7 @@ export type ModelingMachineEvent = | { type: 'Export'; data: ModelingCommandSchema['Export'] } | { type: 'Make'; data: ModelingCommandSchema['Make'] } | { type: 'Extrude'; data?: ModelingCommandSchema['Extrude'] } + | { type: 'Sweep'; data?: ModelingCommandSchema['Sweep'] } | { type: 'Loft'; data?: ModelingCommandSchema['Loft'] } | { type: 'Shell'; data?: ModelingCommandSchema['Shell'] } | { type: 'Revolve'; data?: ModelingCommandSchema['Revolve'] } @@ -1544,6 +1546,66 @@ export const modelingMachine = setup({ } } ), + sweepAstMod: fromPromise( + async ({ + input, + }: { + input: ModelingCommandSchema['Sweep'] | undefined + }) => { + if (!input) return new Error('No input provided') + // Extract inputs + const ast = kclManager.ast + const { profile, path } = input + + // Find the profile declaration + const profileNodePath = getNodePathFromSourceRange( + ast, + profile.graphSelections[0].codeRef.range + ) + const profileNode = getNodeFromPath( + ast, + profileNodePath, + 'VariableDeclarator' + ) + if (err(profileNode)) { + return new Error("Couldn't parse profile selection") + } + const profileDeclarator = profileNode.node + + // Find the path declaration + const pathNodePath = getNodePathFromSourceRange( + ast, + path.graphSelections[0].codeRef.range + ) + const pathNode = getNodeFromPath( + ast, + pathNodePath, + 'VariableDeclarator' + ) + if (err(pathNode)) { + return new Error("Couldn't parse path selection") + } + const pathDeclarator = pathNode.node + + // Perform the sweep + const sweepRes = addSweep(ast, profileDeclarator, pathDeclarator) + const updateAstResult = await kclManager.updateAst( + sweepRes.modifiedAst, + true, + { + focusPath: [sweepRes.pathToNode], + } + ) + + await codeManager.updateEditorWithAstAndWriteToFile( + updateAstResult.newAst + ) + + if (updateAstResult?.selections) { + editorManager.selectRange(updateAstResult?.selections) + } + } + ), loftAstMod: fromPromise( async ({ input, @@ -1739,6 +1801,11 @@ export const modelingMachine = setup({ reenter: false, }, + Sweep: { + target: 'Applying sweep', + reenter: true, + }, + Loft: { target: 'Applying loft', reenter: true, @@ -2531,6 +2598,19 @@ export const modelingMachine = setup({ }, }, + 'Applying sweep': { + invoke: { + src: 'sweepAstMod', + id: 'sweepAstMod', + input: ({ event }) => { + if (event.type !== 'Sweep') return undefined + return event.data + }, + onDone: ['idle'], + onError: ['idle'], + }, + }, + 'Applying loft': { invoke: { src: 'loftAstMod',