diff --git a/src/components/ModelingMachineProvider.tsx b/src/components/ModelingMachineProvider.tsx index 144345c1d..3b921bd48 100644 --- a/src/components/ModelingMachineProvider.tsx +++ b/src/components/ModelingMachineProvider.tsx @@ -54,6 +54,7 @@ import { Selections, updateSelections, canLoftSelection, + canRevolveSelection, canShellSelection, } from 'lib/selections' import { applyConstraintIntersect } from './Toolbar/Intersect' @@ -575,6 +576,26 @@ export const ModelingMachineProvider = ({ if (err(canSweep)) return false return canSweep }, + '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 hasNoSelection = + selectionRanges.graphSelections.length === 0 || + isRangeBetweenCharacters(selectionRanges) || + isSelectionLastLine(selectionRanges, codeManager.code) + + if (hasNoSelection) { + // 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 + return doesSceneHaveSweepableSketch(kclManager.ast) + } + if (!isSketchPipe(selectionRanges)) return false + + const canSweep = canRevolveSelection(selectionRanges) + if (err(canSweep)) return false + return canSweep + }, 'has valid loft selection': ({ context: { selectionRanges } }) => { const hasNoSelection = selectionRanges.graphSelections.length === 0 || diff --git a/src/lang/modifyAst/addEdgeTreatment.ts b/src/lang/modifyAst/addEdgeTreatment.ts index ee30b0f5f..baa0f702f 100644 --- a/src/lang/modifyAst/addEdgeTreatment.ts +++ b/src/lang/modifyAst/addEdgeTreatment.ts @@ -335,7 +335,7 @@ export function mutateAstWithTagForSketchSegment( return { modifiedAst: astClone, tag } } -function getEdgeTagCall( +export function getEdgeTagCall( tag: string, artifact: Artifact ): Node { diff --git a/src/lang/modifyAst/addRevolve.ts b/src/lang/modifyAst/addRevolve.ts new file mode 100644 index 000000000..d9af1917a --- /dev/null +++ b/src/lang/modifyAst/addRevolve.ts @@ -0,0 +1,154 @@ +import { err } from 'lib/trap' +import { KCL_DEFAULT_CONSTANT_PREFIXES } from 'lib/constants' +import { + Program, + PathToNode, + Expr, + CallExpression, + PipeExpression, + VariableDeclarator, +} from 'lang/wasm' +import { Selections } from 'lib/selections' +import { Node } from 'wasm-lib/kcl/bindings/Node' +import { + createLiteral, + createCallExpressionStdLib, + createObjectExpression, + createIdentifier, + createPipeExpression, + findUniqueName, + createVariableDeclaration, +} from 'lang/modifyAst' +import { getNodeFromPath, getNodePathFromSourceRange } from 'lang/queryAst' +import { + mutateAstWithTagForSketchSegment, + getEdgeTagCall, +} from 'lang/modifyAst/addEdgeTreatment' +export function revolveSketch( + ast: Node, + pathToSketchNode: PathToNode, + shouldPipe = false, + angle: Expr = createLiteral(4), + axis: Selections +): + | { + modifiedAst: Node + pathToSketchNode: PathToNode + pathToRevolveArg: PathToNode + } + | Error { + const clonedAst = structuredClone(ast) + const sketchNode = getNodeFromPath(clonedAst, pathToSketchNode) + if (err(sketchNode)) return sketchNode + + // testing code + const pathToAxisSelection = getNodePathFromSourceRange( + clonedAst, + axis.graphSelections[0]?.codeRef.range + ) + + const lineNode = getNodeFromPath( + clonedAst, + pathToAxisSelection, + 'CallExpression' + ) + if (err(lineNode)) return lineNode + + // TODO Kevin: What if |> close(%)? + // TODO Kevin: What if opposite edge + // TODO Kevin: What if the edge isn't planar to the sketch? + // TODO Kevin: add a tag. + const tagResult = mutateAstWithTagForSketchSegment( + clonedAst, + pathToAxisSelection + ) + + // Have the tag whether it is already created or a new one is generated + if (err(tagResult)) return tagResult + const { tag } = tagResult + + /* Original Code */ + const { node: sketchExpression } = sketchNode + + // determine if sketchExpression is in a pipeExpression or not + const sketchPipeExpressionNode = getNodeFromPath( + clonedAst, + pathToSketchNode, + 'PipeExpression' + ) + if (err(sketchPipeExpressionNode)) return sketchPipeExpressionNode + const { node: sketchPipeExpression } = sketchPipeExpressionNode + const isInPipeExpression = sketchPipeExpression.type === 'PipeExpression' + + const sketchVariableDeclaratorNode = getNodeFromPath( + clonedAst, + pathToSketchNode, + 'VariableDeclarator' + ) + if (err(sketchVariableDeclaratorNode)) return sketchVariableDeclaratorNode + const { + node: sketchVariableDeclarator, + shallowPath: sketchPathToDecleration, + } = sketchVariableDeclaratorNode + + const axisSelection = axis?.graphSelections[0]?.artifact + + if (!axisSelection) return new Error('Axis selection is missing.') + + const revolveCall = createCallExpressionStdLib('revolve', [ + createObjectExpression({ + angle: angle, + axis: getEdgeTagCall(tag, axisSelection), + }), + createIdentifier(sketchVariableDeclarator.id.name), + ]) + + if (shouldPipe) { + const pipeChain = createPipeExpression( + isInPipeExpression + ? [...sketchPipeExpression.body, revolveCall] + : [sketchExpression as any, revolveCall] + ) + + sketchVariableDeclarator.init = pipeChain + const pathToRevolveArg: PathToNode = [ + ...sketchPathToDecleration, + ['init', 'VariableDeclarator'], + ['body', ''], + [pipeChain.body.length - 1, 'index'], + ['arguments', 'CallExpression'], + [0, 'index'], + ] + + return { + modifiedAst: clonedAst, + pathToSketchNode, + pathToRevolveArg, + } + } + + // We're not creating a pipe expression, + // but rather a separate constant for the extrusion + const name = findUniqueName(clonedAst, KCL_DEFAULT_CONSTANT_PREFIXES.REVOLVE) + const VariableDeclaration = createVariableDeclaration(name, revolveCall) + const sketchIndexInPathToNode = + sketchPathToDecleration.findIndex((a) => a[0] === 'body') + 1 + const sketchIndexInBody = sketchPathToDecleration[sketchIndexInPathToNode][0] + if (typeof sketchIndexInBody !== 'number') + return new Error('expected sketchIndexInBody to be a number') + clonedAst.body.splice(sketchIndexInBody + 1, 0, VariableDeclaration) + + const pathToRevolveArg: PathToNode = [ + ['body', ''], + [sketchIndexInBody + 1, 'index'], + ['declaration', 'VariableDeclaration'], + ['init', 'VariableDeclarator'], + ['arguments', 'CallExpression'], + [0, 'index'], + ] + return { + modifiedAst: clonedAst, + pathToSketchNode: [...pathToSketchNode.slice(0, -1), [-1, 'index']], + pathToRevolveArg, + } +} diff --git a/src/lang/std/artifactGraph.ts b/src/lang/std/artifactGraph.ts index d5a9e5098..985bb28df 100644 --- a/src/lang/std/artifactGraph.ts +++ b/src/lang/std/artifactGraph.ts @@ -871,3 +871,15 @@ export function codeRefFromRange(range: SourceRange, ast: Program): CodeRef { pathToNode: getNodePathFromSourceRange(ast, range), } } + +export function isSolid2D(artifact: Artifact): artifact is solid2D { + return (artifact as solid2D).pathId !== undefined +} + +export function isSegment(artifact: Artifact): artifact is SegmentArtifact { + return (artifact as SegmentArtifact).pathId !== undefined +} + +export function isSweep(artifact: Artifact): artifact is SweepArtifact { + return (artifact as SweepArtifact).pathId !== undefined +} diff --git a/src/lib/commandBarConfigs/modelingCommandConfig.ts b/src/lib/commandBarConfigs/modelingCommandConfig.ts index 81dad7db6..e66e4ffce 100644 --- a/src/lib/commandBarConfigs/modelingCommandConfig.ts +++ b/src/lib/commandBarConfigs/modelingCommandConfig.ts @@ -9,6 +9,7 @@ import { Selections } from 'lib/selections' import { kclManager } from 'lib/singletons' import { err } from 'lib/trap' import { modelingMachine, SketchTool } from 'machines/modelingMachine' +import { revolveAxisValidator } from './validators' type OutputFormat = Models['OutputFormat_type'] type OutputTypeKey = OutputFormat['type'] @@ -46,6 +47,7 @@ export type ModelingCommandSchema = { Revolve: { selection: Selections angle: KclCommandValue + axis: Selections } Fillet: { // todo @@ -330,6 +332,13 @@ export const modelingMachineCommandConfig: StateMachineCommandSetConfig< required: true, skip: true, }, + axis: { + required: true, + inputType: 'selection', + selectionTypes: ['segment', 'sweepEdge', 'edgeCutEdge'], + multiple: false, + validation: revolveAxisValidator, + }, angle: { inputType: 'kcl', defaultValue: KCL_DEFAULT_DEGREE, diff --git a/src/lib/commandBarConfigs/validators.ts b/src/lib/commandBarConfigs/validators.ts new file mode 100644 index 000000000..3a70efabe --- /dev/null +++ b/src/lib/commandBarConfigs/validators.ts @@ -0,0 +1,107 @@ +import { Models } from '@kittycad/lib' +import { engineCommandManager } from 'lib/singletons' +import { uuidv4 } from 'lib/utils' +import { CommandBarContext } from 'machines/commandBarMachine' +import { Selections } from 'lib/selections' +import { isSolid2D, isSegment, isSweep } from 'lang/std/artifactGraph' + +export const disableDryRunWithRetry = async (numberOfRetries = 3) => { + for (let tries = 0; tries < numberOfRetries; tries++) { + try { + await engineCommandManager.sendSceneCommand({ + type: 'modeling_cmd_req', + cmd_id: uuidv4(), + cmd: { type: 'disable_dry_run' }, + }) + // Exit out since the command was successful + return + } catch (e) { + console.error(e) + console.error('disable_dry_run failed. This is bad!') + } + } +} + +// Takes a callback function and wraps it around enable_dry_run and disable_dry_run +export const dryRunWrapper = async (callback: () => Promise) => { + // Gotcha: What about race conditions? + try { + await engineCommandManager.sendSceneCommand({ + type: 'modeling_cmd_req', + cmd_id: uuidv4(), + cmd: { type: 'enable_dry_run' }, + }) + const result = await callback() + return result + } catch (e) { + console.error(e) + } finally { + await disableDryRunWithRetry(5) + } +} + +function isSelections(selections: unknown): selections is Selections { + return ( + (selections as Selections).graphSelections !== undefined && + (selections as Selections).otherSelections !== undefined + ) +} + +export const revolveAxisValidator = async ({ + data, + context, +}: { + data: { [key: string]: Selections } + context: CommandBarContext +}): Promise => { + if (!isSelections(context.argumentsToSubmit.selection)) { + return 'Unable to revolve, selections are missing' + } + const artifact = + context.argumentsToSubmit.selection.graphSelections[0].artifact + + if (!artifact) { + return 'Unable to revolve, sketch not found' + } + + if (!(isSolid2D(artifact) || isSegment(artifact) || isSweep(artifact))) { + return 'Unable to revolve, sketch has no path' + } + + const sketchSelection = artifact.pathId + let edgeSelection = data.axis.graphSelections[0].artifact?.id + + if (!sketchSelection) { + return 'Unable to revolve, sketch is missing' + } + + if (!edgeSelection) { + return 'Unable to revolve, edge is missing' + } + + const angleInDegrees: Models['Angle_type'] = { + unit: 'degrees', + value: 360, + } + + const revolveAboutEdgeCommand = async () => { + return await engineCommandManager.sendSceneCommand({ + type: 'modeling_cmd_req', + cmd_id: uuidv4(), + cmd: { + type: 'revolve_about_edge', + angle: angleInDegrees, + edge_id: edgeSelection, + target: sketchSelection, + tolerance: 0.0001, + }, + }) + } + const attemptRevolve = await dryRunWrapper(revolveAboutEdgeCommand) + if (attemptRevolve?.success) { + return true + } else { + // return error message for the toast + return 'Unable to revolve with selected axis' + } +} diff --git a/src/lib/commandTypes.ts b/src/lib/commandTypes.ts index 6717d672e..2d5a7e9ea 100644 --- a/src/lib/commandTypes.ts +++ b/src/lib/commandTypes.ts @@ -7,7 +7,7 @@ import { ReactNode } from 'react' import { MachineManager } from 'components/MachineManagerProvider' import { Node } from 'wasm-lib/kcl/bindings/Node' import { Artifact } from 'lang/std/artifactGraph' - +import { CommandBarContext } from 'machines/commandBarMachine' type Icon = CustomIconName const PLATFORMS = ['both', 'web', 'desktop'] as const const INPUT_TYPES = [ @@ -147,6 +147,13 @@ export type CommandArgumentConfig< inputType: 'selection' selectionTypes: Artifact['type'][] multiple: boolean + validation?: ({ + data, + context, + }: { + data: any + context: CommandBarContext + }) => Promise } | { inputType: 'kcl' @@ -236,6 +243,13 @@ export type CommandArgument< inputType: 'selection' selectionTypes: Artifact['type'][] multiple: boolean + validation?: ({ + data, + context, + }: { + data: any + context: CommandBarContext + }) => Promise } | { inputType: 'kcl' diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 5e6958fdd..66320c02a 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -111,3 +111,10 @@ export const KCL_SAMPLES_MANIFEST_URLS = { /** Toast id for the app auto-updater toast */ export const AUTO_UPDATER_TOAST_ID = 'auto-updater-toast' + +/** Local sketch axis values in KCL for operations, it could either be 'X' or 'Y' */ +export const KCL_AXIS_X = 'X' +export const KCL_AXIS_Y = 'Y' +export const KCL_AXIS_NEG_X = '-X' +export const KCL_AXIS_NEG_Y = '-Y' +export const KCL_DEFAULT_AXIS = 'X' diff --git a/src/lib/createMachineCommand.ts b/src/lib/createMachineCommand.ts index a543e0bcf..abd0c5917 100644 --- a/src/lib/createMachineCommand.ts +++ b/src/lib/createMachineCommand.ts @@ -155,6 +155,8 @@ export function buildCommandArgument< context: ContextFrom, machineActor: Actor ): CommandArgument & { inputType: typeof arg.inputType } { + // GOTCHA: modelingCommandConfig is not a 1:1 mapping to this baseCommandArgument + // You need to manually add key/value pairs here. const baseCommandArgument = { description: arg.description, required: arg.required, @@ -181,6 +183,7 @@ export function buildCommandArgument< ...baseCommandArgument, multiple: arg.multiple, selectionTypes: arg.selectionTypes, + validation: arg.validation, } satisfies CommandArgument & { inputType: 'selection' } } else if (arg.inputType === 'kcl') { return { diff --git a/src/lib/selections.ts b/src/lib/selections.ts index a586155cf..d74da02dd 100644 --- a/src/lib/selections.ts +++ b/src/lib/selections.ts @@ -569,6 +569,17 @@ export function canSweepSelection(selection: Selections) { ) } +export function canRevolveSelection(selection: Selections) { + const commonNodes = selection.graphSelections.map((_, i) => + buildCommonNodeFromSelection(selection, i) + ) + return ( + !!isSketchPipe(selection) && + (commonNodes.every((n) => nodeHasClose(n)) || + commonNodes.every((n) => nodeHasCircle(n))) + ) +} + export function canLoftSelection(selection: Selections) { const commonNodes = selection.graphSelections.map((_, i) => buildCommonNodeFromSelection(selection, i) diff --git a/src/machines/commandBarMachine.ts b/src/machines/commandBarMachine.ts index 8e1211e3d..796067b0f 100644 --- a/src/machines/commandBarMachine.ts +++ b/src/machines/commandBarMachine.ts @@ -8,6 +8,7 @@ import { import { Selections__old } from 'lib/selections' import { getCommandArgumentKclValuesOnly } from 'lib/commandUtils' import { MachineManager } from 'components/MachineManagerProvider' +import toast from 'react-hot-toast' export type CommandBarContext = { commands: Command[] @@ -247,14 +248,69 @@ export const commandBarMachine = setup({ 'All arguments are skippable': () => false, }, actors: { - 'Validate argument': fromPromise(({ input }) => { - return new Promise((resolve, reject) => { - // TODO: figure out if we should validate argument data here or in the form itself, - // and if we should support people configuring a argument's validation function + 'Validate argument': fromPromise( + ({ + input, + }: { + input: { + context: CommandBarContext | undefined + event: CommandBarMachineEvent | undefined + } + }) => { + return new Promise((resolve, reject) => { + if (!input || input?.event?.type !== 'Submit argument') { + toast.error(`Unable to validate, wrong event type.`) + return reject(`Unable to validate, wrong event type`) + } - resolve(input) - }) - }), + const context = input?.context + + if (!context) { + toast.error(`Unable to validate, wrong argument.`) + return reject(`Unable to validate, wrong argument`) + } + + const data = input.event.data + const argName = context.currentArgument?.name + const args = context?.selectedCommand?.args + const argConfig = args && argName ? args[argName] : undefined + // Only do a validation check if the argument, selectedCommand, and the validation function are defined + if ( + context.currentArgument && + context.selectedCommand && + argConfig?.inputType === 'selection' && + argConfig?.validation + ) { + argConfig + .validation({ context, data }) + .then((result) => { + if (typeof result === 'boolean' && result === true) { + return resolve(data) + } else { + // validation failed + if (typeof result === 'string') { + // The result of the validation is the error message + toast.error(result) + return reject( + `unable to validate ${argName}, Message: ${result}` + ) + } else { + // Default message if there is not a custom one sent + toast.error(`Unable to validate ${argName}`) + return reject(`unable to validate ${argName}}`) + } + } + }) + .catch(() => { + return reject(`unable to validate ${argName}}`) + }) + } else { + // Missing several requirements for validate argument, just bypass + return resolve(data) + } + }) + } + ), 'Validate all arguments': fromPromise( ({ input }: { input: CommandBarContext }) => { return new Promise((resolve, reject) => { @@ -449,9 +505,10 @@ export const commandBarMachine = setup({ invoke: { src: 'Validate argument', id: 'validateSingleArgument', - input: ({ event }) => { - if (event.type !== 'Submit argument') return {} - return event.data + input: ({ event, context }) => { + if (event.type !== 'Submit argument') + return { event: undefined, context: undefined } + return { event, context } }, onDone: { target: '#Command Bar.Checking Arguments', diff --git a/src/machines/modelingMachine.ts b/src/machines/modelingMachine.ts index ebeae4c2a..3bb313b9d 100644 --- a/src/machines/modelingMachine.ts +++ b/src/machines/modelingMachine.ts @@ -42,12 +42,12 @@ import { applyConstraintEqualLength, setEqualLengthInfo, } from 'components/Toolbar/EqualLength' +import { revolveSketch } from 'lang/modifyAst/addRevolve' import { addOffsetPlane, deleteFromSelection, extrudeSketch, loftSketches, - revolveSketch, } from 'lang/modifyAst' import { applyEdgeTreatmentToSelection, @@ -394,6 +394,7 @@ export const modelingMachine = setup({ guards: { 'Selection is on face': () => false, 'has valid sweep selection': () => false, + 'has valid revolve selection': () => false, 'has valid loft selection': () => false, 'has valid shell selection': () => false, 'has valid edge treatment selection': () => false, @@ -682,7 +683,7 @@ export const modelingMachine = setup({ if (event.type !== 'Revolve') return ;(async () => { if (!event.data) return - const { selection, angle } = event.data + const { selection, angle, axis } = event.data let ast = kclManager.ast if ( 'variableName' in angle && @@ -693,15 +694,21 @@ export const modelingMachine = setup({ newBody.splice(angle.insertIndex, 0, angle.variableDeclarationAst) ast.body = newBody } + + // This is the selection of the sketch that will be revolved const pathToNode = getNodePathFromSourceRange( ast, selection.graphSelections[0]?.codeRef.range ) + const revolveSketchRes = revolveSketch( ast, pathToNode, false, - 'variableName' in angle ? angle.variableIdentifierAst : angle.valueAst + 'variableName' in angle + ? angle.variableIdentifierAst + : angle.valueAst, + axis ) if (trap(revolveSketchRes)) return const { modifiedAst, pathToRevolveArg } = revolveSketchRes @@ -1687,7 +1694,7 @@ export const modelingMachine = setup({ Revolve: { target: 'idle', - guard: 'has valid sweep selection', + guard: 'has valid revolve selection', actions: ['AST revolve'], reenter: false, },