Files
modeling-app/src/components/ModelingSidebar/ModelingPanes/FeatureTreePane.tsx
Andrew Varga fe22a67cf6 #5184 Ability to toggle default planes visibility (#6333)
* add first version of DefaultPlanes to FeatureTreePane

* fix lint issues

* don't show default planes UI in sketch mode

* lint

* toggling default planes: implementation in xstate

* revert malformed modelingMachine.ts

* lint

* save and restore default plane visibility when returning to modeling mode

* fmt

* tsc

* introduce new cleanup state with actor when exiting sketch mode

* temp remove restore default plane visibility - causes error on starting up a project

* set selection filter after executeAst - this is a wip hacky fix

* remove unused early return: this also caused plane selection to only work with double click

* lint

* no need to set selection filter to curves only, we want faces to be selectable in modeling mode, even though this means default planes are also selectable

* tightening types for visibility map

* lint

* cleanups

* fix border issue when visibility toggle is not active and props.visible === true

* ui updates on FeatureTreePane/default planes

* no pointer cursor for unselectable default planes

* show default planes initially even for non-empty projects

* dont show default planes initially when project is not empty

* fix test: Only show axis planes when there are no errors

* fixes for sketch tests

* better initialize for planes

* lint

* fix uneccessary 'reset camera position' in sketch entry

* revert hiding/showing content depending on artifact graph for tests

* only show default planes when there are no errors

* disable Restore default plane visibility, was causing temporary flashing of default planes when exiting sketch mode

* Always show default plane visibility toggles, regardless of being on/off

* revert modelingMachine to original idle states to avoid 'zoom_to_fit' test regression - probably racing condition

* fmt
2025-04-25 18:21:19 +02:00

543 lines
16 KiB
TypeScript

import type { Diagnostic } from '@codemirror/lint'
import { useMachine, useSelector } from '@xstate/react'
import type { ComponentProps } from 'react'
import { useCallback, useEffect, useMemo, useRef } from 'react'
import type { Actor, Prop } from 'xstate'
import type { Operation } from '@rust/kcl-lib/bindings/Operation'
import { ContextMenu, ContextMenuItem } from '@src/components/ContextMenu'
import type { CustomIconName } from '@src/components/CustomIcon'
import { CustomIcon } from '@src/components/CustomIcon'
import Loading from '@src/components/Loading'
import { useModelingContext } from '@src/hooks/useModelingContext'
import { useKclContext } from '@src/lang/KclProvider'
import {
codeRefFromRange,
getArtifactFromRange,
} from '@src/lang/std/artifactGraph'
import { sourceRangeFromRust } from '@src/lang/wasm'
import {
filterOperations,
getOperationIcon,
getOperationLabel,
stdLibMap,
} from '@src/lib/operations'
import { editorManager, kclManager, rustContext } from '@src/lib/singletons'
import {
featureTreeMachine,
featureTreeMachineDefaultContext,
} from '@src/machines/featureTreeMachine'
import {
editorIsMountedSelector,
kclEditorActor,
selectionEventSelector,
} from '@src/machines/kclEditorMachine'
export const FeatureTreePane = () => {
const isEditorMounted = useSelector(kclEditorActor, editorIsMountedSelector)
const lastSelectionEvent = useSelector(kclEditorActor, selectionEventSelector)
const { send: modelingSend, state: modelingState } = useModelingContext()
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [_featureTreeState, featureTreeSend] = useMachine(
featureTreeMachine.provide({
guards: {
codePaneIsOpen: () =>
modelingState.context.store.openPanes.includes('code') &&
editorManager.editorView !== null,
},
actions: {
openCodePane: () => {
modelingSend({
type: 'Set context',
data: {
openPanes: [...modelingState.context.store.openPanes, 'code'],
},
})
},
sendEditFlowStart: () => {
modelingSend({ type: 'Enter sketch' })
},
scrollToError: () => {
editorManager.scrollToFirstErrorDiagnosticIfExists()
},
sendSelectionEvent: ({ context }) => {
if (!context.targetSourceRange) {
return
}
const artifact = context.targetSourceRange
? getArtifactFromRange(
context.targetSourceRange,
kclManager.artifactGraph
)
: null
if (!artifact) {
modelingSend({
type: 'Set selection',
data: {
selectionType: 'singleCodeCursor',
selection: {
codeRef: codeRefFromRange(
context.targetSourceRange,
kclManager.ast
),
},
scrollIntoView: true,
},
})
} else {
modelingSend({
type: 'Set selection',
data: {
selectionType: 'singleCodeCursor',
selection: {
artifact: artifact,
codeRef: codeRefFromRange(
context.targetSourceRange,
kclManager.ast
),
},
scrollIntoView: true,
},
})
}
},
},
}),
{
input: {
...featureTreeMachineDefaultContext,
},
// devTools: true,
}
)
// If there are parse errors we show the last successful operations
// and overlay a message on top of the pane
const parseErrors = kclManager.errors.filter((e) => e.kind !== 'engine')
// If there are engine errors we show the successful operations
// Errors return an operation list, so use the longest one if there are multiple
const longestErrorOperationList = kclManager.errors.reduce((acc, error) => {
return error.operations && error.operations.length > acc.length
? error.operations
: acc
}, [] as Operation[])
const unfilteredOperationList = !parseErrors.length
? !kclManager.errors.length
? kclManager.execState.operations
: longestErrorOperationList
: kclManager.lastSuccessfulOperations
// We filter out operations that are not useful to show in the feature tree
const operationList = filterOperations(unfilteredOperationList)
// Watch for changes in the open panes and send an event to the feature tree machine
useEffect(() => {
const codeOpen = modelingState.context.store.openPanes.includes('code')
if (codeOpen && isEditorMounted) {
featureTreeSend({ type: 'codePaneOpened' })
}
}, [modelingState.context.store.openPanes, isEditorMounted])
// Watch for changes in the selection and send an event to the feature tree machine
useEffect(() => {
featureTreeSend({ type: 'selected' })
}, [lastSelectionEvent])
function goToError() {
featureTreeSend({ type: 'goToError' })
}
return (
<div className="relative">
<section
data-testid="debug-panel"
className="absolute inset-0 p-1 box-border overflow-auto"
>
{kclManager.isExecuting ? (
<Loading className="h-full">Building feature tree...</Loading>
) : (
<>
{!modelingState.matches('Sketch') && <DefaultPlanes />}
{parseErrors.length > 0 && (
<div
className={`absolute inset-0 rounded-lg p-2 ${
operationList.length &&
`bg-destroy-10/40 dark:bg-destroy-80/40`
}`}
>
<div className="text-sm bg-destroy-80 text-chalkboard-10 py-1 px-2 rounded flex gap-2 items-center">
<p className="flex-1">
Errors found in KCL code.
<br />
Please fix them before continuing.
</p>
<button
onClick={goToError}
className="bg-chalkboard-10 text-destroy-80 p-1 rounded-sm flex-none hover:bg-chalkboard-10 hover:border-destroy-70 hover:text-destroy-80 border-transparent"
>
View error
</button>
</div>
</div>
)}
{operationList.map((operation) => {
const key = `${operation.type}-${
'name' in operation ? operation.name : 'anonymous'
}-${
'sourceRange' in operation ? operation.sourceRange[0] : 'start'
}`
return (
<OperationItem
key={key}
item={operation}
send={featureTreeSend}
/>
)
})}
</>
)}
</section>
</div>
)
}
interface VisibilityToggleProps {
visible: boolean
onVisibilityChange: () => unknown
}
/**
* A button that toggles the visibility of an entity
* tied to an artifact in the feature tree.
* For now just used for default planes.
*/
const VisibilityToggle = (props: VisibilityToggleProps) => {
const visible = props.visible
const handleToggleVisible = useCallback(() => {
props.onVisibilityChange()
}, [props.onVisibilityChange])
return (
<button onClick={handleToggleVisible} className="p-0 m-0">
<CustomIcon
name={visible ? 'eyeOpen' : 'eyeCrossedOut'}
className="w-5 h-5"
/>
</button>
)
}
/**
* More generic version of OperationListItem,
* to be used for default planes after we fix them and
* add them to the artifact graph / feature tree
*/
const OperationItemWrapper = ({
icon,
name,
visibilityToggle,
menuItems,
errors,
className,
selectable = true,
...props
}: React.HTMLAttributes<HTMLButtonElement> & {
icon: CustomIconName
name: string
visibilityToggle?: VisibilityToggleProps
menuItems?: ComponentProps<typeof ContextMenu>['items']
errors?: Diagnostic[]
selectable?: boolean
}) => {
const menuRef = useRef<HTMLDivElement>(null)
return (
<div
ref={menuRef}
className={`flex select-none items-center group/item my-0 py-0.5 px-1 ${selectable ? 'focus-within:bg-primary/10 hover:bg-primary/5' : ''}`}
>
<button
{...props}
className={`reset flex-1 flex items-center gap-2 text-left text-base ${selectable ? 'border-transparent dark:border-transparent' : 'border-none cursor-default'} ${className}`}
>
<CustomIcon name={icon} className="w-5 h-5 block" />
{name}
</button>
{errors && errors.length > 0 && (
<em className="text-destroy-80 text-xs">has error</em>
)}
{visibilityToggle && <VisibilityToggle {...visibilityToggle} />}
{menuItems && (
<ContextMenu menuTargetElement={menuRef} items={menuItems} />
)}
</div>
)
}
/**
* A button with an icon, name, and context menu
* for an operation in the feature tree.
*/
const OperationItem = (props: {
item: Operation
send: Prop<Actor<typeof featureTreeMachine>, 'send'>
}) => {
const kclContext = useKclContext()
const name = getOperationLabel(props.item)
const errors = useMemo(() => {
return kclContext.diagnostics.filter(
(diag) =>
diag.severity === 'error' &&
'sourceRange' in props.item &&
diag.from >= props.item.sourceRange[0] &&
diag.to <= props.item.sourceRange[1]
)
}, [kclContext.diagnostics.length])
function selectOperation() {
if (props.item.type === 'GroupEnd') {
return
}
props.send({
type: 'selectOperation',
data: {
targetSourceRange: sourceRangeFromRust(props.item.sourceRange),
},
})
}
/**
* For now we can only enter the "edit" flow for the startSketchOn operation.
* TODO: https://github.com/KittyCAD/modeling-app/issues/4442
*/
function enterEditFlow() {
if (
props.item.type === 'StdLibCall' ||
props.item.type === 'KclStdLibCall'
) {
props.send({
type: 'enterEditFlow',
data: {
targetSourceRange: sourceRangeFromRust(props.item.sourceRange),
currentOperation: props.item,
},
})
}
}
function enterAppearanceFlow() {
if (
props.item.type === 'StdLibCall' ||
props.item.type === 'KclStdLibCall'
) {
props.send({
type: 'enterAppearanceFlow',
data: {
targetSourceRange: sourceRangeFromRust(props.item.sourceRange),
currentOperation: props.item,
},
})
}
}
function enterTranslateFlow() {
if (
props.item.type === 'StdLibCall' ||
props.item.type === 'KclStdLibCall' ||
props.item.type === 'GroupBegin'
) {
props.send({
type: 'enterTranslateFlow',
data: {
targetSourceRange: sourceRangeFromRust(props.item.sourceRange),
currentOperation: props.item,
},
})
}
}
function enterRotateFlow() {
if (
props.item.type === 'StdLibCall' ||
props.item.type === 'KclStdLibCall' ||
props.item.type === 'GroupBegin'
) {
props.send({
type: 'enterRotateFlow',
data: {
targetSourceRange: sourceRangeFromRust(props.item.sourceRange),
currentOperation: props.item,
},
})
}
}
function deleteOperation() {
if (
props.item.type === 'StdLibCall' ||
props.item.type === 'GroupBegin' ||
props.item.type === 'KclStdLibCall'
) {
props.send({
type: 'deleteOperation',
data: {
targetSourceRange: sourceRangeFromRust(props.item.sourceRange),
},
})
}
}
const menuItems = useMemo(
() => [
<ContextMenuItem
onClick={() => {
if (props.item.type === 'GroupEnd') {
return
}
props.send({
type: 'goToKclSource',
data: {
targetSourceRange: sourceRangeFromRust(props.item.sourceRange),
},
})
}}
>
View KCL source code
</ContextMenuItem>,
...(props.item.type === 'GroupBegin' &&
props.item.group.type === 'FunctionCall'
? [
<ContextMenuItem
onClick={() => {
if (props.item.type !== 'GroupBegin') {
return
}
if (props.item.group.type !== 'FunctionCall') {
// TODO: Add module instance support.
return
}
const functionRange = props.item.group.functionSourceRange
// For some reason, the cursor goes to the end of the source
// range we select. So set the end equal to the beginning.
functionRange[1] = functionRange[0]
props.send({
type: 'goToKclSource',
data: {
targetSourceRange: sourceRangeFromRust(functionRange),
},
})
}}
>
View function definition
</ContextMenuItem>,
]
: []),
...(props.item.type === 'StdLibCall' ||
props.item.type === 'KclStdLibCall'
? [
<ContextMenuItem
disabled={!stdLibMap[props.item.name]?.prepareToEdit}
onClick={enterEditFlow}
hotkey="Double click"
>
Edit
</ContextMenuItem>,
<ContextMenuItem
disabled={!stdLibMap[props.item.name]?.supportsAppearance}
onClick={enterAppearanceFlow}
data-testid="context-menu-set-appearance"
>
Set appearance
</ContextMenuItem>,
]
: []),
...(props.item.type === 'StdLibCall' ||
props.item.type === 'KclStdLibCall' ||
props.item.type === 'GroupBegin'
? [
<ContextMenuItem
onClick={enterTranslateFlow}
data-testid="context-menu-set-translate"
disabled={
props.item.type !== 'GroupBegin' &&
!stdLibMap[props.item.name]?.supportsTransform
}
>
Set translate
</ContextMenuItem>,
<ContextMenuItem
onClick={enterRotateFlow}
data-testid="context-menu-set-rotate"
disabled={
props.item.type !== 'GroupBegin' &&
!stdLibMap[props.item.name]?.supportsTransform
}
>
Set rotate
</ContextMenuItem>,
<ContextMenuItem
onClick={deleteOperation}
hotkey="Delete"
data-testid="context-menu-delete"
>
Delete
</ContextMenuItem>,
]
: []),
],
[props.item, props.send]
)
return (
<OperationItemWrapper
icon={getOperationIcon(props.item)}
name={name}
menuItems={menuItems}
onClick={selectOperation}
onDoubleClick={enterEditFlow}
errors={errors}
/>
)
}
const DefaultPlanes = () => {
const { state: modelingState, send } = useModelingContext()
const defaultPlanes = rustContext.defaultPlanes
if (!defaultPlanes) return null
const planes = [
{ name: 'Front plane', id: defaultPlanes.xz, key: 'xz' },
{ name: 'Top plane', id: defaultPlanes.xy, key: 'xy' },
{ name: 'Side plane', id: defaultPlanes.yz, key: 'yz' },
] as const
return (
<div className="mb-2">
{planes.map((plane) => (
<OperationItemWrapper
key={plane.key}
icon={'plane'}
name={plane.name}
selectable={false}
visibilityToggle={{
visible: modelingState.context.defaultPlaneVisibility[plane.key],
onVisibilityChange: () => {
send({
type: 'Toggle default plane visibility',
planeId: plane.id,
planeKey: plane.key,
})
},
}}
/>
))}
<div className="h-px bg-chalkboard-50/20 my-2" />
</div>
)
}