Boolean create UI (#5906)

* first steps, add to cmd bar etc

* cmdbar working well enough

* mvp

* lint

* fix after rebase

* intersect and union mvps

* add test

* some clean up

* further fix up

* Update src/lang/modifyAst/boolean.ts

Co-authored-by: Pierre Jacquier <pierrejacquier39@gmail.com>

* Update src/lang/modifyAst/boolean.ts

Co-authored-by: Pierre Jacquier <pierrejacquier39@gmail.com>

* pierre's comments

* tsc

* add comment

---------

Co-authored-by: Pierre Jacquier <pierrejacquier39@gmail.com>
This commit is contained in:
Kurt Hutten
2025-03-28 14:56:48 +11:00
committed by GitHub
parent 7ca3afff9f
commit d1f811f91d
11 changed files with 793 additions and 16 deletions

View File

@ -0,0 +1,117 @@
import { test, expect } from './zoo-test'
import fs from 'node:fs/promises'
import path from 'node:path'
test.describe('Point and click for boolean workflows', () => {
// Boolean operations to test
const booleanOperations = [
{
name: 'union',
code: 'union([extrude001, extrude006])',
},
{
name: 'subtract',
code: 'subtract([extrude001], tools = [extrude006])',
},
{
name: 'intersect',
code: 'intersect([extrude001, extrude006])',
},
] as const
for (let i = 0; i < booleanOperations.length; i++) {
const operation = booleanOperations[i]
const operationName = operation.name
const commandName = `Boolean ${
operationName.charAt(0).toUpperCase() + operationName.slice(1)
}`
test(`Create boolean operation -- ${operationName}`, async ({
context,
homePage,
cmdBar,
editor,
toolbar,
scene,
page,
}) => {
const file = await fs.readFile(
path.resolve(
__dirname,
'../../',
'./rust/kcl-lib/e2e/executor/inputs/boolean-setup-with'
),
'utf-8'
)
await context.addInitScript((file) => {
localStorage.setItem('persistCode', file)
}, file)
await homePage.goToModelingScene()
await scene.waitForExecutionDone()
await scene.settled(cmdBar)
// Test coordinates for selection - these might need adjustment based on actual scene layout
const cylinderPoint = { x: 592, y: 174 }
const secondObjectPoint = { x: 683, y: 273 }
// Create mouse helpers for selecting objects
const [clickFirstObject] = scene.makeMouseHelpers(
cylinderPoint.x,
cylinderPoint.y,
{ steps: 10 }
)
const [clickSecondObject] = scene.makeMouseHelpers(
secondObjectPoint.x,
secondObjectPoint.y,
{ steps: 10 }
)
await test.step(`Test ${operationName} operation`, async () => {
// Click the boolean operation button in the toolbar
await toolbar.selectBoolean(operationName)
// Verify command bar is showing the right command
await expect(cmdBar.page.getByTestId('command-name')).toContainText(
commandName
)
// Select first object in the scene, expect there to be a pixel diff from the selection color change
await clickFirstObject({ pixelDiff: 50 })
// For subtract, we need to proceed to the next step before selecting the second object
if (operationName !== 'subtract') {
// should down shift key to select multiple objects
await page.keyboard.down('Shift')
}
// Select second object
await clickSecondObject({ pixelDiff: 50 })
// Confirm the operation in the command bar
await cmdBar.progressCmdBar()
if (operationName === 'union' || operationName === 'intersect') {
await cmdBar.expectState({
stage: 'review',
headerArguments: {
Solids: '2 paths',
},
commandName,
})
} else if (operationName === 'subtract') {
await cmdBar.expectState({
stage: 'review',
headerArguments: {
Tool: '1 path',
Target: '1 path',
},
commandName,
})
}
await cmdBar.submit()
await editor.expectEditor.toContain(operation.code)
})
})
}
})

View File

@ -181,6 +181,14 @@ export class ToolbarFixture {
).toBeVisible() ).toBeVisible()
await this.page.getByTestId('dropdown-center-rectangle').click() await this.page.getByTestId('dropdown-center-rectangle').click()
} }
selectBoolean = async (operation: 'union' | 'subtract' | 'intersect') => {
await this.page
.getByRole('button', { name: 'caret down Union: open menu' })
.click()
const operationTestId = `dropdown-boolean-${operation}`
await expect(this.page.getByTestId(operationTestId)).toBeVisible()
await this.page.getByTestId(operationTestId).click()
}
selectCircleThreePoint = async () => { selectCircleThreePoint = async () => {
await this.page await this.page

View File

@ -0,0 +1,77 @@
@settings(defaultLengthUnit = mm)
sketch001 = startSketchOn(XZ)
profile001 = circle(sketch001, center = [154.36, 113.92], radius = 41.09)
extrude001 = extrude(profile001, length = 200)
sketch002 = startSketchOn(XY)
profile002 = startProfileAt([72.24, -52.05], sketch002)
|> angledLine([0, 181.26], %, $rectangleSegmentA001)
|> angledLine([
segAng(rectangleSegmentA001) - 90,
21.54
], %)
|> angledLine([
segAng(rectangleSegmentA001),
-segLen(rectangleSegmentA001)
], %, $mySeg)
|> line(endAbsolute = [profileStartX(%), profileStartY(%)])
|> close()
extrude002 = extrude(profile002, length = 150)
|> chamfer(
%,
length = 15,
tags = [mySeg],
tag = $seg02,
)
sketch003 = startSketchOn(extrude002, mySeg)
profile003 = startProfileAt([207.36, 126.19], sketch003)
|> angledLine([0, 33.57], %, $rectangleSegmentA002)
|> angledLine([
segAng(rectangleSegmentA002) - 90,
99.11
], %)
|> angledLine([
segAng(rectangleSegmentA002),
-segLen(rectangleSegmentA002)
], %, $seg01)
|> line(endAbsolute = [profileStartX(%), profileStartY(%)])
|> close()
extrude003 = extrude(profile003, length = -20)
sketch004 = startSketchOn(extrude003, seg01)
profile004 = startProfileAt([-235.38, 66.16], sketch004)
|> angledLine([0, 24.21], %, $rectangleSegmentA003)
|> angledLine([
segAng(rectangleSegmentA003) - 90,
3.72
], %)
|> angledLine([
segAng(rectangleSegmentA003),
-segLen(rectangleSegmentA003)
], %)
|> line(endAbsolute = [profileStartX(%), profileStartY(%)])
|> close()
extrude004 = extrude(profile004, length = 30)
sketch005 = startSketchOn(extrude002, seg02)
profile005 = startProfileAt([-129.93, -59.19], sketch005)
|> xLine(length = 48.79)
|> line(end = [1.33, 11.03])
|> xLine(length = -60.56, tag = $seg03)
|> line(endAbsolute = [profileStartX(%), profileStartY(%)])
|> close()
extrude005 = extrude(profile005, length = -10)
sketch006 = startSketchOn(extrude005, seg03)
profile006 = startProfileAt([-95.86, 38.73], sketch006)
|> angledLine([0, 3.48], %, $rectangleSegmentA004)
|> angledLine([
segAng(rectangleSegmentA004) - 90,
3.36
], %)
|> angledLine([
segAng(rectangleSegmentA004),
-segLen(rectangleSegmentA004)
], %)
|> line(endAbsolute = [profileStartX(%), profileStartY(%)])
|> close()
extrude006 = extrude(profile006, length = 13)

View File

@ -6,7 +6,7 @@ import {
getSelectionCountByType, getSelectionCountByType,
getSelectionTypeDisplayText, getSelectionTypeDisplayText,
} from 'lib/selections' } from 'lib/selections'
import { kclManager } from 'lib/singletons' import { engineCommandManager, kclManager } from 'lib/singletons'
import { reportRejection } from 'lib/trap' import { reportRejection } from 'lib/trap'
import { toSync } from 'lib/utils' import { toSync } from 'lib/utils'
import { commandBarActor, useCommandBarState } from 'machines/commandBarMachine' import { commandBarActor, useCommandBarState } from 'machines/commandBarMachine'
@ -112,6 +112,23 @@ function CommandBarSelectionInput({
onSubmit(selection) onSubmit(selection)
} }
// Clear selection if needed
useEffect(() => {
arg.clearSelectionFirst &&
engineCommandManager.modelingSend({
type: 'Set selection',
data: {
selectionType: 'singleCodeCursor',
},
})
}, [arg.clearSelectionFirst])
// Set selection filter if needed, and reset it when the component unmounts
useEffect(() => {
arg.selectionFilter && kclManager.setSelectionFilter(arg.selectionFilter)
return () => kclManager.defaultSelectionFilter(selection)
}, [arg.selectionFilter])
return ( return (
<form id="arg-form" onSubmit={handleSubmit}> <form id="arg-form" onSubmit={handleSubmit}>
<label <label

View File

@ -7,6 +7,7 @@ import {
} from 'lib/selections' } from 'lib/selections'
import { useSelector } from '@xstate/react' import { useSelector } from '@xstate/react'
import { commandBarActor, useCommandBarState } from 'machines/commandBarMachine' import { commandBarActor, useCommandBarState } from 'machines/commandBarMachine'
import { kclManager } from 'lib/singletons'
const selectionSelector = (snapshot: any) => snapshot?.context.selectionRanges const selectionSelector = (snapshot: any) => snapshot?.context.selectionRanges
@ -56,6 +57,12 @@ export default function CommandBarSelectionMixedInput({
} }
}, []) }, [])
// Set selection filter if needed, and reset it when the component unmounts
useEffect(() => {
arg.selectionFilter && kclManager.setSelectionFilter(arg.selectionFilter)
return () => kclManager.defaultSelectionFilter(selection)
}, [arg.selectionFilter])
function handleChange() { function handleChange() {
inputRef.current?.focus() inputRef.current?.focus()
} }

View File

@ -0,0 +1,338 @@
import { Node } from '@rust/kcl-lib/bindings/Node'
import EditorManager from 'editor/manager'
import CodeManager from 'lang/codeManager'
import { KclManager } from 'lang/KclSingleton'
import { updateModelingState } from 'lang/modelingWorkflows'
import {
createArrayExpression,
createCallExpressionStdLibKw,
createLabeledArg,
createLocalName,
createVariableDeclaration,
findUniqueName,
} from 'lang/modifyAst'
import { getNodeFromPath } from 'lang/queryAst'
import { getNodePathFromSourceRange } from 'lang/queryAstNodePathUtils'
import { getFaceCodeRef } from 'lang/std/artifactGraph'
import { EngineCommandManager } from 'lang/std/engineConnection'
import {
Artifact,
ArtifactGraph,
Program,
VariableDeclaration,
} from 'lang/wasm'
import { EXECUTION_TYPE_REAL } from 'lib/constants'
import { Selection, Selections } from 'lib/selections'
import { err } from 'lib/trap'
import { isArray } from 'lib/utils'
export async function applySubtractFromTargetOperatorSelections(
target: Selection,
tool: Selection,
dependencies: {
kclManager: KclManager
engineCommandManager: EngineCommandManager
codeManager: CodeManager
editorManager: EditorManager
}
): Promise<Error | void> {
const ast = dependencies.kclManager.ast
if (!target.artifact || !tool.artifact) {
return new Error('No artifact found')
}
const orderedChildrenTarget = findAllChildrenAndOrderByPlaceInCode(
target.artifact,
dependencies.engineCommandManager.artifactGraph
)
const orderedChildrenTool = findAllChildrenAndOrderByPlaceInCode(
tool.artifact,
dependencies.engineCommandManager.artifactGraph
)
const lastVarTarget = getLastVariable(orderedChildrenTarget, ast)
const lastVarTool = getLastVariable(orderedChildrenTool, ast)
if (!lastVarTarget || !lastVarTool) {
return new Error('No variable found')
}
const modifiedAst = booleanSubtractAstMod({
ast,
targets: [lastVarTarget?.variableDeclaration?.node],
tools: [lastVarTool?.variableDeclaration.node],
})
await updateModelingState(modifiedAst, EXECUTION_TYPE_REAL, dependencies)
}
export async function applyUnionFromTargetOperatorSelections(
solids: Selections,
dependencies: {
kclManager: KclManager
engineCommandManager: EngineCommandManager
codeManager: CodeManager
editorManager: EditorManager
}
): Promise<Error | void> {
const ast = dependencies.kclManager.ast
const artifacts: Artifact[] = []
for (const selection of solids.graphSelections) {
if (selection.artifact) {
artifacts.push(selection.artifact)
}
}
if (artifacts.length < 2) {
return new Error('Not enough artifacts selected')
}
const orderedChildrenEach = artifacts.map((artifact) =>
findAllChildrenAndOrderByPlaceInCode(
artifact,
dependencies.engineCommandManager.artifactGraph
)
)
const lastVars: VariableDeclaration[] = []
for (const orderedArtifactLeafs of orderedChildrenEach) {
const lastVar = getLastVariable(orderedArtifactLeafs, ast)
if (!lastVar) continue
lastVars.push(lastVar.variableDeclaration.node)
}
if (lastVars.length < 2) {
return new Error('Not enough variables found')
}
const modifiedAst = booleanUnionAstMod({
ast,
solids: lastVars,
})
await updateModelingState(modifiedAst, EXECUTION_TYPE_REAL, dependencies)
}
export async function applyIntersectFromTargetOperatorSelections(
solids: Selections,
dependencies: {
kclManager: KclManager
engineCommandManager: EngineCommandManager
codeManager: CodeManager
editorManager: EditorManager
}
): Promise<Error | void> {
const ast = dependencies.kclManager.ast
const artifacts: Artifact[] = []
for (const selection of solids.graphSelections) {
if (selection.artifact) {
artifacts.push(selection.artifact)
}
}
if (artifacts.length < 2) {
return new Error('Not enough artifacts selected')
}
const orderedChildrenEach = artifacts.map((artifact) =>
findAllChildrenAndOrderByPlaceInCode(
artifact,
dependencies.engineCommandManager.artifactGraph
)
)
const lastVars: VariableDeclaration[] = []
for (const orderedArtifactLeafs of orderedChildrenEach) {
const lastVar = getLastVariable(orderedArtifactLeafs, ast)
if (!lastVar) continue
lastVars.push(lastVar.variableDeclaration.node)
}
if (lastVars.length < 2) {
return new Error('Not enough variables found')
}
const modifiedAst = booleanIntersectAstMod({
ast,
solids: lastVars,
})
await updateModelingState(modifiedAst, EXECUTION_TYPE_REAL, dependencies)
}
/** returns all children of a given artifact, and sorts them DESC by start sourceRange
* The usecase is we want the last declare relevant child to use in the boolean operations
* but might be useful else where.
*/
export function findAllChildrenAndOrderByPlaceInCode(
artifact: Artifact,
artifactGraph: ArtifactGraph
): Artifact[] {
const result: string[] = []
const stack: string[] = [artifact.id]
const getArtifacts = (stringIds: string[]): Artifact[] => {
const artifactsWithCodeRefs: Artifact[] = []
for (const id of stringIds) {
const artifact = artifactGraph.get(id)
if (artifact) {
const codeRef = getFaceCodeRef(artifact)
if (codeRef && codeRef.range[1] > 0) {
artifactsWithCodeRefs.push(artifact)
}
}
}
return artifactsWithCodeRefs
}
const pushToSomething = (
resultId: string,
childrenIdOrIds: null | string | string[]
) => {
if (isArray(childrenIdOrIds)) {
if (childrenIdOrIds.length) {
stack.push(...childrenIdOrIds)
result.push(resultId)
} else {
}
} else {
if (childrenIdOrIds) {
stack.push(childrenIdOrIds)
result.push(resultId)
} else {
}
}
}
while (stack.length > 0) {
const currentId = stack.pop()!
const current = artifactGraph.get(currentId)
if (current?.type === 'path') {
pushToSomething(currentId, current?.sweepId)
pushToSomething(currentId, current?.segIds)
} else if (current?.type === 'sweep') {
pushToSomething(currentId, current?.surfaceIds)
} else if (current?.type === 'wall' || current?.type === 'cap') {
pushToSomething(currentId, current?.pathIds)
} else if (current?.type === 'segment') {
pushToSomething(currentId, current?.edgeCutId)
pushToSomething(currentId, current?.surfaceId)
} else if (current?.type === 'edgeCut') {
pushToSomething(currentId, current?.surfaceId)
} else if (current?.type === 'startSketchOnPlane') {
pushToSomething(currentId, current?.planeId)
} else if (current?.type === 'plane') {
pushToSomething(currentId, current.pathIds)
}
}
const codeRefArtifacts = getArtifacts(result)
const orderedByCodeRefDest = codeRefArtifacts.sort((a, b) => {
const aCodeRef = getFaceCodeRef(a)
const bCodeRef = getFaceCodeRef(b)
if (!aCodeRef || !bCodeRef) {
return 0
}
return bCodeRef.range[0] - aCodeRef.range[0]
})
return orderedByCodeRefDest
}
/** Returns the last declared in code, relevant child */
export function getLastVariable(
orderedDescArtifacts: Artifact[],
ast: Node<Program>
) {
for (const artifact of orderedDescArtifacts) {
const codeRef = getFaceCodeRef(artifact)
if (codeRef) {
const pathToNode = getNodePathFromSourceRange(ast, codeRef.range)
const varDec = getNodeFromPath<VariableDeclaration>(
ast,
pathToNode,
'VariableDeclaration'
)
if (!err(varDec)) {
return {
variableDeclaration: varDec,
pathToNode: pathToNode,
artifact,
}
}
}
}
return null
}
export function booleanSubtractAstMod({
ast,
targets,
tools,
}: {
ast: Node<Program>
targets: VariableDeclaration[]
tools: VariableDeclaration[]
}): Node<Program> {
const newAst = structuredClone(ast)
const newVarName = findUniqueName(newAst, 'solid')
const createArrExpr = (varDecs: VariableDeclaration[]) =>
createArrayExpression(
varDecs.map((varDec) => createLocalName(varDec.declaration.id.name))
)
const targetsArrayExpression = createArrExpr(targets)
const toolsArrayExpression = createArrExpr(tools)
const newVarDec = createVariableDeclaration(
newVarName,
createCallExpressionStdLibKw('subtract', targetsArrayExpression, [
createLabeledArg('tools', toolsArrayExpression),
])
)
newAst.body.push(newVarDec)
return newAst
}
export function booleanUnionAstMod({
ast,
solids,
}: {
ast: Node<Program>
solids: VariableDeclaration[]
}): Node<Program> {
const newAst = structuredClone(ast)
const newVarName = findUniqueName(newAst, 'solid')
const createArrExpr = (varDecs: VariableDeclaration[]) =>
createArrayExpression(
varDecs.map((varDec) => createLocalName(varDec.declaration.id.name))
)
const solidsArrayExpression = createArrExpr(solids)
const newVarDec = createVariableDeclaration(
newVarName,
createCallExpressionStdLibKw('union', solidsArrayExpression, [])
)
newAst.body.push(newVarDec)
return newAst
}
export function booleanIntersectAstMod({
ast,
solids,
}: {
ast: Node<Program>
solids: VariableDeclaration[]
}): Node<Program> {
const newAst = structuredClone(ast)
const newVarName = findUniqueName(newAst, 'solid')
const createArrExpr = (varDecs: VariableDeclaration[]) =>
createArrayExpression(
varDecs.map((varDec) => createLocalName(varDec.declaration.id.name))
)
const solidsArrayExpression = createArrExpr(solids)
const newVarDec = createVariableDeclaration(
newVarName,
createCallExpressionStdLibKw('intersect', solidsArrayExpression, [])
)
newAst.body.push(newVarDec)
return newAst
}

View File

@ -23,6 +23,8 @@ import {
import { getVariableDeclaration } from 'lang/queryAst/getVariableDeclaration' import { getVariableDeclaration } from 'lang/queryAst/getVariableDeclaration'
import { getNodePathFromSourceRange } from 'lang/queryAstNodePathUtils' import { getNodePathFromSourceRange } from 'lang/queryAstNodePathUtils'
import { getNodeFromPath } from 'lang/queryAst' import { getNodeFromPath } from 'lang/queryAst'
import { IS_NIGHTLY_OR_DEBUG } from 'routes/Settings'
import { DEV } from 'env'
type OutputFormat = Models['OutputFormat3d_type'] type OutputFormat = Models['OutputFormat3d_type']
type OutputTypeKey = OutputFormat['type'] type OutputTypeKey = OutputFormat['type']
@ -153,6 +155,16 @@ export type ModelingCommandSchema = {
nodeToEdit?: PathToNode nodeToEdit?: PathToNode
color: string color: string
} }
'Boolean Subtract': {
target: Selections
tool: Selections
}
'Boolean Union': {
solids: Selections
}
'Boolean Intersect': {
solids: Selections
}
} }
export const modelingMachineCommandConfig: StateMachineCommandSetConfig< export const modelingMachineCommandConfig: StateMachineCommandSetConfig<
@ -507,6 +519,67 @@ export const modelingMachineCommandConfig: StateMachineCommandSetConfig<
}, },
}, },
}, },
'Boolean Subtract': {
hide: DEV || IS_NIGHTLY_OR_DEBUG ? undefined : 'both',
description: 'Subtract one solid from another.',
icon: 'booleanSubtract',
needsReview: true,
args: {
target: {
inputType: 'selection',
selectionTypes: ['path'],
selectionFilter: ['object'],
multiple: false,
required: true,
skip: true,
hidden: (context) => Boolean(context.argumentsToSubmit.nodeToEdit),
},
tool: {
clearSelectionFirst: true,
inputType: 'selection',
selectionTypes: ['path'],
selectionFilter: ['object'],
multiple: false,
required: true,
skip: false,
hidden: (context) => Boolean(context.argumentsToSubmit.nodeToEdit),
},
},
},
'Boolean Union': {
hide: DEV || IS_NIGHTLY_OR_DEBUG ? undefined : 'both',
description: 'Union multiple solids into a single solid.',
icon: 'booleanUnion',
needsReview: true,
args: {
solids: {
inputType: 'selection',
selectionTypes: ['path'],
selectionFilter: ['object'],
multiple: true,
required: true,
skip: false,
hidden: (context) => Boolean(context.argumentsToSubmit.nodeToEdit),
},
},
},
'Boolean Intersect': {
hide: DEV || IS_NIGHTLY_OR_DEBUG ? undefined : 'both',
description: 'Subtract one solid from another.',
icon: 'booleanIntersect',
needsReview: true,
args: {
solids: {
inputType: 'selectionMixed',
selectionTypes: ['path'],
selectionFilter: ['object'],
multiple: true,
required: true,
skip: false,
hidden: (context) => Boolean(context.argumentsToSubmit.nodeToEdit),
},
},
},
'Offset plane': { 'Offset plane': {
description: 'Offset a plane.', description: 'Offset a plane.',
icon: 'plane', icon: 'plane',
@ -940,3 +1013,5 @@ export const modelingMachineCommandConfig: StateMachineCommandSetConfig<
}, },
}, },
} }
modelingMachineCommandConfig

View File

@ -1,14 +1,14 @@
import { CustomIconName } from 'components/CustomIcon' import { CustomIconName } from 'components/CustomIcon'
import { AllMachines } from 'hooks/useStateMachineCommands' import { AllMachines } from 'hooks/useStateMachineCommands'
import { Actor, AnyStateMachine, ContextFrom, EventFrom } from 'xstate' import { Actor, AnyStateMachine, ContextFrom, EventFrom } from 'xstate'
import { Expr, VariableDeclaration } from 'lang/wasm' import { Expr, Name, VariableDeclaration } from 'lang/wasm'
import { commandBarMachine } from 'machines/commandBarMachine' import { commandBarMachine } from 'machines/commandBarMachine'
import { ReactNode } from 'react' import { ReactNode } from 'react'
import { MachineManager } from 'components/MachineManagerProvider' import { MachineManager } from 'components/MachineManagerProvider'
import { Node } from '@rust/kcl-lib/bindings/Node' import { Node } from '@rust/kcl-lib/bindings/Node'
import { Artifact } from 'lang/std/artifactGraph' import { Artifact } from 'lang/std/artifactGraph'
import { CommandBarContext } from 'machines/commandBarMachine' import { CommandBarContext } from 'machines/commandBarMachine'
import { Name } from '@rust/kcl-lib/bindings/Name' import { EntityType_type } from '@kittycad/lib/dist/types/src/models'
type Icon = CustomIconName type Icon = CustomIconName
const _PLATFORMS = ['both', 'web', 'desktop'] as const const _PLATFORMS = ['both', 'web', 'desktop'] as const
@ -160,6 +160,8 @@ export type CommandArgumentConfig<
| { | {
inputType: 'selection' inputType: 'selection'
selectionTypes: Artifact['type'][] selectionTypes: Artifact['type'][]
clearSelectionFirst?: boolean
selectionFilter?: EntityType_type[]
multiple: boolean multiple: boolean
validation?: ({ validation?: ({
data, data,
@ -172,6 +174,7 @@ export type CommandArgumentConfig<
| { | {
inputType: 'selectionMixed' inputType: 'selectionMixed'
selectionTypes: Artifact['type'][] selectionTypes: Artifact['type'][]
selectionFilter?: EntityType_type[]
multiple: boolean multiple: boolean
allowNoSelection?: boolean allowNoSelection?: boolean
validation?: ({ validation?: ({
@ -281,6 +284,8 @@ export type CommandArgument<
| { | {
inputType: 'selection' inputType: 'selection'
selectionTypes: Artifact['type'][] selectionTypes: Artifact['type'][]
clearSelectionFirst?: boolean
selectionFilter?: EntityType_type[]
multiple: boolean multiple: boolean
validation?: ({ validation?: ({
data, data,
@ -293,6 +298,7 @@ export type CommandArgument<
| { | {
inputType: 'selectionMixed' inputType: 'selectionMixed'
selectionTypes: Artifact['type'][] selectionTypes: Artifact['type'][]
selectionFilter?: EntityType_type[]
multiple: boolean multiple: boolean
allowNoSelection?: boolean allowNoSelection?: boolean
validation?: ({ validation?: ({

View File

@ -188,6 +188,8 @@ export function buildCommandArgument<
multiple: arg.multiple, multiple: arg.multiple,
selectionTypes: arg.selectionTypes, selectionTypes: arg.selectionTypes,
validation: arg.validation, validation: arg.validation,
clearSelectionFirst: arg.clearSelectionFirst,
selectionFilter: arg.selectionFilter,
} satisfies CommandArgument<O, T> & { inputType: 'selection' } } satisfies CommandArgument<O, T> & { inputType: 'selection' }
} else if (arg.inputType === 'selectionMixed') { } else if (arg.inputType === 'selectionMixed') {
return { return {
@ -198,6 +200,7 @@ export function buildCommandArgument<
validation: arg.validation, validation: arg.validation,
allowNoSelection: arg.allowNoSelection, allowNoSelection: arg.allowNoSelection,
selectionSource: arg.selectionSource, selectionSource: arg.selectionSource,
selectionFilter: arg.selectionFilter,
} satisfies CommandArgument<O, T> & { inputType: 'selectionMixed' } } satisfies CommandArgument<O, T> & { inputType: 'selectionMixed' }
} else if (arg.inputType === 'kcl') { } else if (arg.inputType === 'kcl') {
return { return {

View File

@ -209,9 +209,13 @@ export const toolbarConfig: Record<ToolbarModeName, ToolbarMode> = {
[ [
{ {
id: 'boolean-union', id: 'boolean-union',
onClick: () => console.error('Boolean union not yet implemented'), onClick: () =>
commandBarActor.send({
type: 'Find and select command',
data: { name: 'Boolean Union', groupId: 'modeling' },
}),
icon: 'booleanUnion', icon: 'booleanUnion',
status: 'unavailable', status: DEV || IS_NIGHTLY_OR_DEBUG ? 'available' : 'unavailable',
title: 'Union', title: 'Union',
hotkey: 'Shift + B U', hotkey: 'Shift + B U',
description: 'Combine two or more solids into a single solid.', description: 'Combine two or more solids into a single solid.',
@ -224,9 +228,13 @@ export const toolbarConfig: Record<ToolbarModeName, ToolbarMode> = {
}, },
{ {
id: 'boolean-subtract', id: 'boolean-subtract',
onClick: () => console.error('Boolean subtract not yet implemented'), onClick: () =>
commandBarActor.send({
type: 'Find and select command',
data: { name: 'Boolean Subtract', groupId: 'modeling' },
}),
icon: 'booleanSubtract', icon: 'booleanSubtract',
status: 'unavailable', status: DEV || IS_NIGHTLY_OR_DEBUG ? 'available' : 'unavailable',
title: 'Subtract', title: 'Subtract',
hotkey: 'Shift + B S', hotkey: 'Shift + B S',
description: 'Subtract one solid from another.', description: 'Subtract one solid from another.',
@ -239,9 +247,13 @@ export const toolbarConfig: Record<ToolbarModeName, ToolbarMode> = {
}, },
{ {
id: 'boolean-intersect', id: 'boolean-intersect',
onClick: () => console.error('Boolean intersect not yet implemented'), onClick: () =>
commandBarActor.send({
type: 'Find and select command',
data: { name: 'Boolean Intersect', groupId: 'modeling' },
}),
icon: 'booleanIntersect', icon: 'booleanIntersect',
status: 'unavailable', status: DEV || IS_NIGHTLY_OR_DEBUG ? 'available' : 'unavailable',
title: 'Intersect', title: 'Intersect',
hotkey: 'Shift + B I', hotkey: 'Shift + B I',
description: 'Create a solid from the intersection of two solids.', description: 'Create a solid from the intersection of two solids.',

File diff suppressed because one or more lines are too long