Add offset plane point-and-click user flow (#4552)

* Add a code mod for offset plane

* Add support for default plane selections to our `otherSelections` object

* Make availableVars work without a selection range
(because default planes don't have one)

* Make default planes selectable in cmdbar even if AST is empty

* Add offset plane command and activate in toolbar

* Avoid unnecessary error when sketching on offset plane by returning early

* Add supporting test features for offset plane E2E test

* Add WIP E2E test for offset plane
Struggling to get local electron test suite running properly

* Typos

* Lints

* Fix test by making it a web-based one:
I couldn't use the cmdBar fixture with an electron test for some reason.

* Update src/lib/commandBarConfigs/modelingCommandConfig.ts

* Update src/machines/modelingMachine.ts

* Revert changes to `homePageFixture`, as they were unused

* @Irev-Dev feedback: convert action to actor, fix machine layout

* Update plane icon to be not dashed, follow conventions closer
This commit is contained in:
Frank Noirot
2024-11-26 11:36:14 -05:00
committed by GitHub
parent 1d45bed649
commit 4423ae16dc
17 changed files with 389 additions and 83 deletions

View File

@ -35,7 +35,7 @@ export class CmdBarFixture {
} }
private _serialiseCmdBar = async (): Promise<CmdBarSerialised> => { private _serialiseCmdBar = async (): Promise<CmdBarSerialised> => {
const reviewForm = await this.page.locator('#review-form') const reviewForm = this.page.locator('#review-form')
const getHeaderArgs = async () => { const getHeaderArgs = async () => {
const inputs = await this.page.getByTestId('cmd-bar-input-tab').all() const inputs = await this.page.getByTestId('cmd-bar-input-tab').all()
const entries = await Promise.all( const entries = await Promise.all(

View File

@ -6,6 +6,7 @@ export class ToolbarFixture {
public page: Page public page: Page
extrudeButton!: Locator extrudeButton!: Locator
offsetPlaneButton!: Locator
startSketchBtn!: Locator startSketchBtn!: Locator
lineBtn!: Locator lineBtn!: Locator
rectangleBtn!: Locator rectangleBtn!: Locator
@ -25,6 +26,7 @@ export class ToolbarFixture {
reConstruct = (page: Page) => { reConstruct = (page: Page) => {
this.page = page this.page = page
this.extrudeButton = page.getByTestId('extrude') this.extrudeButton = page.getByTestId('extrude')
this.offsetPlaneButton = page.getByTestId('plane-offset')
this.startSketchBtn = page.getByTestId('sketch') this.startSketchBtn = page.getByTestId('sketch')
this.lineBtn = page.getByTestId('line') this.lineBtn = page.getByTestId('line')
this.rectangleBtn = page.getByTestId('corner-rectangle') this.rectangleBtn = page.getByTestId('corner-rectangle')

View File

@ -551,3 +551,53 @@ test(`Verify axis, origin, and horizontal snapping`, async ({
) )
}) })
}) })
test(`Offset plane point-and-click`, async ({
app,
scene,
editor,
toolbar,
cmdBar,
}) => {
await app.initialise()
// One dumb hardcoded screen pixel value
const testPoint = { x: 700, y: 150 }
const [clickOnXzPlane] = scene.makeMouseHelpers(testPoint.x, testPoint.y)
const expectedOutput = `plane001 = offsetPlane('XZ', 5)`
await test.step(`Look for the blue of the XZ plane`, async () => {
await scene.expectPixelColor([50, 51, 96], testPoint, 15)
})
await test.step(`Go through the command bar flow`, async () => {
await toolbar.offsetPlaneButton.click()
await cmdBar.expectState({
stage: 'arguments',
currentArgKey: 'plane',
currentArgValue: '',
headerArguments: { Plane: '', Distance: '' },
highlightedHeaderArg: 'plane',
commandName: 'Offset plane',
})
await clickOnXzPlane()
await cmdBar.expectState({
stage: 'arguments',
currentArgKey: 'distance',
currentArgValue: '5',
headerArguments: { Plane: '1 plane', Distance: '' },
highlightedHeaderArg: 'distance',
commandName: 'Offset plane',
})
await cmdBar.progressCmdBar()
})
await test.step(`Confirm code is added to the editor, scene has changed`, async () => {
await editor.expectEditor.toContain(expectedOutput)
await editor.expectState({
diagnostics: [],
activeLines: [expectedOutput],
highlightedCode: '',
})
await scene.expectPixelColor([74, 74, 74], testPoint, 15)
})
})

View File

@ -22,7 +22,7 @@ import {
import { Coords2d, compareVec2Epsilon2 } from 'lang/std/sketch' import { Coords2d, compareVec2Epsilon2 } from 'lang/std/sketch'
import { useModelingContext } from 'hooks/useModelingContext' import { useModelingContext } from 'hooks/useModelingContext'
import * as TWEEN from '@tweenjs/tween.js' import * as TWEEN from '@tweenjs/tween.js'
import { Axis } from 'lib/selections' import { Axis, NonCodeSelection } from 'lib/selections'
import { type BaseUnit } from 'lib/settings/settingsTypes' import { type BaseUnit } from 'lib/settings/settingsTypes'
import { CameraControls } from './CameraControls' import { CameraControls } from './CameraControls'
import { EngineCommandManager } from 'lang/std/engineConnection' import { EngineCommandManager } from 'lang/std/engineConnection'
@ -654,7 +654,7 @@ export class SceneInfra {
await this.onClickCallback({ mouseEvent, intersects }) await this.onClickCallback({ mouseEvent, intersects })
} }
} }
updateOtherSelectionColors = (otherSelections: Axis[]) => { updateOtherSelectionColors = (otherSelections: NonCodeSelection[]) => {
const axisGroup = this.scene.children.find( const axisGroup = this.scene.children.find(
({ userData }) => userData?.type === AXIS_GROUP ({ userData }) => userData?.type === AXIS_GROUP
) )

View File

@ -1,21 +1,26 @@
import { useSelector } from '@xstate/react' import { useSelector } from '@xstate/react'
import { useCommandsContext } from 'hooks/useCommandsContext' import { useCommandsContext } from 'hooks/useCommandsContext'
import { useKclContext } from 'lang/KclProvider'
import { Artifact } from 'lang/std/artifactGraph' import { Artifact } from 'lang/std/artifactGraph'
import { CommandArgument } from 'lib/commandTypes' import { CommandArgument } from 'lib/commandTypes'
import { import {
canSubmitSelectionArg, canSubmitSelectionArg,
getSelectionType, getSelectionCountByType,
getSelectionTypeDisplayText, getSelectionTypeDisplayText,
} from 'lib/selections' } from 'lib/selections'
import { kclManager } from 'lib/singletons'
import { reportRejection } from 'lib/trap'
import { toSync } from 'lib/utils'
import { modelingMachine } from 'machines/modelingMachine' import { modelingMachine } from 'machines/modelingMachine'
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { StateFrom } from 'xstate' import { StateFrom } from 'xstate'
const semanticEntityNames: { [key: string]: Array<Artifact['type']> } = { const semanticEntityNames: {
[key: string]: Array<Artifact['type'] | 'defaultPlane'>
} = {
face: ['wall', 'cap', 'solid2D'], face: ['wall', 'cap', 'solid2D'],
edge: ['segment', 'sweepEdge', 'edgeCutEdge'], edge: ['segment', 'sweepEdge', 'edgeCutEdge'],
point: [], point: [],
plane: ['defaultPlane'],
} }
function getSemanticSelectionType(selectionType: Array<Artifact['type']>) { function getSemanticSelectionType(selectionType: Array<Artifact['type']>) {
@ -43,21 +48,13 @@ function CommandBarSelectionInput({
stepBack: () => void stepBack: () => void
onSubmit: (data: unknown) => void onSubmit: (data: unknown) => void
}) { }) {
const { code } = useKclContext()
const inputRef = useRef<HTMLInputElement>(null) const inputRef = useRef<HTMLInputElement>(null)
const { commandBarState, commandBarSend } = useCommandsContext() const { commandBarState, commandBarSend } = useCommandsContext()
const [hasSubmitted, setHasSubmitted] = useState(false) const [hasSubmitted, setHasSubmitted] = useState(false)
const selection = useSelector(arg.machineActor, selectionSelector) const selection = useSelector(arg.machineActor, selectionSelector)
const selectionsByType = useMemo(() => { const selectionsByType = useMemo(() => {
const selectionRangeEnd = !selection return getSelectionCountByType(selection)
? null }, [selection])
: selection?.graphSelections[0]?.codeRef?.range[1]
return !selectionRangeEnd || selectionRangeEnd === code.length || !selection
? 'none'
: !selection
? 'none'
: getSelectionType(selection)
}, [selection, code])
const canSubmitSelection = useMemo<boolean>( const canSubmitSelection = useMemo<boolean>(
() => canSubmitSelectionArg(selectionsByType, arg), () => canSubmitSelectionArg(selectionsByType, arg),
[selectionsByType] [selectionsByType]
@ -67,6 +64,30 @@ function CommandBarSelectionInput({
inputRef.current?.focus() inputRef.current?.focus()
}, [selection, inputRef]) }, [selection, inputRef])
// Show the default planes if the selection type is 'plane'
useEffect(() => {
if (arg.selectionTypes.includes('plane') && !canSubmitSelection) {
toSync(() => {
return Promise.all([
kclManager.showPlanes(),
kclManager.setSelectionFilter(['plane', 'object']),
])
}, reportRejection)()
}
return () => {
toSync(() => {
const promises = [
new Promise(() => kclManager.defaultSelectionFilter()),
]
if (!kclManager._isAstEmpty(kclManager.ast)) {
promises.push(kclManager.hidePlanes())
}
return Promise.all(promises)
}, reportRejection)()
}
}, [])
// Fast-forward through this arg if it's marked as skippable // Fast-forward through this arg if it's marked as skippable
// and we have a valid selection already // and we have a valid selection already
useEffect(() => { useEffect(() => {
@ -109,11 +130,15 @@ function CommandBarSelectionInput({
{arg.warningMessage} {arg.warningMessage}
</p> </p>
)} )}
<span data-testid="cmd-bar-arg-name" className="sr-only">
{arg.name}
</span>
<input <input
id="selection" id="selection"
name="selection" name="selection"
ref={inputRef} ref={inputRef}
required required
data-testid="cmd-bar-arg-value"
placeholder="Select an entity with your mouse" placeholder="Select an entity with your mouse"
className="absolute inset-0 w-full h-full opacity-0 cursor-default" className="absolute inset-0 w-full h-full opacity-0 cursor-default"
onKeyDown={(event) => { onKeyDown={(event) => {

View File

@ -818,15 +818,16 @@ const CustomIconMap = {
), ),
plane: ( plane: (
<svg <svg
width="20"
height="20"
viewBox="0 0 20 20" viewBox="0 0 20 20"
fill="none" fill="none"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
aria-label="plane"
> >
<path <path
fillRule="evenodd" fillRule="evenodd"
clipRule="evenodd" clipRule="evenodd"
d="M4.92871 5.11391L4.43964 5.00995V4.10898V3.60898V3.10898L4.92871 3.21293L5.41778 3.31689L6.29907 3.50421V4.00421V4.50421L5.41778 4.31689V5.21786L4.92871 5.11391ZM11.8774 4.68991L8.1585 3.89945V4.39945V4.89945L11.8774 5.68991V5.18991V4.68991ZM13.7368 5.08515V5.58515V6.08515L14.6181 6.27247V7.17344L15.1071 7.2774L15.5962 7.38135V6.48038V5.98038V5.48038L15.1071 5.37643L14.6181 5.27247L13.7368 5.08515ZM15.5962 9.28233L15.1071 9.17837L14.6181 9.07441V12.8764L15.1071 12.9803L15.5962 13.0843V9.28233ZM15.5962 14.9852L15.1071 14.8813L14.6181 14.7773V15.6783L13.7368 15.491V15.991V16.491L14.6181 16.6783L15.1071 16.7823L15.5962 16.8862V16.3862V15.8862V14.9852ZM11.8774 16.0957V15.5957V15.0957L8.1585 14.3053V14.8053V15.3053L11.8774 16.0957ZM6.29907 14.91V14.41V13.91L5.41778 13.7227V12.8217L4.92871 12.7178L4.43964 12.6138V13.5148V14.0148V14.5148L4.92871 14.6188L5.41778 14.7227L6.29907 14.91ZM4.43964 10.7129L4.92871 10.8168L5.41778 10.9208V7.11883L4.92871 7.01488L4.43964 6.91092V10.7129Z" d="M10.9781 5.49876L14.6181 6.27247V9.99381L10.9781 9.22011V5.49876ZM10 4.29085L10.9781 4.49876L14.6181 5.27247L14.6182 5.27247L15.5963 5.48038H15.5963V6.48038V10.2017V11.2017L15.5963 11.2017V15.8862V16.8862L14.6181 16.6783L5.41784 14.7227L4.4397 14.5148V13.5148V4.10898V3.10898L5.41784 3.31689L10 4.29085ZM14.6181 10.9938V15.6783L5.41784 13.7227V4.31689L10 5.29085V9.0122V10.0122L10.9781 10.2201L14.6181 10.9938Z"
fill="currentColor" fill="currentColor"
/> />
</svg> </svg>

View File

@ -317,6 +317,7 @@ export const ModelingMachineProvider = ({
}) })
}) })
} }
let selections: Selections = { let selections: Selections = {
graphSelections: [], graphSelections: [],
otherSelections: [], otherSelections: [],
@ -375,7 +376,10 @@ export const ModelingMachineProvider = ({
} }
} }
if (setSelections.selectionType === 'otherSelection') { if (
setSelections.selectionType === 'axisSelection' ||
setSelections.selectionType === 'defaultPlaneSelection'
) {
if (editorManager.isShiftDown) { if (editorManager.isShiftDown) {
selections = { selections = {
graphSelections: selectionRanges.graphSelections, graphSelections: selectionRanges.graphSelections,
@ -387,20 +391,11 @@ export const ModelingMachineProvider = ({
otherSelections: [setSelections.selection], otherSelections: [setSelections.selection],
} }
} }
const { engineEvents, updateSceneObjectColors } =
handleSelectionBatch({
selections: selections,
})
engineEvents &&
engineEvents.forEach((event) => {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
engineCommandManager.sendSceneCommand(event)
})
updateSceneObjectColors()
return { return {
selectionRanges: selections, selectionRanges: selections,
} }
} }
if (setSelections.selectionType === 'completeSelection') { if (setSelections.selectionType === 'completeSelection') {
editorManager.selectRange(setSelections.selection) editorManager.selectRange(setSelections.selection)
if (!sketchDetails) if (!sketchDetails)

View File

@ -17,6 +17,7 @@ import {
import { useRouteLoaderData } from 'react-router-dom' import { useRouteLoaderData } from 'react-router-dom'
import { PATHS } from 'lib/paths' import { PATHS } from 'lib/paths'
import { IndexLoaderData } from 'lib/types' import { IndexLoaderData } from 'lib/types'
import { useCommandsContext } from 'hooks/useCommandsContext'
enum StreamState { enum StreamState {
Playing = 'playing', Playing = 'playing',
@ -30,6 +31,7 @@ export const Stream = () => {
const videoRef = useRef<HTMLVideoElement>(null) const videoRef = useRef<HTMLVideoElement>(null)
const { settings } = useSettingsAuthContext() const { settings } = useSettingsAuthContext()
const { state, send } = useModelingContext() const { state, send } = useModelingContext()
const { commandBarState } = useCommandsContext()
const { mediaStream } = useAppStream() const { mediaStream } = useAppStream()
const { overallState, immediateState } = useNetworkContext() const { overallState, immediateState } = useNetworkContext()
const [streamState, setStreamState] = useState(StreamState.Unset) const [streamState, setStreamState] = useState(StreamState.Unset)
@ -260,7 +262,15 @@ export const Stream = () => {
if (!videoRef.current) return if (!videoRef.current) return
// If we're in sketch mode, don't send a engine-side select event // If we're in sketch mode, don't send a engine-side select event
if (state.matches('Sketch')) return if (state.matches('Sketch')) return
if (state.matches({ idle: 'showPlanes' })) return // Only respect default plane selection if we're on a selection command argument
if (
state.matches({ idle: 'showPlanes' }) &&
!(
commandBarState.matches('Gathering arguments') &&
commandBarState.context.currentArgument?.inputType === 'selection'
)
)
return
// If we're mousing up from a camera drag, don't send a select event // If we're mousing up from a camera drag, don't send a select event
if (sceneInfra.camControls.wasDragging === true) return if (sceneInfra.camControls.wasDragging === true) return

View File

@ -169,6 +169,7 @@ export function useEngineConnectionSubscriptions() {
pathToNode: artifact.codeRef.pathToNode, pathToNode: artifact.codeRef.pathToNode,
}, },
}) })
return
} }
// Artifact is likely an extrusion face // Artifact is likely an extrusion face

View File

@ -23,6 +23,7 @@ import { codeManager, editorManager, sceneInfra } from 'lib/singletons'
import { Diagnostic } from '@codemirror/lint' import { Diagnostic } from '@codemirror/lint'
import { markOnce } from 'lib/performance' import { markOnce } from 'lib/performance'
import { Node } from 'wasm-lib/kcl/bindings/Node' import { Node } from 'wasm-lib/kcl/bindings/Node'
import { EntityType_type } from '@kittycad/lib/dist/types/src/models'
interface ExecuteArgs { interface ExecuteArgs {
ast?: Node<Program> ast?: Node<Program>
@ -281,7 +282,7 @@ export class KclManager {
this.lints = await lintAst({ ast: ast }) this.lints = await lintAst({ ast: ast })
sceneInfra.modelingSend({ type: 'code edit during sketch' }) sceneInfra.modelingSend({ type: 'code edit during sketch' })
defaultSelectionFilter(execState.memory, this.engineCommandManager) setSelectionFilterToDefault(execState.memory, this.engineCommandManager)
if (args.zoomToFit) { if (args.zoomToFit) {
let zoomObjectId: string | undefined = '' let zoomObjectId: string | undefined = ''
@ -568,8 +569,13 @@ export class KclManager {
} }
return Promise.all(thePromises) return Promise.all(thePromises)
} }
/** TODO: this function is hiding unawaited asynchronous work */
defaultSelectionFilter() { defaultSelectionFilter() {
defaultSelectionFilter(this.programMemory, this.engineCommandManager) setSelectionFilterToDefault(this.programMemory, this.engineCommandManager)
}
/** TODO: this function is hiding unawaited asynchronous work */
setSelectionFilter(filter: EntityType_type[]) {
setSelectionFilter(filter, this.engineCommandManager)
} }
/** /**
@ -591,18 +597,35 @@ export class KclManager {
} }
} }
function defaultSelectionFilter( const defaultSelectionFilter: EntityType_type[] = [
'face',
'edge',
'solid2d',
'curve',
'object',
]
/** TODO: This function is not synchronous but is currently treated as such */
function setSelectionFilterToDefault(
programMemory: ProgramMemory, programMemory: ProgramMemory,
engineCommandManager: EngineCommandManager engineCommandManager: EngineCommandManager
) { ) {
// eslint-disable-next-line @typescript-eslint/no-floating-promises // eslint-disable-next-line @typescript-eslint/no-floating-promises
programMemory.hasSketchOrSolid() && setSelectionFilter(defaultSelectionFilter, engineCommandManager)
}
/** TODO: This function is not synchronous but is currently treated as such */
function setSelectionFilter(
filter: EntityType_type[],
engineCommandManager: EngineCommandManager
) {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
engineCommandManager.sendSceneCommand({ engineCommandManager.sendSceneCommand({
type: 'modeling_cmd_req', type: 'modeling_cmd_req',
cmd_id: uuidv4(), cmd_id: uuidv4(),
cmd: { cmd: {
type: 'set_selection_filter', type: 'set_selection_filter',
filter: ['face', 'edge', 'solid2d', 'curve'], filter,
}, },
}) })
} }

View File

@ -527,6 +527,45 @@ export function sketchOnExtrudedFace(
} }
} }
/**
* Append an offset plane to the AST
*/
export function addOffsetPlane({
node,
defaultPlane,
offset,
}: {
node: Node<Program>
defaultPlane: DefaultPlaneStr
offset: Expr
}): { modifiedAst: Node<Program>; pathToNode: PathToNode } {
const modifiedAst = structuredClone(node)
const newPlaneName = findUniqueName(node, KCL_DEFAULT_CONSTANT_PREFIXES.PLANE)
const newPlane = createVariableDeclaration(
newPlaneName,
createCallExpressionStdLib('offsetPlane', [
createLiteral(defaultPlane.toUpperCase()),
offset,
])
)
modifiedAst.body.push(newPlane)
const pathToNode: PathToNode = [
['body', ''],
[modifiedAst.body.length - 1, 'index'],
['declarations', 'VariableDeclaration'],
['0', 'index'],
['init', 'VariableDeclarator'],
['arguments', 'CallExpression'],
[0, 'index'],
]
return {
modifiedAst,
pathToNode,
}
}
/** /**
* Modify the AST to create a new sketch using the variable declaration * Modify the AST to create a new sketch using the variable declaration
* of an offset plane. The new sketch just has to come after the offset * of an offset plane. The new sketch just has to come after the offset

View File

@ -40,6 +40,10 @@ export type ModelingCommandSchema = {
selection: Selections selection: Selections
radius: KclCommandValue radius: KclCommandValue
} }
'Offset plane': {
plane: Selections
distance: KclCommandValue
}
'change tool': { 'change tool': {
tool: SketchTool tool: SketchTool
} }
@ -276,6 +280,24 @@ export const modelingMachineCommandConfig: StateMachineCommandSetConfig<
}, },
}, },
}, },
'Offset plane': {
description: 'Offset a plane.',
icon: 'plane',
args: {
plane: {
inputType: 'selection',
selectionTypes: ['plane'],
multiple: false,
required: true,
skip: true,
},
distance: {
inputType: 'kcl',
defaultValue: KCL_DEFAULT_LENGTH,
required: true,
},
},
},
Fillet: { Fillet: {
description: 'Fillet edge', description: 'Fillet edge',
icon: 'fillet', icon: 'fillet',

View File

@ -54,6 +54,7 @@ export const KCL_DEFAULT_CONSTANT_PREFIXES = {
EXTRUDE: 'extrude', EXTRUDE: 'extrude',
SEGMENT: 'seg', SEGMENT: 'seg',
REVOLVE: 'revolve', REVOLVE: 'revolve',
PLANE: 'plane',
} as const } as const
/** The default KCL length expression */ /** The default KCL length expression */
export const KCL_DEFAULT_LENGTH = `5` export const KCL_DEFAULT_LENGTH = `5`

View File

@ -21,6 +21,7 @@ import {
} from 'lang/queryAst' } from 'lang/queryAst'
import { CommandArgument } from './commandTypes' import { CommandArgument } from './commandTypes'
import { import {
DefaultPlaneStr,
getParentGroup, getParentGroup,
SEGMENT_BODIES_PLUS_PROFILE_START, SEGMENT_BODIES_PLUS_PROFILE_START,
} from 'clientSideScene/sceneEntities' } from 'clientSideScene/sceneEntities'
@ -46,6 +47,10 @@ export const X_AXIS_UUID = 'ad792545-7fd3-482a-a602-a93924e3055b'
export const Y_AXIS_UUID = '680fd157-266f-4b8a-984f-cdf46b8bdf01' export const Y_AXIS_UUID = '680fd157-266f-4b8a-984f-cdf46b8bdf01'
export type Axis = 'y-axis' | 'x-axis' | 'z-axis' export type Axis = 'y-axis' | 'x-axis' | 'z-axis'
export type DefaultPlaneSelection = {
name: DefaultPlaneStr
id: string
}
/** @deprecated Use {@link Artifact} instead. */ /** @deprecated Use {@link Artifact} instead. */
type Selection__old = type Selection__old =
@ -72,9 +77,11 @@ type Selection__old =
// TODO this is a temporary measure that well be made redundant with: https://github.com/KittyCAD/modeling-app/pull/3836 // TODO this is a temporary measure that well be made redundant with: https://github.com/KittyCAD/modeling-app/pull/3836
secondaryRange: SourceRange secondaryRange: SourceRange
} }
export type NonCodeSelection = Axis | DefaultPlaneSelection
/** @deprecated Use {@link Selection} instead. */ /** @deprecated Use {@link Selection} instead. */
export type Selections__old = { export type Selections__old = {
otherSelections: Axis[] otherSelections: NonCodeSelection[]
codeBasedSelections: Selection__old[] codeBasedSelections: Selection__old[]
} }
export interface Selection { export interface Selection {
@ -82,7 +89,7 @@ export interface Selection {
codeRef: CodeRef codeRef: CodeRef
} }
export type Selections = { export type Selections = {
otherSelections: Array<Axis> otherSelections: Array<NonCodeSelection>
graphSelections: Array<Selection> graphSelections: Array<Selection>
} }
@ -172,11 +179,31 @@ export async function getEventForSelectWithPoint({
return { return {
type: 'Set selection', type: 'Set selection',
data: { data: {
selectionType: 'otherSelection', selectionType: 'axisSelection',
selection: X_AXIS_UUID === data.entity_id ? 'x-axis' : 'y-axis', selection: X_AXIS_UUID === data.entity_id ? 'x-axis' : 'y-axis',
}, },
} }
} }
// Check for default plane selection
const foundDefaultPlane =
engineCommandManager.defaultPlanes !== null &&
Object.entries(engineCommandManager.defaultPlanes).find(
([, plane]) => plane === data.entity_id
)
if (foundDefaultPlane) {
return {
type: 'Set selection',
data: {
selectionType: 'defaultPlaneSelection',
selection: {
name: foundDefaultPlane[0] as DefaultPlaneStr,
id: data.entity_id,
},
},
}
}
let _artifact = engineCommandManager.artifactGraph.get(data.entity_id) let _artifact = engineCommandManager.artifactGraph.get(data.entity_id)
const codeRefs = getCodeRefsByArtifactId( const codeRefs = getCodeRefsByArtifactId(
data.entity_id, data.entity_id,
@ -207,7 +234,7 @@ export function getEventForSegmentSelection(
return { return {
type: 'Set selection', type: 'Set selection',
data: { data: {
selectionType: 'otherSelection', selectionType: 'axisSelection',
selection: obj?.userData?.type === X_AXIS ? 'x-axis' : 'y-axis', selection: obj?.userData?.type === X_AXIS ? 'x-axis' : 'y-axis',
}, },
} }
@ -272,7 +299,6 @@ export function handleSelectionBatch({
}): { }): {
engineEvents: Models['WebSocketRequest_type'][] engineEvents: Models['WebSocketRequest_type'][]
codeMirrorSelection: EditorSelection codeMirrorSelection: EditorSelection
otherSelections: Axis[]
updateSceneObjectColors: () => void updateSceneObjectColors: () => void
} { } {
const ranges: ReturnType<typeof EditorSelection.cursor>[] = [] const ranges: ReturnType<typeof EditorSelection.cursor>[] = []
@ -303,7 +329,6 @@ export function handleSelectionBatch({
ranges, ranges,
selections.graphSelections.length - 1 selections.graphSelections.length - 1
), ),
otherSelections: selections.otherSelections,
updateSceneObjectColors: () => updateSceneObjectColors: () =>
updateSceneObjectColors(selections.graphSelections), updateSceneObjectColors(selections.graphSelections),
} }
@ -314,7 +339,6 @@ export function handleSelectionBatch({
0 0
), ),
engineEvents, engineEvents,
otherSelections: selections.otherSelections,
updateSceneObjectColors: () => updateSceneObjectColors: () =>
updateSceneObjectColors(selections.graphSelections), updateSceneObjectColors(selections.graphSelections),
} }
@ -536,7 +560,8 @@ export function canSweepSelection(selection: Selections) {
} }
// This accounts for non-geometry selections under "other" // This accounts for non-geometry selections under "other"
export type ResolvedSelectionType = [Artifact['type'] | 'other', number] export type ResolvedSelectionType = Artifact['type'] | 'other'
export type SelectionCountsByType = Map<ResolvedSelectionType, number>
/** /**
* In the future, I'd like this function to properly return the type of each selected entity based on * In the future, I'd like this function to properly return the type of each selected entity based on
@ -545,28 +570,48 @@ export type ResolvedSelectionType = [Artifact['type'] | 'other', number]
* @param selection * @param selection
* @returns * @returns
*/ */
export function getSelectionType( export function getSelectionCountByType(
selection?: Selections selection?: Selections
): ResolvedSelectionType[] { ): SelectionCountsByType | 'none' {
if (!selection) return [] const selectionsByType: SelectionCountsByType = new Map()
const selectionsWithArtifacts = selection.graphSelections.filter( if (
(s) => !!s.artifact !selection ||
(!selection.graphSelections.length && !selection.otherSelections.length)
) )
const firstSelection = selectionsWithArtifacts[0] return 'none'
const firstSelectionType = firstSelection?.artifact?.type
if (!firstSelectionType) return [] function incrementOrInitializeSelectionType(type: ResolvedSelectionType) {
const selectionsWithSameType = selectionsWithArtifacts.filter( const count = selectionsByType.get(type) || 0
(s) => s.artifact?.type === firstSelection.artifact?.type selectionsByType.set(type, count + 1)
) }
return [[firstSelectionType, selectionsWithSameType.length]]
selection.otherSelections.forEach((selection) => {
if (typeof selection === 'string') {
incrementOrInitializeSelectionType('other')
} else if ('name' in selection) {
incrementOrInitializeSelectionType('plane')
}
})
selection.graphSelections.forEach((selection) => {
if (!selection.artifact) {
incrementOrInitializeSelectionType('other')
return
}
incrementOrInitializeSelectionType(selection.artifact.type)
})
return selectionsByType
} }
export function getSelectionTypeDisplayText( export function getSelectionTypeDisplayText(
selection?: Selections selection?: Selections
): string | null { ): string | null {
const selectionsByType = getSelectionType(selection) const selectionsByType = getSelectionCountByType(selection)
if (selectionsByType === 'none') return null
return (selectionsByType as Exclude<typeof selectionsByType, 'none'>) return selectionsByType
.entries()
.map( .map(
// Hack for showing "face" instead of "extrude-wall" in command bar text // Hack for showing "face" instead of "extrude-wall" in command bar text
([type, count]) => ([type, count]) =>
@ -575,16 +620,17 @@ export function getSelectionTypeDisplayText(
.replace('solid2D', 'face') .replace('solid2D', 'face')
.replace('segment', 'face')}${count > 1 ? 's' : ''}` .replace('segment', 'face')}${count > 1 ? 's' : ''}`
) )
.toArray()
.join(', ') .join(', ')
} }
export function canSubmitSelectionArg( export function canSubmitSelectionArg(
selectionsByType: 'none' | ResolvedSelectionType[], selectionsByType: 'none' | Map<ResolvedSelectionType, number>,
argument: CommandArgument<unknown> & { inputType: 'selection' } argument: CommandArgument<unknown> & { inputType: 'selection' }
) { ) {
return ( return (
selectionsByType !== 'none' && selectionsByType !== 'none' &&
selectionsByType.every(([type, count]) => { selectionsByType.entries().every(([type, count]) => {
const foundIndex = argument.selectionTypes.findIndex((s) => s === type) const foundIndex = argument.selectionTypes.findIndex((s) => s === type)
return ( return (
foundIndex !== -1 && foundIndex !== -1 &&

View File

@ -252,10 +252,15 @@ export const toolbarConfig: Record<ToolbarModeName, ToolbarMode> = {
[ [
{ {
id: 'plane-offset', id: 'plane-offset',
onClick: () => onClick: ({ commandBarSend }) => {
console.error('Plane through normal not yet implemented'), commandBarSend({
type: 'Find and select command',
data: { name: 'Offset plane', groupId: 'modeling' },
})
},
hotkey: 'O',
icon: 'plane', icon: 'plane',
status: 'unavailable', status: 'available',
title: 'Offset plane', title: 'Offset plane',
description: 'Create a plane parallel to an existing plane.', description: 'Create a plane parallel to an existing plane.',
links: [], links: [],

View File

@ -34,6 +34,8 @@ export function useCalculateKclExpression({
} { } {
const { programMemory, code } = useKclContext() const { programMemory, code } = useKclContext()
const { context } = useModelingContext() const { context } = useModelingContext()
// If there is no selection, use the end of the code
// so all variables are available
const selectionRange: const selectionRange:
| (typeof context)['selectionRanges']['graphSelections'][number]['codeRef']['range'] | (typeof context)['selectionRanges']['graphSelections'][number]['codeRef']['range']
| undefined = context.selectionRanges.graphSelections[0]?.codeRef?.range | undefined = context.selectionRanges.graphSelections[0]?.codeRef?.range
@ -72,11 +74,12 @@ export function useCalculateKclExpression({
}, [programMemory, newVariableName]) }, [programMemory, newVariableName])
useEffect(() => { useEffect(() => {
if (!programMemory || !selectionRange) return if (!programMemory) return
const varInfo = findAllPreviousVariables( const varInfo = findAllPreviousVariables(
kclManager.ast, kclManager.ast,
kclManager.programMemory, kclManager.programMemory,
selectionRange // If there is no selection, use the end of the code
selectionRange || [code.length, code.length]
) )
setAvailableVarInfo(varInfo) setAvailableVarInfo(varInfo)
}, [kclManager.ast, kclManager.programMemory, selectionRange]) }, [kclManager.ast, kclManager.programMemory, selectionRange])

File diff suppressed because one or more lines are too long