diff --git a/src/components/ModelingMachineProvider.tsx b/src/components/ModelingMachineProvider.tsx index 533d1121f5..88768b4a78 100644 --- a/src/components/ModelingMachineProvider.tsx +++ b/src/components/ModelingMachineProvider.tsx @@ -38,7 +38,7 @@ import { import { applyConstraintAngleLength } from './Toolbar/setAngleLength' import { Selections, - canExtrudeSelection, + canSweepSelection, handleSelectionBatch, isSelectionLastLine, isRangeInbetweenCharacters, @@ -62,8 +62,8 @@ import { } from 'lang/modifyAst' import { Program, parse, recast } from 'lang/wasm' import { + doesSceneHaveSweepableSketch, getNodePathFromSourceRange, - hasExtrudableGeometry, isSingleCursorInPipe, } from 'lang/queryAst' import { exportFromEngine } from 'lib/exportFromEngine' @@ -528,12 +528,32 @@ export const ModelingMachineProvider = ({ // they have no selection, we should enable the button // so they can select the face through the cmdbar // BUT only if there's extrudable geometry - if (hasExtrudableGeometry(kclManager.ast)) return true + if (doesSceneHaveSweepableSketch(kclManager.ast)) return true return false } if (!isPipe) return false - return canExtrudeSelection(selectionRanges) + return canSweepSelection(selectionRanges) + }, + 'has valid revolve selection': ({ context: { selectionRanges } }) => { + // A user can begin extruding if they either have 1+ faces selected or nothing selected + // TODO: I believe this guard only allows for extruding a single face at a time + const isPipe = isSketchPipe(selectionRanges) + + if ( + selectionRanges.codeBasedSelections.length === 0 || + isRangeInbetweenCharacters(selectionRanges) || + isSelectionLastLine(selectionRanges, codeManager.code) + ) { + // they have no selection, we should enable the button + // so they can select the face through the cmdbar + // BUT only if there's extrudable geometry + if (doesSceneHaveSweepableSketch(kclManager.ast)) return true + return false + } + if (!isPipe) return false + + return canSweepSelection(selectionRanges) }, 'has valid selection for deletion': ({ context: { selectionRanges }, diff --git a/src/lang/modifyAst.ts b/src/lang/modifyAst.ts index 0dda319d34..9d774c7b89 100644 --- a/src/lang/modifyAst.ts +++ b/src/lang/modifyAst.ts @@ -251,7 +251,7 @@ export function extrudeSketch( node: Program, pathToNode: PathToNode, shouldPipe = false, - distance = createLiteral(4) as Expr + distance: Expr = createLiteral(4) ): | { modifiedAst: Program @@ -259,7 +259,7 @@ export function extrudeSketch( pathToExtrudeArg: PathToNode } | Error { - const _node = { ...node } + const _node = structuredClone(node) const _node1 = getNodeFromPath(_node, pathToNode) if (err(_node1)) return _node1 const { node: sketchExpression } = _node1 @@ -342,6 +342,102 @@ export function extrudeSketch( } } +export function revolveSketch( + node: Program, + pathToNode: PathToNode, + shouldPipe = false, + angle: Expr = createLiteral(4) +): + | { + modifiedAst: Program + pathToNode: PathToNode + pathToRevolveArg: PathToNode + } + | Error { + const _node = structuredClone(node) + const _node1 = getNodeFromPath(_node, pathToNode) + if (err(_node1)) return _node1 + const { node: sketchExpression } = _node1 + + // determine if sketchExpression is in a pipeExpression or not + const _node2 = getNodeFromPath( + _node, + pathToNode, + 'PipeExpression' + ) + if (err(_node2)) return _node2 + const { node: pipeExpression } = _node2 + + const isInPipeExpression = pipeExpression.type === 'PipeExpression' + + const _node3 = getNodeFromPath( + _node, + pathToNode, + 'VariableDeclarator' + ) + if (err(_node3)) return _node3 + const { node: variableDeclarator, shallowPath: pathToDecleration } = _node3 + + const revolveCall = createCallExpressionStdLib('revolve', [ + createObjectExpression({ + angle: angle, + // TODO: hard coded 'X' axis for revolve MVP, should be changed. + axis: createLiteral('X'), + }), + createIdentifier(variableDeclarator.id.name), + ]) + + if (shouldPipe) { + const pipeChain = createPipeExpression( + isInPipeExpression + ? [...pipeExpression.body, revolveCall] + : [sketchExpression as any, revolveCall] + ) + + variableDeclarator.init = pipeChain + const pathToRevolveArg: PathToNode = [ + ...pathToDecleration, + ['init', 'VariableDeclarator'], + ['body', ''], + [pipeChain.body.length - 1, 'index'], + ['arguments', 'CallExpression'], + [0, 'index'], + ] + + return { + modifiedAst: _node, + pathToNode, + pathToRevolveArg, + } + } + + // We're not creating a pipe expression, + // but rather a separate constant for the extrusion + const name = findUniqueName(node, KCL_DEFAULT_CONSTANT_PREFIXES.REVOLVE) + const VariableDeclaration = createVariableDeclaration(name, revolveCall) + const sketchIndexInPathToNode = + pathToDecleration.findIndex((a) => a[0] === 'body') + 1 + const sketchIndexInBody = pathToDecleration[sketchIndexInPathToNode][0] + if (typeof sketchIndexInBody !== 'number') + return new Error('expected sketchIndexInBody to be a number') + _node.body.splice(sketchIndexInBody + 1, 0, VariableDeclaration) + + const pathToRevolveArg: PathToNode = [ + ['body', ''], + [sketchIndexInBody + 1, 'index'], + ['declarations', 'VariableDeclaration'], + [0, 'index'], + ['init', 'VariableDeclarator'], + ['arguments', 'CallExpression'], + [0, 'index'], + ] + return { + modifiedAst: _node, + pathToNode: [...pathToNode.slice(0, -1), [-1, 'index']], + pathToRevolveArg, + } +} + export function sketchOnExtrudedFace( node: Program, sketchPathToNode: PathToNode, diff --git a/src/lang/queryAst.test.ts b/src/lang/queryAst.test.ts index 7b076d2a73..9c91a14766 100644 --- a/src/lang/queryAst.test.ts +++ b/src/lang/queryAst.test.ts @@ -8,7 +8,7 @@ import { hasExtrudeSketchGroup, findUsesOfTagInPipe, hasSketchPipeBeenExtruded, - hasExtrudableGeometry, + doesSceneHaveSweepableSketch, traverse, } from './queryAst' import { enginelessExecutor } from '../lib/testHelpers' @@ -488,7 +488,7 @@ const sketch002 = startSketchOn(extrude001, $seg01) }) }) -describe('Testing hasExtrudableGeometry', () => { +describe('Testing doesSceneHaveSweepableSketch', () => { it('finds sketch001 pipe to be extruded', async () => { const exampleCode = `const sketch001 = startSketchOn('XZ') |> startProfileAt([3.29, 7.86], %) @@ -506,7 +506,7 @@ const sketch002 = startSketchOn(extrude001, $seg01) ` const ast = parse(exampleCode) if (err(ast)) throw ast - const extrudable = hasExtrudableGeometry(ast) + const extrudable = doesSceneHaveSweepableSketch(ast) expect(extrudable).toBeTruthy() }) it('find sketch002 NOT pipe to be extruded', async () => { @@ -520,7 +520,7 @@ const extrude001 = extrude(10, sketch001) ` const ast = parse(exampleCode) if (err(ast)) throw ast - const extrudable = hasExtrudableGeometry(ast) + const extrudable = doesSceneHaveSweepableSketch(ast) expect(extrudable).toBeFalsy() }) }) diff --git a/src/lang/queryAst.ts b/src/lang/queryAst.ts index 8fcfd2ad9f..3e1b5812bf 100644 --- a/src/lang/queryAst.ts +++ b/src/lang/queryAst.ts @@ -880,7 +880,7 @@ export function hasSketchPipeBeenExtruded(selection: Selection, ast: Program) { if ( node.type === 'CallExpression' && node.callee.type === 'Identifier' && - node.callee.name === 'extrude' && + (node.callee.name === 'extrude' || node.callee.name === 'revolve') && node.arguments?.[1]?.type === 'Identifier' && node.arguments[1].name === varDec.id.name ) { @@ -892,7 +892,7 @@ export function hasSketchPipeBeenExtruded(selection: Selection, ast: Program) { } /** File must contain at least one sketch that has not been extruded already */ -export function hasExtrudableGeometry(ast: Program) { +export function doesSceneHaveSweepableSketch(ast: Program) { const theMap: any = {} traverse(ast as any, { enter(node) { @@ -925,7 +925,7 @@ export function hasExtrudableGeometry(ast: Program) { } } else if ( node.type === 'CallExpression' && - node.callee.name === 'extrude' && + (node.callee.name === 'extrude' || node.callee.name === 'revolve') && node.arguments[1]?.type === 'Identifier' && theMap?.[node?.arguments?.[1]?.name] ) { diff --git a/src/lib/commandBarConfigs/modelingCommandConfig.ts b/src/lib/commandBarConfigs/modelingCommandConfig.ts index bf1d9c2729..081d708736 100644 --- a/src/lib/commandBarConfigs/modelingCommandConfig.ts +++ b/src/lib/commandBarConfigs/modelingCommandConfig.ts @@ -1,6 +1,6 @@ import { Models } from '@kittycad/lib' import { StateMachineCommandSetConfig, KclCommandValue } from 'lib/commandTypes' -import { KCL_DEFAULT_LENGTH } from 'lib/constants' +import { KCL_DEFAULT_LENGTH, KCL_DEFAULT_DEGREE } from 'lib/constants' import { components } from 'lib/machine-api' import { Selections } from 'lib/selections' import { machineManager } from 'lib/machineManager' @@ -32,6 +32,10 @@ export type ModelingCommandSchema = { // result: (typeof EXTRUSION_RESULTS)[number] distance: KclCommandValue } + Revolve: { + selection: Selections + angle: KclCommandValue + } Fillet: { // todo selection: Selections @@ -209,6 +213,7 @@ export const modelingMachineCommandConfig: StateMachineCommandSetConfig< args: { selection: { inputType: 'selection', + // TODO: These are products of an extrude selectionTypes: ['extrude-wall', 'start-cap', 'end-cap'], multiple: false, // TODO: multiple selection required: true, @@ -232,6 +237,26 @@ export const modelingMachineCommandConfig: StateMachineCommandSetConfig< }, }, }, + // TODO: Update this configuration, copied from extrude for MVP of revolve, specifically the args.selection + Revolve: { + description: 'Create a 3D body by rotating a sketch region about an axis.', + icon: 'revolve', + needsReview: true, + args: { + selection: { + inputType: 'selection', + selectionTypes: ['extrude-wall', 'start-cap', 'end-cap'], + multiple: false, // TODO: multiple selection + required: true, + skip: true, + }, + angle: { + inputType: 'kcl', + defaultValue: KCL_DEFAULT_DEGREE, + required: true, + }, + }, + }, Fillet: { // todo description: 'Fillet edge', diff --git a/src/lib/constants.ts b/src/lib/constants.ts index cacd8d312f..2541ce53e6 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -53,9 +53,14 @@ export const KCL_DEFAULT_CONSTANT_PREFIXES = { SKETCH: 'sketch', EXTRUDE: 'extrude', SEGMENT: 'seg', + REVOLVE: 'revolve', } as const /** The default KCL length expression */ export const KCL_DEFAULT_LENGTH = `5` + +/** The default KCL degree expression */ +export const KCL_DEFAULT_DEGREE = `360` + /** localStorage key for the playwright test-specific app settings file */ export const TEST_SETTINGS_FILE_KEY = 'playwright-test-settings' diff --git a/src/lib/createMachineCommand.ts b/src/lib/createMachineCommand.ts index 5201b714a4..729cf6f8ff 100644 --- a/src/lib/createMachineCommand.ts +++ b/src/lib/createMachineCommand.ts @@ -46,6 +46,7 @@ export function createMachineCommand< | Command[] | null { const commandConfig = commandBarConfig && commandBarConfig[type] + // There may be no command config for this event type, // or there may be multiple commands to create. if (!commandConfig) { diff --git a/src/lib/selections.ts b/src/lib/selections.ts index a285b90ebd..2801cd28a3 100644 --- a/src/lib/selections.ts +++ b/src/lib/selections.ts @@ -395,10 +395,16 @@ function buildCommonNodeFromSelection(selectionRanges: Selections, i: number) { } function nodeHasExtrude(node: CommonASTNode) { - return doesPipeHaveCallExp({ - calleeName: 'extrude', - ...node, - }) + return ( + doesPipeHaveCallExp({ + calleeName: 'extrude', + ...node, + }) || + doesPipeHaveCallExp({ + calleeName: 'revolve', + ...node, + }) + ) } function nodeHasClose(node: CommonASTNode) { @@ -408,7 +414,7 @@ function nodeHasClose(node: CommonASTNode) { }) } -export function canExtrudeSelection(selection: Selections) { +export function canSweepSelection(selection: Selections) { const commonNodes = selection.codeBasedSelections.map((_, i) => buildCommonNodeFromSelection(selection, i) ) diff --git a/src/lib/toolbar.ts b/src/lib/toolbar.ts index c791ffe1eb..0e4e0afb47 100644 --- a/src/lib/toolbar.ts +++ b/src/lib/toolbar.ts @@ -94,9 +94,16 @@ export const toolbarConfig: Record = { }, { id: 'revolve', - onClick: () => console.error('Revolve not yet implemented'), + onClick: ({ commandBarSend }) => + commandBarSend({ + type: 'Find and select command', + data: { name: 'Revolve', groupId: 'modeling' }, + }), + // TODO: disabled + // Who's state is this? + disabled: (state) => !state.can({ type: 'Revolve' }), icon: 'revolve', - status: 'kcl-only', + status: DEV ? 'available' : 'kcl-only', title: 'Revolve', hotkey: 'R', description: diff --git a/src/machines/modelingMachine.ts b/src/machines/modelingMachine.ts index fd6eb57fef..172c329de6 100644 --- a/src/machines/modelingMachine.ts +++ b/src/machines/modelingMachine.ts @@ -33,7 +33,11 @@ import { applyConstraintEqualLength, setEqualLengthInfo, } from 'components/Toolbar/EqualLength' -import { deleteFromSelection, extrudeSketch } from 'lang/modifyAst' +import { + deleteFromSelection, + extrudeSketch, + revolveSketch, +} from 'lang/modifyAst' import { applyFilletToSelection } from 'lang/modifyAst/addFillet' import { getNodeFromPath } from '../lang/queryAst' import { @@ -202,6 +206,7 @@ export type ModelingMachineEvent = | { type: 'Export'; data: ModelingCommandSchema['Export'] } | { type: 'Make'; data: ModelingCommandSchema['Make'] } | { type: 'Extrude'; data?: ModelingCommandSchema['Extrude'] } + | { type: 'Revolve'; data?: ModelingCommandSchema['Revolve'] } | { type: 'Fillet'; data?: ModelingCommandSchema['Fillet'] } | { type: 'Text-to-CAD'; data: ModelingCommandSchema['Text-to-CAD'] } | { @@ -310,6 +315,7 @@ export const modelingMachine = setup({ guards: { 'Selection is on face': () => false, 'has valid extrude selection': () => false, + 'has valid revolve selection': () => false, 'has valid fillet selection': () => false, 'Has exportable geometry': () => false, 'has valid selection for deletion': () => false, @@ -566,6 +572,53 @@ export const modelingMachine = setup({ } })().catch(reportRejection) }, + 'AST revolve': ({ context: { store }, event }) => { + if (event.type !== 'Revolve') return + ;(async () => { + if (!event.data) return + const { selection, angle } = event.data + let ast = kclManager.ast + if ( + 'variableName' in angle && + angle.variableName && + angle.insertIndex !== undefined + ) { + const newBody = [...ast.body] + newBody.splice(angle.insertIndex, 0, angle.variableDeclarationAst) + ast.body = newBody + } + const pathToNode = getNodePathFromSourceRange( + ast, + selection.codeBasedSelections[0].range + ) + const revolveSketchRes = revolveSketch( + ast, + pathToNode, + false, + 'variableName' in angle ? angle.variableIdentifierAst : angle.valueAst + ) + if (trap(revolveSketchRes)) return + const { modifiedAst, pathToRevolveArg } = revolveSketchRes + + store.videoElement?.pause() + const updatedAst = await kclManager.updateAst(modifiedAst, true, { + focusPath: pathToRevolveArg, + zoomToFit: true, + zoomOnRangeAndType: { + range: selection.codeBasedSelections[0].range, + type: 'path', + }, + }) + if (!engineCommandManager.engineConnection?.idleMode) { + store.videoElement?.play().catch((e) => { + console.warn('Video playing was prevented', e) + }) + } + if (updatedAst?.selections) { + editorManager.selectRange(updatedAst?.selections) + } + })().catch(reportRejection) + }, 'AST delete selection': ({ context: { selectionRanges } }) => { ;(async () => { let ast = kclManager.ast @@ -1238,6 +1291,13 @@ export const modelingMachine = setup({ reenter: false, }, + Revolve: { + target: 'idle', + guard: 'has valid revolve selection', + actions: ['AST revolve'], + reenter: false, + }, + Fillet: { target: 'idle', guard: 'has valid fillet selection', // TODO: fix selections