diff --git a/e2e/playwright/flow-tests.spec.ts b/e2e/playwright/flow-tests.spec.ts index c77024933..459daa59c 100644 --- a/e2e/playwright/flow-tests.spec.ts +++ b/e2e/playwright/flow-tests.spec.ts @@ -743,12 +743,12 @@ test('Command bar works and can change a setting', async ({ page }) => { const themeOption = page.getByRole('option', { name: 'Set Theme' }) await expect(themeOption).toBeVisible() await themeOption.click() - const themeInput = page.getByPlaceholder('Select an option') + const themeInput = page.getByPlaceholder('system') await expect(themeInput).toBeVisible() await expect(themeInput).toBeFocused() // Select dark theme await page.keyboard.press('ArrowDown') - await page.keyboard.press('ArrowDown') + await page.keyboard.press('ArrowUp') await expect(page.getByRole('option', { name: Themes.Dark })).toHaveAttribute( 'data-headlessui-state', 'active' diff --git a/e2e/playwright/snapshot-tests.spec.ts b/e2e/playwright/snapshot-tests.spec.ts index d19168358..a861411ce 100644 --- a/e2e/playwright/snapshot-tests.spec.ts +++ b/e2e/playwright/snapshot-tests.spec.ts @@ -29,7 +29,7 @@ test.beforeEach(async ({ context, page }) => { await page.emulateMedia({ reducedMotion: 'reduce' }) }) -test.setTimeout(60000) +test.setTimeout(120_000) test('exports of each format should work', async ({ page, context }) => { // FYI this test doesn't work with only engine running locally @@ -90,8 +90,6 @@ const part001 = startSketchOn('-XZ') await page.waitForTimeout(1000) await u.clearAndCloseDebugPanel() - await page.getByRole('button', { name: APP_NAME }).click() - interface Paths { modelPath: string imagePath: string @@ -100,19 +98,21 @@ const part001 = startSketchOn('-XZ') const doExport = async ( output: Models['OutputFormat_type'] ): Promise => { - await page.getByRole('button', { name: 'Export Model' }).click() - - const exportSelect = page.getByTestId('export-type') - await exportSelect.selectOption({ label: output.type }) + await page.getByRole('button', { name: APP_NAME }).click() + await page.getByRole('button', { name: 'Export Part' }).click() + // Go through export via command bar + await page.getByRole('option', { name: output.type, exact: false }).click() if ('storage' in output) { - const storageSelect = page.getByTestId('export-storage') - await storageSelect.selectOption({ label: output.storage }) + await page.getByRole('button', { name: 'storage', exact: false }).click() + await page + .getByRole('option', { name: output.storage, exact: false }) + .click() } + await page.getByRole('button', { name: 'Submit command' }).click() - const downloadPromise = page.waitForEvent('download') - await page.getByRole('button', { name: 'Export', exact: true }).click() - const download = await downloadPromise + // Handle download + const download = await page.waitForEvent('download') const downloadLocationer = (extra = '', isImage = false) => `./e2e/playwright/export-snapshots/${output.type}-${ 'storage' in output ? output.storage : '' diff --git a/src/components/CommandBar/CommandArgOptionInput.tsx b/src/components/CommandBar/CommandArgOptionInput.tsx index 3f0cfe4ae..0b74fad98 100644 --- a/src/components/CommandBar/CommandArgOptionInput.tsx +++ b/src/components/CommandBar/CommandArgOptionInput.tsx @@ -1,8 +1,8 @@ import { Combobox } from '@headlessui/react' import Fuse from 'fuse.js' import { useCommandsContext } from 'hooks/useCommandsContext' -import { CommandArgumentOption } from 'lib/commandTypes' -import { useEffect, useRef, useState } from 'react' +import { CommandArgument, CommandArgumentOption } from 'lib/commandTypes' +import { useEffect, useMemo, useRef, useState } from 'react' function CommandArgOptionInput({ options, @@ -11,51 +11,89 @@ function CommandArgOptionInput({ onSubmit, placeholder, }: { - options: CommandArgumentOption[] + options: (CommandArgument & { inputType: 'options' })['options'] argName: string stepBack: () => void onSubmit: (data: unknown) => void placeholder?: string }) { const { commandBarSend, commandBarState } = useCommandsContext() + const resolvedOptions = useMemo( + () => + typeof options === 'function' + ? options(commandBarState.context) + : options, + [argName, options, commandBarState.context] + ) + // The initial current option is either an already-input value or the configured default + const currentOption = useMemo( + () => + resolvedOptions.find( + (o) => o.value === commandBarState.context.argumentsToSubmit[argName] + ) || resolvedOptions.find((o) => o.isCurrent), + [commandBarState.context.argumentsToSubmit, argName, resolvedOptions] + ) const inputRef = useRef(null) const formRef = useRef(null) - const [argValue, setArgValue] = useState<(typeof options)[number]['value']>( - options.find((o) => 'isCurrent' in o && o.isCurrent)?.value || - commandBarState.context.argumentsToSubmit[argName] || - options[0].value + const [selectedOption, setSelectedOption] = useState< + CommandArgumentOption + >(currentOption || resolvedOptions[0]) + const initialQuery = useMemo(() => '', [options, argName]) + const [query, setQuery] = useState(initialQuery) + const [filteredOptions, setFilteredOptions] = + useState() + + // Create a new Fuse instance when the options change + const fuse = useMemo( + () => + new Fuse(resolvedOptions, { + keys: ['name', 'description'], + threshold: 0.3, + }), + [argName, resolvedOptions] ) - const [query, setQuery] = useState('') - const [filteredOptions, setFilteredOptions] = useState() - const fuse = new Fuse(options, { - keys: ['name', 'description'], - threshold: 0.3, - }) + // Reset the query and selected option when the argName changes + useEffect(() => { + setQuery(initialQuery) + setSelectedOption(currentOption || resolvedOptions[0]) + }, [argName]) + // Auto focus and select the input when the component mounts useEffect(() => { inputRef.current?.focus() inputRef.current?.select() }, [inputRef]) + // Filter the options based on the query, + // resetting the query when the options change useEffect(() => { const results = fuse.search(query).map((result) => result.item) - setFilteredOptions(query.length > 0 ? results : options) - }, [query]) + setFilteredOptions(query.length > 0 ? results : resolvedOptions) + }, [query, resolvedOptions, fuse]) function handleSelectOption(option: CommandArgumentOption) { - setArgValue(option) + // We deal with the whole option object internally + setSelectedOption(option) + + // But we only submit the value onSubmit(option.value) } function handleSubmit(e: React.FormEvent) { e.preventDefault() - onSubmit(argValue) + + // We submit the value of the selected option, not the whole object + onSubmit(selectedOption.value) } return (
- +
{isReviewing ? : } diff --git a/src/components/CommandBar/CommandBarReview.tsx b/src/components/CommandBar/CommandBarReview.tsx index 8c047b2c0..280d1677a 100644 --- a/src/components/CommandBar/CommandBarReview.tsx +++ b/src/components/CommandBar/CommandBarReview.tsx @@ -48,7 +48,8 @@ function CommandBarReview({ stepBack }: { stepBack: () => void }) { if (!arg) return }) - function submitCommand() { + function submitCommand(e: React.FormEvent) { + e.preventDefault() commandBarSend({ type: 'Submit command', data: argumentsToSubmit, diff --git a/src/components/CommandBar/CommandBarSelectionInput.tsx b/src/components/CommandBar/CommandBarSelectionInput.tsx index 506eb7488..414a6de2e 100644 --- a/src/components/CommandBar/CommandBarSelectionInput.tsx +++ b/src/components/CommandBar/CommandBarSelectionInput.tsx @@ -29,7 +29,7 @@ function CommandBarSelectionInput({ const inputRef = useRef(null) const { commandBarState, commandBarSend } = useCommandsContext() const [hasSubmitted, setHasSubmitted] = useState(false) - const selection = useSelector(arg.actor, selectionSelector) + const selection = useSelector(arg.machineActor, selectionSelector) const [selectionsByType, setSelectionsByType] = useState< 'none' | ResolvedSelectionType[] >( diff --git a/src/components/CustomIcon.tsx b/src/components/CustomIcon.tsx index e59241584..47ee5baa0 100644 --- a/src/components/CustomIcon.tsx +++ b/src/components/CustomIcon.tsx @@ -9,6 +9,7 @@ export type CustomIconName = | 'clipboardCheckmark' | 'close' | 'equal' + | 'exportFile' | 'extrude' | 'file' | 'filePlus' @@ -194,6 +195,22 @@ export const CustomIcon = ({ /> ) + case 'exportFile': + return ( + + + + ) case 'extrude': return ( = T extends { storage: infer U } ? U : never -type StorageUnion = ExtractStorageTypes - -export interface ExportButtonProps extends React.PropsWithChildren { - className?: { - button?: string - icon?: string - bg?: string - } -} - -export const ExportButton = ({ children, className }: ExportButtonProps) => { - const [modalIsOpen, setIsOpen] = React.useState(false) - const { - settings: { - state: { - context: { baseUnit }, - }, - }, - } = useGlobalStateContext() - - const defaultType = 'gltf' - const [type, setType] = React.useState(defaultType) - const defaultStorage = 'embedded' - const [storage, setStorage] = React.useState(defaultStorage) - - function openModal() { - setIsOpen(true) - } - - function closeModal() { - setIsOpen(false) - } - - // Default to gltf and embedded. - const initialValues: OutputFormat = { - type: defaultType, - storage: defaultStorage, - presentation: 'pretty', - } - const formik = useFormik({ - initialValues, - onSubmit: (values: OutputFormat) => { - // Set the default coords. - if ( - values.type === 'obj' || - values.type === 'ply' || - values.type === 'step' || - values.type === 'stl' - ) { - // Set the default coords. - // In the future we can make this configurable. - // But for now, its probably best to keep it consistent with the - // UI. - values.coords = { - forward: { - axis: 'y', - direction: 'negative', - }, - up: { - axis: 'z', - direction: 'positive', - }, - } - } - if ( - values.type === 'obj' || - values.type === 'stl' || - values.type === 'ply' - ) { - values.units = baseUnit - } - if ( - values.type === 'ply' || - values.type === 'stl' || - values.type === 'gltf' - ) { - // Set the storage type. - values.storage = storage - } - if (values.type === 'ply' || values.type === 'stl') { - values.selection = { type: 'default_scene' } - } - engineCommandManager.sendSceneCommand({ - type: 'modeling_cmd_req', - cmd: { - type: 'export', - // By default let's leave this blank to export the whole scene. - // In the future we might want to let the user choose which entities - // in the scene to export. In that case, you'd pass the IDs thru here. - entity_ids: [], - format: values, - source_unit: baseUnit, - }, - cmd_id: uuidv4(), - }) - - closeModal() - }, - }) - - return ( - <> - - {children || 'Export'} - - -

Export your design

- -
- - {(type === 'gltf' || type === 'ply' || type === 'stl') && ( - - )} -
- -
- - Close - - - Export - -
- - - - ) -} diff --git a/src/components/GlobalStateProvider.tsx b/src/components/GlobalStateProvider.tsx index e1b7b02dd..feed47406 100644 --- a/src/components/GlobalStateProvider.tsx +++ b/src/components/GlobalStateProvider.tsx @@ -57,27 +57,30 @@ export const GlobalStateProvider = ({ > ) - const [settingsState, settingsSend] = useMachine(settingsMachine, { - context: persistedSettings, - actions: { - toastSuccess: (context, event) => { - const truncatedNewValue = - 'data' in event && event.data instanceof Object - ? (context[Object.keys(event.data)[0] as keyof typeof context] - .toString() - .substring(0, 28) as any) - : undefined - toast.success( - event.type + - (truncatedNewValue - ? ` to "${truncatedNewValue}${ - truncatedNewValue.length === 28 ? '...' : '' - }"` - : '') - ) + const [settingsState, settingsSend, settingsActor] = useMachine( + settingsMachine, + { + context: persistedSettings, + actions: { + toastSuccess: (context, event) => { + const truncatedNewValue = + 'data' in event && event.data instanceof Object + ? (String( + context[Object.keys(event.data)[0] as keyof typeof context] + ).substring(0, 28) as any) + : undefined + toast.success( + event.type + + (truncatedNewValue + ? ` to "${truncatedNewValue}${ + truncatedNewValue.length === 28 ? '...' : '' + }"` + : '') + ) + }, }, - }, - }) + } + ) settingsStateRef = settingsState.context useStateMachineCommands({ @@ -85,6 +88,7 @@ export const GlobalStateProvider = ({ state: settingsState, send: settingsSend, commandBarConfig: settingsCommandBarConfig, + actor: settingsActor, }) // Listen for changes to the system theme and update the app theme accordingly @@ -105,7 +109,7 @@ export const GlobalStateProvider = ({ }, [settingsState.context]) // Auth machine setup - const [authState, authSend] = useMachine(authMachine, { + const [authState, authSend, authActor] = useMachine(authMachine, { actions: { goToSignInPage: () => { navigate(paths.SIGN_IN) @@ -125,6 +129,7 @@ export const GlobalStateProvider = ({ state: authState, send: authSend, commandBarConfig: authCommandBarConfig, + actor: authActor, }) return ( diff --git a/src/components/ModelingMachineProvider.tsx b/src/components/ModelingMachineProvider.tsx index 27a01a201..1ca0fdbd2 100644 --- a/src/components/ModelingMachineProvider.tsx +++ b/src/components/ModelingMachineProvider.tsx @@ -38,6 +38,10 @@ import { getSketchQuaternion } from 'clientSideScene/sceneEntities' import { startSketchOnDefault } from 'lang/modifyAst' import { Program } from 'lang/wasm' import { isSingleCursorInPipe } from 'lang/queryAst' +import { TEST } from 'env' +import { exportFromEngine } from 'lib/exportFromEngine' +import { Models } from '@kittycad/lib/dist/types/src' +import toast from 'react-hot-toast' type MachineContext = { state: StateFrom @@ -54,7 +58,12 @@ export const ModelingMachineProvider = ({ }: { children: React.ReactNode }) => { - const { auth } = useGlobalStateContext() + const { + auth, + settings: { + context: { baseUnit }, + }, + } = useGlobalStateContext() const { code } = useKclContext() const token = auth?.context?.token const streamRef = useRef(null) @@ -170,6 +179,56 @@ export const ModelingMachineProvider = ({ } return { selectionRangeTypeMap } }), + 'Engine export': (_, event) => { + if (event.type !== 'Export' || TEST) return + const format = { + ...event.data, + } as Partial + + // Set all the un-configurable defaults here. + if (format.type === 'gltf') { + format.presentation = 'pretty' + } + + if ( + format.type === 'obj' || + format.type === 'ply' || + format.type === 'step' || + format.type === 'stl' + ) { + // Set the default coords. + // In the future we can make this configurable. + // But for now, its probably best to keep it consistent with the + // UI. + format.coords = { + forward: { + axis: 'y', + direction: 'negative', + }, + up: { + axis: 'z', + direction: 'positive', + }, + } + } + + if ( + format.type === 'obj' || + format.type === 'stl' || + format.type === 'ply' + ) { + format.units = baseUnit + } + + if (format.type === 'ply' || format.type === 'stl') { + format.selection = { type: 'default_scene' } + } + + exportFromEngine({ + source_unit: baseUnit, + format: format as Models['OutputFormat_type'], + }).catch((e) => toast.error('Error while exporting', e)) // TODO I think we need to throw the error from engineCommandManager + }, }, guards: { 'has valid extrude selection': ({ selectionRanges }) => { @@ -192,6 +251,8 @@ export const ModelingMachineProvider = ({ selectionRanges ) }, + 'Has exportable geometry': () => + kclManager.kclErrors.length === 0 && kclManager.ast.body.length > 0, }, services: { 'AST-undo-startSketchOn': async ({ sketchPathToNode }) => { diff --git a/src/components/ProjectSidebarMenu.test.tsx b/src/components/ProjectSidebarMenu.test.tsx index ab4899c39..5c0a9ed4c 100644 --- a/src/components/ProjectSidebarMenu.test.tsx +++ b/src/components/ProjectSidebarMenu.test.tsx @@ -5,7 +5,6 @@ import { type ProjectWithEntryPointMetadata } from 'lib/types' import { GlobalStateProvider } from './GlobalStateProvider' import { APP_NAME } from 'lib/constants' import { vi } from 'vitest' -import { ExportButtonProps } from './ExportButton' const now = new Date() const projectWellFormed = { @@ -38,15 +37,6 @@ const projectWellFormed = { }, } satisfies ProjectWithEntryPointMetadata -const mockExportButton = vi.fn() -vi.mock('/src/components/ExportButton', () => ({ - // engineCommandManager method call in ExportButton causes vitest to hang - ExportButton: (props: ExportButtonProps) => { - mockExportButton(props) - return - }, -})) - describe('ProjectSidebarMenu tests', () => { test('Renders the project name', () => { render( diff --git a/src/components/ProjectSidebarMenu.tsx b/src/components/ProjectSidebarMenu.tsx index 917c1927f..1facc9ef2 100644 --- a/src/components/ProjectSidebarMenu.tsx +++ b/src/components/ProjectSidebarMenu.tsx @@ -5,12 +5,12 @@ import { type IndexLoaderData } from 'lib/types' import { paths } from 'lib/paths' import { isTauri } from '../lib/isTauri' import { Link } from 'react-router-dom' -import { ExportButton } from './ExportButton' import { Fragment } from 'react' import { FileTree } from './FileTree' import { sep } from '@tauri-apps/api/path' import { Logo } from './Logo' import { APP_NAME } from 'lib/constants' +import { useCommandsContext } from 'hooks/useCommandsContext' const ProjectSidebarMenu = ({ project, @@ -21,6 +21,8 @@ const ProjectSidebarMenu = ({ project?: IndexLoaderData['project'] file?: IndexLoaderData['file'] }) => { + const { commandBarSend } = useCommandsContext() + return renderAsLink ? ( )}
- + commandBarSend({ + type: 'Find and select command', + data: { name: 'Export', ownerMachine: 'modeling' }, + }) + } > - Export Model - + Export Part + {isTauri() && ( send: Function - actor?: InterpreterFrom + actor: InterpreterFrom commandBarConfig?: CommandSetConfig allCommandsRequireNetwork?: boolean onCancel?: () => void diff --git a/src/lib/commandBarConfigs/homeCommandConfig.ts b/src/lib/commandBarConfigs/homeCommandConfig.ts index 5210530d3..1b66d3baa 100644 --- a/src/lib/commandBarConfigs/homeCommandConfig.ts +++ b/src/lib/commandBarConfigs/homeCommandConfig.ts @@ -28,7 +28,8 @@ export const homeCommandBarConfig: CommandSetConfig< name: { inputType: 'options', required: true, - options: (context) => + options: [], + optionsFromContext: (context) => context.projects.map((p) => ({ name: p.name!, value: p.name!, @@ -43,7 +44,7 @@ export const homeCommandBarConfig: CommandSetConfig< name: { inputType: 'string', required: true, - defaultValue: (context) => context.defaultProjectName, + defaultValueFromContext: (context) => context.defaultProjectName, }, }, }, @@ -55,7 +56,8 @@ export const homeCommandBarConfig: CommandSetConfig< name: { inputType: 'options', required: true, - options: (context) => + options: [], + optionsFromContext: (context) => context.projects.map((p) => ({ name: p.name!, value: p.name!, @@ -71,7 +73,8 @@ export const homeCommandBarConfig: CommandSetConfig< oldName: { inputType: 'options', required: true, - options: (context) => + options: [], + optionsFromContext: (context) => context.projects.map((p) => ({ name: p.name!, value: p.name!, @@ -80,7 +83,7 @@ export const homeCommandBarConfig: CommandSetConfig< newName: { inputType: 'string', required: true, - defaultValue: (context) => context.defaultProjectName, + defaultValueFromContext: (context) => context.defaultProjectName, }, }, }, diff --git a/src/lib/commandBarConfigs/modelingCommandConfig.ts b/src/lib/commandBarConfigs/modelingCommandConfig.ts index 4a5da3382..45c88cf2a 100644 --- a/src/lib/commandBarConfigs/modelingCommandConfig.ts +++ b/src/lib/commandBarConfigs/modelingCommandConfig.ts @@ -1,7 +1,13 @@ +import { Models } from '@kittycad/lib' import { CommandSetConfig, KclCommandValue } from 'lib/commandTypes' import { Selections } from 'lib/selections' import { modelingMachine } from 'machines/modelingMachine' +type OutputFormat = Models['OutputFormat_type'] +type OutputTypeKey = OutputFormat['type'] +type ExtractStorageTypes = T extends { storage: infer U } ? U : never +type StorageUnion = ExtractStorageTypes + export const EXTRUSION_RESULTS = [ 'new', 'add', @@ -11,6 +17,10 @@ export const EXTRUSION_RESULTS = [ export type ModelingCommandSchema = { 'Enter sketch': {} + Export: { + type: OutputTypeKey + storage?: StorageUnion + } Extrude: { selection: Selections // & { type: 'face' } would be cool to lock that down // result: (typeof EXTRUSION_RESULTS)[number] @@ -26,6 +36,80 @@ export const modelingMachineConfig: CommandSetConfig< description: 'Enter sketch mode.', icon: 'sketch', }, + Export: { + description: 'Export the current model.', + icon: 'exportFile', + needsReview: true, + args: { + type: { + inputType: 'options', + defaultValue: 'gltf', + required: true, + options: [ + { name: 'gLTF', isCurrent: true, value: 'gltf' }, + { name: 'OBJ', isCurrent: false, value: 'obj' }, + { name: 'STL', isCurrent: false, value: 'stl' }, + { name: 'STEP', isCurrent: false, value: 'step' }, + { name: 'PLY', isCurrent: false, value: 'ply' }, + ], + }, + storage: { + inputType: 'options', + defaultValue: (c) => { + switch (c.argumentsToSubmit.type) { + case 'gltf': + return 'embedded' + case 'stl': + return 'ascii' + case 'ply': + return 'ascii' + default: + return undefined + } + }, + skip: true, + required: (commandContext) => + ['gltf', 'stl', 'ply'].includes( + commandContext.argumentsToSubmit.type as string + ), + options: (commandContext) => { + const type = commandContext.argumentsToSubmit.type as + | OutputTypeKey + | undefined + + switch (type) { + case 'gltf': + return [ + { name: 'embedded', isCurrent: true, value: 'embedded' }, + { name: 'binary', isCurrent: false, value: 'binary' }, + { name: 'standard', isCurrent: false, value: 'standard' }, + ] + case 'stl': + return [ + { name: 'binary', isCurrent: false, value: 'binary' }, + { name: 'ascii', isCurrent: true, value: 'ascii' }, + ] + case 'ply': + return [ + { name: 'ascii', isCurrent: true, value: 'ascii' }, + { + name: 'binary_big_endian', + isCurrent: false, + value: 'binary_big_endian', + }, + { + name: 'binary_little_endian', + isCurrent: false, + value: 'binary_little_endian', + }, + ] + default: + return [] + } + }, + }, + }, + }, Extrude: { description: 'Pull a sketch into 3D along its normal or perpendicular.', icon: 'extrude', diff --git a/src/lib/commandBarConfigs/settingsCommandConfig.ts b/src/lib/commandBarConfigs/settingsCommandConfig.ts index e8a9c955a..52ccca729 100644 --- a/src/lib/commandBarConfigs/settingsCommandConfig.ts +++ b/src/lib/commandBarConfigs/settingsCommandConfig.ts @@ -41,8 +41,9 @@ export const settingsCommandBarConfig: CommandSetConfig< baseUnit: { inputType: 'options', required: true, - defaultValue: (context) => context.baseUnit, - options: (context) => + defaultValueFromContext: (context) => context.baseUnit, + options: [], + optionsFromContext: (context) => Object.values(baseUnitsUnion).map((v) => ({ name: v, value: v, @@ -57,8 +58,9 @@ export const settingsCommandBarConfig: CommandSetConfig< cameraControls: { inputType: 'options', required: true, - defaultValue: (context) => context.cameraControls, - options: (context) => + defaultValueFromContext: (context) => context.cameraControls, + options: [], + optionsFromContext: (context) => Object.values(cameraSystems).map((v) => ({ name: v, value: v, @@ -74,7 +76,7 @@ export const settingsCommandBarConfig: CommandSetConfig< defaultProjectName: { inputType: 'string', required: true, - defaultValue: (context) => context.defaultProjectName, + defaultValueFromContext: (context) => context.defaultProjectName, }, }, }, @@ -84,8 +86,9 @@ export const settingsCommandBarConfig: CommandSetConfig< textWrapping: { inputType: 'options', required: true, - defaultValue: (context) => context.textWrapping, - options: (context) => [ + defaultValueFromContext: (context) => context.textWrapping, + options: [], + optionsFromContext: (context) => [ { name: 'On', value: 'On' as Toggle, @@ -106,8 +109,9 @@ export const settingsCommandBarConfig: CommandSetConfig< theme: { inputType: 'options', required: true, - defaultValue: (context) => context.theme, - options: (context) => + defaultValueFromContext: (context) => context.theme, + options: [], + optionsFromContext: (context) => Object.values(Themes).map((v) => ({ name: v, value: v, @@ -122,8 +126,9 @@ export const settingsCommandBarConfig: CommandSetConfig< unitSystem: { inputType: 'options', required: true, - defaultValue: (context) => context.unitSystem, - options: (context) => [ + defaultValueFromContext: (context) => context.unitSystem, + options: [], + optionsFromContext: (context) => [ { name: 'Imperial', value: 'imperial' as UnitSystem, diff --git a/src/lib/commandTypes.ts b/src/lib/commandTypes.ts index c0a03decc..ae6c0776f 100644 --- a/src/lib/commandTypes.ts +++ b/src/lib/commandTypes.ts @@ -8,6 +8,7 @@ import { } from 'xstate' import { Selection } from './selections' import { Identifier, Value, VariableDeclaration } from 'lang/wasm' +import { commandBarMachine } from 'machines/commandBarMachine' type Icon = CustomIconName const PLATFORMS = ['both', 'web', 'desktop'] as const @@ -93,15 +94,31 @@ export type CommandArgumentConfig< > = | { description?: string - required: boolean - skip?: true + required: + | boolean + | (( + commandBarContext: { argumentsToSubmit: Record } // Should be the commandbarMachine's context, but it creates a circular dependency + ) => boolean) + skip?: boolean } & ( | { inputType: Extract options: | CommandArgumentOption[] - | ((context: ContextFrom) => CommandArgumentOption[]) - defaultValue?: OutputType | ((context: ContextFrom) => OutputType) + | (( + commandBarContext: { + argumentsToSubmit: Record + } // Should be the commandbarMachine's context, but it creates a circular dependency + ) => CommandArgumentOption[]) + optionsFromContext?: ( + context: ContextFrom + ) => CommandArgumentOption[] + defaultValue?: + | OutputType + | (( + commandBarContext: ContextFrom + ) => OutputType) + defaultValueFromContext?: (context: ContextFrom) => OutputType } | { inputType: Extract @@ -111,7 +128,12 @@ export type CommandArgumentConfig< | { inputType: Extract; defaultValue?: string } // KCL expression inputs have simple strings as default values | { inputType: Extract - defaultValue?: OutputType | ((context: ContextFrom) => OutputType) + defaultValue?: + | OutputType + | (( + commandBarContext: ContextFrom + ) => OutputType) + defaultValueFromContext?: (context: ContextFrom) => OutputType } ) @@ -121,24 +143,42 @@ export type CommandArgument< > = | { description?: string - required: boolean - skip?: true + required: + | boolean + | (( + commandBarContext: { argumentsToSubmit: Record } // Should be the commandbarMachine's context, but it creates a circular dependency + ) => boolean) + skip?: boolean + machineActor: InterpreterFrom } & ( | { inputType: Extract - options: CommandArgumentOption[] - defaultValue?: OutputType + options: + | CommandArgumentOption[] + | (( + commandBarContext: { + argumentsToSubmit: Record + } // Should be the commandbarMachine's context, but it creates a circular dependency + ) => CommandArgumentOption[]) + defaultValue?: + | OutputType + | (( + commandBarContext: ContextFrom + ) => OutputType) } | { inputType: Extract selectionTypes: Selection['type'][] - actor: InterpreterFrom multiple: boolean } | { inputType: Extract; defaultValue?: string } // KCL expression inputs have simple strings as default values | { inputType: Extract - defaultValue?: OutputType + defaultValue?: + | OutputType + | (( + commandBarContext: ContextFrom + ) => OutputType) } ) diff --git a/src/lib/createMachineCommand.ts b/src/lib/createMachineCommand.ts index 767ee0baa..f23686601 100644 --- a/src/lib/createMachineCommand.ts +++ b/src/lib/createMachineCommand.ts @@ -17,7 +17,7 @@ interface CreateMachineCommandProps< ownerMachine: T['id'] state: StateFrom send: Function - actor?: InterpreterFrom + actor: InterpreterFrom commandBarConfig?: CommandSetConfig onCancel?: () => void } @@ -91,13 +91,13 @@ function buildCommandArguments< >( state: StateFrom, args: CommandConfig['args'], - actor?: InterpreterFrom + machineActor: InterpreterFrom ): NonNullable['args']> { const newArgs = {} as NonNullable['args']> for (const arg in args) { const argConfig = args[arg] as CommandArgumentConfig - const newArg = buildCommandArgument(argConfig, arg, state, actor) + const newArg = buildCommandArgument(argConfig, arg, state, machineActor) newArgs[arg] = newArg } @@ -111,44 +111,36 @@ function buildCommandArgument< arg: CommandArgumentConfig, argName: string, state: StateFrom, - actor?: InterpreterFrom + machineActor: InterpreterFrom ): CommandArgument & { inputType: typeof arg.inputType } { const baseCommandArgument = { description: arg.description, required: arg.required, skip: arg.skip, + machineActor, } satisfies Omit, 'inputType'> if (arg.inputType === 'options') { - const options = arg.options - ? arg.options instanceof Function - ? arg.options(state.context) - : arg.options - : undefined - - if (!options) { + if (!arg.options) { throw new Error('Options must be provided for options input type') } return { inputType: arg.inputType, ...baseCommandArgument, - defaultValue: - arg.defaultValue instanceof Function - ? arg.defaultValue(state.context) - : arg.defaultValue, - options, + defaultValue: arg.defaultValueFromContext + ? arg.defaultValueFromContext(state.context) + : arg.defaultValue, + options: arg.optionsFromContext + ? arg.optionsFromContext(state.context) + : arg.options, } satisfies CommandArgument & { inputType: 'options' } } else if (arg.inputType === 'selection') { - if (!actor) - throw new Error('Actor must be provided for selection input type') - return { inputType: arg.inputType, ...baseCommandArgument, multiple: arg.multiple, selectionTypes: arg.selectionTypes, - actor, } satisfies CommandArgument & { inputType: 'selection' } } else if (arg.inputType === 'kcl') { return { @@ -159,10 +151,7 @@ function buildCommandArgument< } else { return { inputType: arg.inputType, - defaultValue: - arg.defaultValue instanceof Function - ? arg.defaultValue(state.context) - : arg.defaultValue, + defaultValue: arg.defaultValue, ...baseCommandArgument, } } diff --git a/src/lib/exportFromEngine.ts b/src/lib/exportFromEngine.ts new file mode 100644 index 000000000..c0218a5a7 --- /dev/null +++ b/src/lib/exportFromEngine.ts @@ -0,0 +1,27 @@ +import { engineCommandManager } from 'lang/std/engineConnection' +import { type Models } from '@kittycad/lib' +import { v4 as uuidv4 } from 'uuid' + +// Isolating a function to call the engine to export the current scene. +// Because it has given us trouble in automated testing environments. +export function exportFromEngine({ + source_unit, + format, +}: { + source_unit: Models['UnitLength_type'] + format: Models['OutputFormat_type'] +}) { + return engineCommandManager.sendSceneCommand({ + type: 'modeling_cmd_req', + cmd: { + type: 'export', + // By default let's leave this blank to export the whole scene. + // In the future we might want to let the user choose which entities + // in the scene to export. In that case, you'd pass the IDs thru here. + entity_ids: [], + format, + source_unit, + }, + cmd_id: uuidv4(), + }) +} diff --git a/src/machines/commandBarMachine.ts b/src/machines/commandBarMachine.ts index 52773f306..c61f9b030 100644 --- a/src/machines/commandBarMachine.ts +++ b/src/machines/commandBarMachine.ts @@ -8,21 +8,29 @@ import { import { Selections } from 'lib/selections' import { getCommandArgumentKclValuesOnly } from 'lib/commandUtils' +export type CommandBarContext = { + commands: Command[] + selectedCommand?: Command + currentArgument?: CommandArgument & { name: string } + selectionRanges: Selections + argumentsToSubmit: { [x: string]: unknown } +} + export const commandBarMachine = createMachine( { - /** @xstate-layout N4IgpgJg5mDOIC5QGED2BbdBDAdhABAEJYBOAxMgDaqxgDaADALqKgAONAlgC6eo6sQAD0QBaAJwA6AGwAmAKwBmBoukAWafIAcDcSoA0IAJ6JZDaZIDs8hgzV6AjA61a1DWQF8PhtJlwFiciowUkYWJBAOWB4+AQiRBFF5CwdpcVkHS1lpVyU5QxNEh1lFGTUsrUUtOQd5SwZLLx8MbDwiUkkqGkgyAHk2MBwwwSiY-kEEswdJNWcNbPFLNMr5AsRFWUtJcVSdRR2VVMUmkF9WgI6u2ggyADFONv98WkowAGNufDeW-2GI0d443iiHESkktUUG1klTsSi0awQDgYWhmqkWjnUslBWhOZyegU61GuZAAghACN8-HhYH92FxAXFQAlRA5FpJ5FjFPYtNDpIpLOkEW4GNs4eJxJoGvJxOVcT82gSrj0AEpgdCoABuYC+8ogNOYI3psQmYlkGUkU2slhc6mkmUUCIyKLc4i0lkU8iUyjUHLlVIuJEkAGUwK8Pg8oDr-WQQ2HPpTzrTIkagUzEDkLHZkQK1CUtKCHAiNlsdjkVAdFEc-ed2oG8W0Xu9uD0kwDjcCEJDplUPfn+Ut7CUhXJJFo6uYbBOMtJq-jLrrnqGmy2HOE6dEGSbESoRSUHOVqpV99Zh7JR+PXPu1G7zI1vKcFwSAOJYbgACzAJAj+FIUAAruggzcLAFBvrgMBfH+JAkEBP4kP+gE4NwrYpoywiIA4qjyDIyR2LmlTSHY8LGBhyjsrm-L2No0hpHys4Kh0L7vp+36-gBQEgQAInAS4fFGiYGv8qFbjkpSWLmswMNK-LOI6UmSKouZOBo8jOPu9EBpITEfl+OCRmxiHAZIJIAO5YDEen4A8bB-twMZ-gARugPBwQhQEoRu7ZpoiyRbLm1HiJC16bLIQo7FYUrVNkKhZJ4971pp2ksZZBkcZIABqWCUJwECvhGZAQPwYCSA8GqoAA1sVGpZTlr5gCS8HsUhHljGhCTODYVissoxaWPutQInyFhctKSL8jFmwabWWmvjprGNYZsAZTVuW8HpZCfiQqCBmwlCvgAZtt6CSNV2WrfVC3uYJ66tVuqQlNsaTpKkUlCrMClqNeuibHUolTQSqoapwYAmfZTkuQmvzXcmnmpuhCB9eyOieuk1o6C4DokQjZHqKjO66C6-0dIDwOg2SBCpc10NtnDCTiqU0ICjyciyG4+RYwKpQaBmSKpE4dpE4GJMg2QqrqlqrlNch1PCR2vNSLa4rZq4eaDVyo4+jymRqJWDQ4vFj7E2AQMiwAohALmU9La4w7dHaaNhNjaBKnqsqCatqBrdTirMNiQuIgudB+bzld+DVuUhIGFTgxWlRVVUrXV4dS-qNs021iDyO9TslMoTj1LJWN6BYOsyphOhmDkcXNP603IMHoeWcni0FUVJU4GVlUnYnzbNxxdCroasMZwgWKPay0qWNYLhZ+UCIuGeyR6O47o0ZsAcG7XioN2Hl2Rxt0HbZIu0HUd3dnUne-AS1m5y+UWz7DkY7pKpsKDRoMz1MK4olO4G-3jgVAEA4CCASrWIedtvKiAWBaBgmQ6g2g0PaR00xWYSjHFPHQNFCIzk3jWRURJIAQNvlA4oFo8zWlSPUT00pVhYw9NMHWrhKwbH2GaNQgdYxNm-JDPAxCvLw1mAzTY3opJ9TqKFehqlUTMMwiUdIrNA5gMbB8IhQlh5bkWFsVwyJshYmkNYQs9DNhI2vJhFQZp+SgkDklXS+kr7wHUZA+G-UzwygUPyLB+xApFh9BaCU6hpSLGqNKDheC5yBlsfNCORlTLmTWpGaytl+G0wwhoEU7ilDWnMN4zGhRPqO0Cn1XQthVKaBsbNZK9iYlLUyhfBJKSR7OH2FYAi1oDGzFSNIIUGwZiVD0BKTCSkFCB2FiZRpIlMjgk+v2VQch0jEUKIYz+HoOSaA0IeJRO8m4OImXLTQjDYRpAFIsfckillZAUjYGipceQ6zvF4IAA */ + /** @xstate-layout N4IgpgJg5mDOIC5QGED2BbdBDAdhABAEJYBOAxMgDaqxgDaADALqKgAONAlgC6eo6sQAD0QBaAJwA6AGwAmAKwBmBoukAWafIAcDcSoA0IAJ6JZDaZIDs8hgzV6AjA61a1DWQF8PhtJlwFiciowUkYWJBAOWB4+AQiRBFF5CwdpcVkHS1lpVyU5QxNEh1lFGTUsrUUtOQd5SwZLLx8MbDwiUkkqGkgyAHk2MBwwwSiY-kEEswZJbPltM0U3eXLZAsRFeQcrerUHRbTFvfkmkF9WgI6u2ggyADFONv98WkowAGNufDeW-2GI0d443iiHESkktUUilkskqdiUWjWCAcDC0kjUqnElkc6lkoK0JzOT0CnWo1zIAEEIARvn48LA-uwuIC4qAEqIHJjJPJcYtxFoYdJFFjVsZEG5pqCquJxJoGvJxOUCT82sSrj0AEpgdCoABuYC+yog9OYIyZsQmYmhWzMmTqLnU0kyikRGVRbj5lg2SmUam5StpFxIkgAymBXh8HlADQGyKHw58aecGZEzUDWYgchY7CisWoSlpQQ5EVDLJJxKkdIpyypUop-ed2kHCW0Xu9uD1kwDzcCEJCtlUNgWhZZ1OlnaKEBpZJItHVzDZ5xlpPWiZdDc8w22Ow5wozosyLUiVNMSg5ytVKmfrIipzO564z2otPVpI1vKd18SAOJYbgACzAEhI3wUgoAAV3QQZuFgCg-1wGAvjAkgSCgkCSHAyCcG4TtUxZYRED2TQZGSOw80qaQ7ARCc9mmZYSksextGkNJBRXFUOh-f9AOA0CIKgmCABE4E3D5oyTE1-lww8clKBjZF2Bh5SFZwXUUyRVDzJwNE2LQzzYwNJE4gCgJwKNeMw6DJHJAB3LAYlM-AHjYMDuFjMCACN0B4NCMKgnD927dMkWSUs8yY8RISfWQshvcsrDlapshULJPHfZsDKM7iHPM-jJAANSwShOAgX9IzICB+DASQHh1VAAGsqp1Qrit-MByXQvisP8sY8ISZwbCsDllBLSwz1qRFBQsRZ5WRIVkui-TG0M39jJ4jqLNgfLmpK3hTLIQCSFQIM2EoX8ADMjvQSQmqKna2vWvyJL3HrD1SEoyzSdJUkUm9dnUtQn10aK6hkxbiU1HVODAay3M87zE1+J6UwCtN8IQUauR0OZ0ksFwUUqRFPWmUdouPXR3TBjoIahmHKQIHKuqRrtUYSaVShhLF+TkeTzBFQosVKDRM2RVInEdSmg2p6GyE1bU9R8zrsKZqSexFqQHWlHNXHzCbFhnX1+UydFkVxiXJClmGAFEIG8hmld3ZGXp7TR5C5RSCxdjlQV1tR9bqaVdhsSFxDN5AALeOrgPa3ysJgiqcCqmr6sa7bWujxXjQd5nesQZYthsblIQYJx6hUic9AsdEFT2HQzByVLmgDJaw-eSOHPTjbysq6qcFqhrrtT9sO-4ugd1NFGc4QXEPo5eVLGsFxlnKREXGnZI9HcT1mOikO0s-S5w7bqNh9j-bkKOyQTvOy6B9utOHtj7qD1V8pSyrHJZ3STY4QmjQ0R2Ww0oSjuF3u+HAqAIBwEEOlRs48nZBVENkKQNprC42qBoJ0LothaXMJ9aElRXDLj3k3VUpJIBwOfgg4oMx8y41SPUOY8p5DFk2GiKoxsoRVmhGoM2cY2zAQRngChgU0a7HZtFH0ilRp1D5usVh6JXCKD2CUdI8lQ7rlbB8chkkJ6HkxFsBeulbQZAUCwrYCiqzIhyE+fqZtMomTMg-aCwiWYEV2NOBUCghQ6EFGzYsvpwQUT5MxawFECx2JWllRxMdLI2TsrtKMTkXIuMnmeCiZYwrePMFWCKv1XZKVGroWwmxNARK4g4hWG0tp3wSSk16lQpC41ULjV8uxUjSBvFCNEDTdCOkWCY44xCGzgzAJDaGdTnaZHBADYcqg5DpCovzeRno5izA0BeUOh8o5OPgDo+BaNvqV0xNFaE7gdYTnnvkmUChdKEMyF4LwQA */ + predictableActionArguments: true, + tsTypes: {} as import('./commandBarMachine.typegen').Typegen0, context: { - commands: [] as Command[], - selectedCommand: undefined as Command | undefined, - currentArgument: undefined as - | (CommandArgument & { name: string }) - | undefined, + commands: [], + selectedCommand: undefined, + currentArgument: undefined, selectionRanges: { otherSelections: [], codeBasedSelections: [], - } as Selections, - argumentsToSubmit: {} as { [x: string]: unknown }, - }, + }, + argumentsToSubmit: {}, + } as CommandBarContext, id: 'Command Bar', initial: 'Closed', states: { @@ -267,7 +275,6 @@ export const commandBarMachine = createMachine( data: { [x: string]: CommandArgumentWithName } }, }, - predictableActionArguments: true, preserveActionOrder: true, }, { @@ -279,28 +286,45 @@ export const commandBarMachine = createMachine( (selectedCommand?.args && event.type === 'Submit command') || event.type === 'done.invoke.validateArguments' ) { - selectedCommand?.onSubmit(getCommandArgumentKclValuesOnly(event.data)) + const resolvedArgs = {} as { [x: string]: unknown } + for (const [argName, argValue] of Object.entries( + getCommandArgumentKclValuesOnly(event.data) + )) { + resolvedArgs[argName] = + typeof argValue === 'function' ? argValue(context) : argValue + } + selectedCommand?.onSubmit(resolvedArgs) } else { selectedCommand?.onSubmit() } }, 'Set current argument to first non-skippable': assign({ - currentArgument: (context) => { + currentArgument: (context, event) => { const { selectedCommand } = context if (!(selectedCommand && selectedCommand.args)) return undefined + const rejectedArg = 'data' in event && event.data.arg // Find the first argument that is not to be skipped: // that is, the first argument that is not already in the argumentsToSubmit // or that is not undefined, or that is not marked as "skippable". // TODO validate the type of the existing arguments let argIndex = 0 + while (argIndex < Object.keys(selectedCommand.args).length) { - const argName = Object.keys(selectedCommand.args)[argIndex] + const [argName, argConfig] = Object.entries(selectedCommand.args)[ + argIndex + ] + const argIsRequired = + typeof argConfig.required === 'function' + ? argConfig.required(context) + : argConfig.required const mustNotSkipArg = - !context.argumentsToSubmit.hasOwnProperty(argName) || - context.argumentsToSubmit[argName] === undefined || - !selectedCommand.args[argName].skip - if (mustNotSkipArg) { + argIsRequired && + (!context.argumentsToSubmit.hasOwnProperty(argName) || + context.argumentsToSubmit[argName] === undefined || + (rejectedArg && rejectedArg.name === argName)) + + if (mustNotSkipArg === true) { return { ...selectedCommand.args[argName], name: argName, @@ -308,14 +332,10 @@ export const commandBarMachine = createMachine( } argIndex++ } - // Just show the last argument if all are skippable + // TODO: use an XState service to continue onto review step // if all arguments are skippable and contain values. - const argName = Object.keys(selectedCommand.args)[argIndex - 1] - return { - ...selectedCommand.args[argName], - name: argName, - } + return undefined }, }), 'Clear current argument': assign({ @@ -333,8 +353,6 @@ export const commandBarMachine = createMachine( 'Set current argument': assign({ currentArgument: (context, event) => { switch (event.type) { - case 'error.platform.validateArguments': - return event.data.arg case 'Edit argument': return event.data.arg default: @@ -343,27 +361,22 @@ export const commandBarMachine = createMachine( }, }), 'Remove current argument and set a new one': assign({ - currentArgument: (context, event) => { - if (event.type !== 'Change current argument') - return context.currentArgument - return Object.values(event.data)[0] - }, argumentsToSubmit: (context, event) => { if ( event.type !== 'Change current argument' || !context.currentArgument ) return context.argumentsToSubmit - const { name, required } = context.currentArgument - if (required) - return { - [name]: undefined, - ...context.argumentsToSubmit, - } + const { name } = context.currentArgument const { [name]: _, ...rest } = context.argumentsToSubmit return rest }, + currentArgument: (context, event) => { + if (event.type !== 'Change current argument') + return context.currentArgument + return Object.values(event.data)[0] + }, }), 'Clear argument data': assign({ selectedCommand: undefined, @@ -388,11 +401,6 @@ export const commandBarMachine = createMachine( }), 'Initialize arguments to submit': assign({ argumentsToSubmit: (c, e) => { - if ( - e.type !== 'Select command' && - e.type !== 'Find and select command' - ) - return c.argumentsToSubmit const command = 'command' in e.data ? e.data.command : c.selectedCommand! if (!command.args) return {} @@ -421,38 +429,67 @@ export const commandBarMachine = createMachine( }, 'Validate all arguments': (context, _) => { return new Promise((resolve, reject) => { - for (const [argName, arg] of Object.entries( - context.argumentsToSubmit + for (const [argName, argConfig] of Object.entries( + context.selectedCommand!.args! )) { - let argConfig = context.selectedCommand!.args![argName] + let arg = context.argumentsToSubmit[argName] + let argValue = typeof arg === 'function' ? arg(context) : arg - if ( - ('defaultValue' in argConfig && - argConfig.defaultValue && - typeof arg !== typeof argConfig.defaultValue && - argConfig.inputType !== 'kcl') || - (argConfig.inputType === 'kcl' && - !(arg as Partial).valueAst) || - ('options' in argConfig && - typeof arg !== typeof argConfig.options[0].value) - ) { - return reject({ - message: 'Argument payload is of the wrong type', - arg: { - ...argConfig, - name: argName, - }, - }) - } + try { + const isRequired = + typeof argConfig.required === 'function' + ? argConfig.required(context) + : argConfig.required - if (!arg && argConfig.required) { - return reject({ - message: 'Argument payload is falsy but is required', - arg: { - ...argConfig, - name: argName, - }, - }) + const resolvedDefaultValue = + 'defaultValue' in argConfig + ? typeof argConfig.defaultValue === 'function' + ? argConfig.defaultValue(context) + : argConfig.defaultValue + : undefined + + const hasMismatchedDefaultValueType = + isRequired && + typeof argValue !== typeof resolvedDefaultValue && + !(argConfig.inputType === 'kcl' || argConfig.skip) + const hasInvalidKclValue = + argConfig.inputType === 'kcl' && + !(argValue as Partial | undefined)?.valueAst + const hasInvalidOptionsValue = + isRequired && + 'options' in argConfig && + !( + typeof argConfig.options === 'function' + ? argConfig.options(context) + : argConfig.options + ).some((o) => o.value === argValue) + + if ( + hasMismatchedDefaultValueType || + hasInvalidKclValue || + hasInvalidOptionsValue + ) { + return reject({ + message: 'Argument payload is of the wrong type', + arg: { + ...argConfig, + name: argName, + }, + }) + } + + if (!argValue && isRequired) { + return reject({ + message: 'Argument payload is falsy but is required', + arg: { + ...argConfig, + name: argName, + }, + }) + } + } catch (e) { + console.error('Error validating argument', context, e) + throw e } } diff --git a/src/machines/modelingMachine.ts b/src/machines/modelingMachine.ts index 6f557e6e8..0b320497b 100644 --- a/src/machines/modelingMachine.ts +++ b/src/machines/modelingMachine.ts @@ -104,6 +104,7 @@ export type ModelingMachineEvent = | { type: 'Constrain parallel' } | { type: 'Constrain remove constraints' } | { type: 'Re-execute' } + | { type: 'Export'; data: ModelingCommandSchema['Export'] } | { type: 'Extrude'; data?: ModelingCommandSchema['Extrude'] } | { type: 'Equip Line tool' } | { type: 'Equip tangential arc to' } @@ -119,7 +120,7 @@ export type MoveDesc = { line: number; snippet: string } export const modelingMachine = createMachine( { - /** @xstate-layout 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-McBNlhGQjynZHZSmibF5HsGoKFmKgKi6KcCAA */ + /** @xstate-layout N4IgpgJg5mDOIC5QFkD2EwBsCWA7KAxAMICGuAxlgNoAMAuoqAA6qzYAu2qujIAHogC0AdgCsAZgB04gEyjhADnEA2GgoUAWJQBoQAT0QBGGuICckmoZkbTM42YWKAvk91oMOfAQDKYdgAJYLDByTm5aBiQQFjYwniiBBFtpGhlxDRphGg1ZURlhXQMEcRLDSVF5UwV84XEK8Rc3dCw8KElsCEwwHz9A4NCuXAjeGI5B3kTBLWFJDWV5hVSZZTrlBUKjS1FZiUrDeWVDasaQdxb8ds7ugFFcdjAAJ0CAaz9yAAthqNG4iaF07bCNLiRYlGiZUyZDbFYQzfIaLSQ5Sw0TGZQnM6eNodLoEW73J6wV7sD5UQyRZisMbcP4IQRrGiSdRmfIqUxrCrQlbKcoQ4RHDTWUx5DHNLGXXHXPjsB4AVwwX0psXGCSEFThkNEymFGlECkM0MM+0ZMgUa1MwuypgRhlFHlaEpufBYD3YiuiVN+qrpdg0klMwkFaU0ermwuhwlUkiBhxUyKUazt5za3mJH2IZEomFTb0+9BGnpVoESRrNkmMga0ahMplkhqOZVL+sM4ksCj1SfFOZJ70k3Y+AEkrj0AkEugNwvnvoWad7DIGyuqOe2rPNDRCLIKaKJa6JBXrRJ2Hf3eyeh7jkCRXn0oABbMB3fwAN0enHIJEw7p+Rf4RhkpkbapNEjTRUgKfQjA0FsUnBOQJHyJQNCPC4Tz7NN3nPbpL2vII7wfAJ3lQB5sAAL24dgPy-Gd4mLP9A0kVQzW3I18mRGRDXEI1yl1LJDBWYwKhkZCU3QtDc0w4huFgGUSDwfxCOIsi7g-fwIGwaTMzAKjlVnWiECsPdNzSGgqnSAD1GhNJ2QsCpRHBXUAJbYSxJ7FzB2HIgpJkuSX1dbB30wVT1IoigtKnJVqRo399OWBR-TyFdlCgtRBUs1lymsAVYRjPdnNQs8PK8h5ZNwfwAEEACFvH8AANbTItpKx21mcQxEMGxOP-NJLJ1eL41gndagaVxTjFY9RIK3FPNwaTirkyrqoATXqr09Ka7ZrDsZQ5H2ZQtXYiDijUOKgVsvi1ErPKJvQiTptmkr-DIKAuhWn8S3SKQQRBU15H1YRTEsuy4QPbKtX+gMrtzNyMMKmbvNKrp8HYPMKQ9HSove1rpD21QANkTjREsw4Tu1KD-oRBNhEh1zJu6O74f8JhHiZ3A1PIWVMBIJ41I00LXt06KrFNWYLU6jIjlUZRLP1P1+SqAMgQRDQxGpj5oduoqHoU0jyI-TA9EenAoCGcK0YaudlhkJlQ3bAEMhWQ1UR5f9Q1hfItRKVXTxu2H7p819-L1g2P2wY3+YxujzByU1-qdsxUUNbbtj1AMqjWf9HGGpp7RQ67xN9hnYFwEgmH8dhUFq8PGs4qRlhBNQA2qMxLOyK2ZDkeZ5m3Rx2699WC7m0qi5LsuK+W03vwF97oMyfjYVa4wNEs9qZj3Hud3nOYcj72nJLhwf-DAABHWUVMRqBkari3a3KFf-qyPIJHAop51bFqTHBbccdMHefamzW5JMC5nragE9qLV3yBYdu-EdzpE3oaWsMwTCcUOBaTIahDwjUxONKGu96YHweGAW8qAXz+HIAAu4sAr5rWsHFQ41h9qaH2DoQ6RxOIWD4rYbIYIzRCSwWNXOuC-7dAAEpgEEGAPgIRZT3GoYLOoUgWyGFrKkdIYgE6HXUFbdIqRkSdwTLafhOcRJCPzpKE+2BS4ABk8BgFHqgT8YD0aNQUNZCQKgbDCnSBZTRdReSK3bnuPaRpf5mJuBY0uIUYB3GwCpLm5BR5yMSFtOKIIPZ8V+go+slhoxrFUJkhESU+5lQAO6yQIkRHWylAo8xCpQfweAABmqACAQG4GAdouAnyoFeJIGA7BBDayUhRTAggmmoCSYgYyfpCkZADGYBW4hLJQXMO2U0aROEdypkY5M0NJClPKfJSpwyVK1M0g03AzSCCPAeERSQTBObsGaQ8W8fS-CDOObrUZ4zJkIGMtsI0NoWy6lNAGSy31Nw0FUCYPIkZDHZ12ahA5HBnwBwCkFXm9TxmtPaZ07pvT+mCF8m+D8YzLkTKcebPSGyrYtkUPYZEyj8hpSUEyMQcg7ClCBMUspKLiWBxqcFc52Kbl3IeSQJ5RFXmEv5QFMlzTfk0qZNuMQAExCaDSoKcsONVFaOZDyw5C1aoXKuW03AHS8D4o6YSkgAAjWAgg+DyopajSeEc-koNyTIWCcg6jrEOv+ba5YMhQSSrIbatQDUoqNTVE1LTRUPHuY8550r3l2odU6n5lLVrRQ2TMHIIIlA7m3FoHqfFpB6j2pxbIgYf47K7KJZFAQjWLTjTi81eKenWrTfawQehnWKs9XUeQFRbCIN1NCVsyIRauLsnqVsuV604Nck28qVV-CtpFQ8W5ibxWSpeW8gZ6a+0DuzW9KZnEkGsVassFYobJ18T9FC9k20do3qjQEJ6XQ21motV0rth7BBfvEVm114DvR5pSEaOocgLQaKKHBHkKi7JGgqGaWEH7Hr4G-VundSaJUpsA8B09YHnEQc4idbITUrAN3nIDec5YWzyFSHO5Y2yEUNqhqu8+yMf24stQBwlPH3gkYLGR6lBMWpWTJluZlAaOQWBWJelQ84rCYeE22hN+H92poGcJ0T05xO5s9cYGF+o1jKwOghpKVt5i0OtGIFE6Il2CJXbygITMHgszZhzLmGK6l2Oxb+ztBL3mee8-5XzDxBBnNCgZiKObkmW3ikoawr706EwDdUbYC85h7Tg76zD4WHw+c5tzIVoVNPbrFcmqVgHius0i2VmLFXKDxbNolqZyXhSpbmNA7LllsrSH5HlncwpCsuZMa5Gx5r7GYBHH0cccRFXeqjjRo4mQjh5CWQGgM5YesmVoYxPhHHl1qxm3Y8uDi8QRLLk9fCsTArxMSWeqeUzYR+n2GYcE6h5ZS0OgrFqrVHBZHZH3C7c3JADlwBwAgK2VBMjSRt60sGuT6kkMGWskJWr-TSOD2xkPoew7JKRqlub8hWzsqaR9NHtQRn+rMFYEhbBakhNufHs2ruYEkLgKVH4FtjhCMt177rrC1GkKafUvCMhan+0UJKMwFzKz1O1KCnEOeXdQA4yQAA5CuAAFVAeB2CwAIGVCAEBAgUVdIzI3dxFXzD9JqfLgpFhJS5NqFqqcsiKCUAoDXkO9f+EN8b03pBQqONJ51-SmhzAmGDKtrQep-VFF4iLVBpRTQrD7kT9gcOReNUsDyJPWQVDbkT4aYEjPIzLChboqCOeYd55J2JsnJZmJMkhJCOYshwTtUrwBaMts5irg6s507rm1YABV7sxLiQ8BJ5cBf9GF1H89fztrmG1EoLxh3-yV6gkyR93rLC6g7JNvZM-8APfn4vlp1xbsQ65w7hE79FA7jsttaE1oeRuzSMxqFOYdjUaYxPZWUVmCuDSV0E8AAeVwHbT-StX2W8Cn0EHALaUECgPYFgJNjXze30nxkZxMF1BMkyEWGfkQB3DihXDQzpU8UwQnymw+H8F538EaRIEoB6CW1UjAHYI5g805nNV+UEHanMDSRhSNEDAkGUS5DkH9BsEODmEsDgmAOwQuDIGwFvAlVaFHkZkEO6GCwE16Q0K0PuEEHLkEHYMoF+QqEAjIOyB9ShEOm2lSSsDBFBUDHUGchMO0PwF0PFXNWXyW0GEVSBEYw5R3EYkDGhDXnLFWDsHBBWBqG8Jh1MJ0PLkPj4GCh0KJFzAQJCw6R8LMIsNyJ7BsL2gYisFqAtHbjSFhGhCghywSkAN9BcKQhOF5wwHgCiDUKgFb2j0EEWDKFyEUDL3UC0B2yKEGJNC1EsFqCFGMDNGchxDAH6PX3pC0HKGRHmRDGAmhHUEXFbH1GkOMmUS9jWPwPZDihLxMHyQr0OiQQWGtEGlsHmAwwv3ymEQuPdRbAtAxySn1GB19UyxfiZTiNalSGqAAmhMwyGS+X82FXJW+JcTWFmDmUUH5BqFSHBWSABFbChW1HyHhRAMRUbXc1RT8nRViyxSRMMzbyMC0W1T7wUU3wNADTkEZFSCghlnmFsMwxjTjWRLnFcSkDUE6lrG2lMwBl23YV1HjFUHOj1BOxJM4zc0NXXU3VpIS3XyOGSAShyA21SkOinQ2jghHSqChMw2A0FLpOj11O2BWHshKCqDyFrEBjUCZGDAqFUBrzqHUwfAvneBtO1PwN1MZAyFWBVwlLXHkwR3+mUUcG2LlKzhVLO17FXQa1Kz82pMCy1I6x1PbF-3ZDslAgUJKEG3RzlnbAAnVDBw+NEify10wCFOpXsAsCGnbBsHUDWBiKjG5MEmQVtnH1TMn17EbO11zxbNzX2DKEyBKE7ItJ7OcOSD4jvR72721ADy5x5z52bNtPXyFHzXnn-GrFkCs0QDTwUOUUzzNFkC3KbN1wNztxNynOSV1FriUHan1HjxUEmIvKDWp2gyOEjC0GJN6L2UnP3NDO3C311EFAjJ3371YQbEYwOJbDbHP0YMv1n04Fv1HlfKmVDGjBjBUB+nBnXDyH+LNA5AfgMj7nQMgOt2wPQjgIIv0nNHKDqCTyZ1S2lKKCCUZyZTP3jC1C9hYIrisNWKgvdSmDkIkBWVUDqH+i-2cLkKgnsDnQmNNBSM0N8KgH8P0LYr4ikADG1CtHkFp1kJ5BMHUEjDXiONUIETaCKPSIrkkWyL8NKI+CMscAx1hCYlanZE5UnUjF5HsHIMswAnaKwpcr8IyICLsQAApyEmA9AABKNikQCQCXTJRiTQFhIoHhRTF9QJOlZU8C2K-S+K-Q-wZK1AVKtK2qlK9KzKsQT6OQUYvKiYydBESnHcawA0muYUFwFwIAA */ id: 'Modeling', tsTypes: {} as import('./modelingMachine.typegen').Typegen0, @@ -170,6 +171,13 @@ export const modelingMachine = createMachine( actions: ['AST extrude'], internal: true, }, + + Export: { + target: 'idle', + internal: true, + cond: 'Has exportable geometry', + actions: 'Engine export', + }, }, entry: 'reset client scene mouse handlers', @@ -530,6 +538,9 @@ export const modelingMachine = createMachine( entry: 'clientToEngine cam sync direction', }, + + 'animating to plane (copy)': {}, + 'animating to plane (copy) (copy)': {}, }, initial: 'idle', diff --git a/src/routes/Home.tsx b/src/routes/Home.tsx index ca5f1c8e8..35c6fe001 100644 --- a/src/routes/Home.tsx +++ b/src/routes/Home.tsx @@ -72,7 +72,7 @@ const Home = () => { } ) - const [state, send] = useMachine(homeMachine, { + const [state, send, actor] = useMachine(homeMachine, { context: { projects: loadedProjects, defaultProjectName, @@ -176,6 +176,7 @@ const Home = () => { send, state, commandBarConfig: homeCommandBarConfig, + actor, }) useEffect(() => { diff --git a/src/routes/Onboarding/Export.tsx b/src/routes/Onboarding/Export.tsx index c879550f7..863e7923c 100644 --- a/src/routes/Onboarding/Export.tsx +++ b/src/routes/Onboarding/Export.tsx @@ -21,7 +21,7 @@ export default function Export() {

Export

- Try opening the project menu and clicking "Export Model". + Try opening the project menu and clicking "Export Part".

{APP_NAME} uses{' '}