Point-and-click Loft (#4605)
* WIP: experimenting with Loft UI Relates to #4470 * A snapshot a day keeps the bugs away! 📷🐛 (OS: ubuntu-latest-8-cores) * A snapshot a day keeps the bugs away! 📷🐛 (OS: windows-latest-8-cores) * A snapshot a day keeps the bugs away! 📷🐛 (OS: windows-latest-8-cores) * Add selection guard * Working loft for two sketches in the right hardcoded order * First pass at handling more than 2 sketches * WIP selections * WIP selections * More checks * Appends the loft line after the 'last' sketch in the code * Clean up * Enable multiple selections after the button click * First point-click loft test (not working locally, loft gets inserted at the wrong place) * Lint * A snapshot a day keeps the bugs away! 📷🐛 (OS: ubuntu-latest-8-cores) * Clean up and working pw test * Add test for doesSceneHaveSweepableSketch with count = 2 * Clean up loftSketches function * Add pw test for preselected sketches * A snapshot a day keeps the bugs away! 📷🐛 (OS: windows-latest-8-cores) * A snapshot a day keeps the bugs away! 📷🐛 (OS: ubuntu-latest-8-cores) * A snapshot a day keeps the bugs away! 📷🐛 (OS: windows-latest-8-cores) * Trigger CI * A snapshot a day keeps the bugs away! 📷🐛 (OS: ubuntu-latest-8-cores) * A snapshot a day keeps the bugs away! 📷🐛 (OS: ubuntu-latest-8-cores) * A snapshot a day keeps the bugs away! 📷🐛 (OS: windows-latest-8-cores) * Move to fromPromise-based Actor * Move error logic out of loftSketches, fix pw tests * Remove comments * A snapshot a day keeps the bugs away! 📷🐛 (OS: windows-latest-8-cores) * Trigger CI * A snapshot a day keeps the bugs away! 📷🐛 (OS: ubuntu-latest-8-cores) * A snapshot a day keeps the bugs away! 📷🐛 (OS: ubuntu-latest-8-cores) * A snapshot a day keeps the bugs away! 📷🐛 (OS: windows-latest-8-cores) * A snapshot a day keeps the bugs away! 📷🐛 (OS: windows-latest-8-cores) * Trigger CI * A snapshot a day keeps the bugs away! 📷🐛 (OS: ubuntu-latest-8-cores) * A snapshot a day keeps the bugs away! 📷🐛 (OS: windows-latest-8-cores) * Fix typo * Revert snapshots * A snapshot a day keeps the bugs away! 📷🐛 (OS: ubuntu-latest-8-cores) * A snapshot a day keeps the bugs away! 📷🐛 (OS: windows-latest-8-cores) * Trigger CI * A snapshot a day keeps the bugs away! 📷🐛 (OS: ubuntu-latest-8-cores) * A snapshot a day keeps the bugs away! 📷🐛 (OS: windows-latest-8-cores) * Trigger CI --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
@ -6,6 +6,7 @@ export class ToolbarFixture {
|
||||
public page: Page
|
||||
|
||||
extrudeButton!: Locator
|
||||
loftButton!: Locator
|
||||
offsetPlaneButton!: Locator
|
||||
startSketchBtn!: Locator
|
||||
lineBtn!: Locator
|
||||
@ -26,6 +27,7 @@ export class ToolbarFixture {
|
||||
reConstruct = (page: Page) => {
|
||||
this.page = page
|
||||
this.extrudeButton = page.getByTestId('extrude')
|
||||
this.loftButton = page.getByTestId('loft')
|
||||
this.offsetPlaneButton = page.getByTestId('plane-offset')
|
||||
this.startSketchBtn = page.getByTestId('sketch')
|
||||
this.lineBtn = page.getByTestId('line')
|
||||
|
@ -677,3 +677,94 @@ test(`Offset plane point-and-click`, async ({
|
||||
await scene.expectPixelColor([74, 74, 74], testPoint, 15)
|
||||
})
|
||||
})
|
||||
|
||||
const loftPointAndClickCases = [
|
||||
{ shouldPreselect: true },
|
||||
{ shouldPreselect: false },
|
||||
]
|
||||
loftPointAndClickCases.forEach(({ shouldPreselect }) => {
|
||||
test(`Loft point-and-click (preselected sketches: ${shouldPreselect})`, async ({
|
||||
app,
|
||||
page,
|
||||
scene,
|
||||
editor,
|
||||
toolbar,
|
||||
cmdBar,
|
||||
}) => {
|
||||
const initialCode = `sketch001 = startSketchOn('XZ')
|
||||
|> circle({ center = [0, 0], radius = 30 }, %)
|
||||
plane001 = offsetPlane('XZ', 50)
|
||||
sketch002 = startSketchOn(plane001)
|
||||
|> circle({ center = [0, 0], radius = 20 }, %)
|
||||
`
|
||||
await app.initialise(initialCode)
|
||||
|
||||
// One dumb hardcoded screen pixel value
|
||||
const testPoint = { x: 575, y: 200 }
|
||||
const [clickOnSketch1] = scene.makeMouseHelpers(testPoint.x, testPoint.y)
|
||||
const [clickOnSketch2] = scene.makeMouseHelpers(
|
||||
testPoint.x,
|
||||
testPoint.y + 80
|
||||
)
|
||||
const loftDeclaration = 'loft001 = loft([sketch001, sketch002])'
|
||||
|
||||
await test.step(`Look for the white of the sketch001 shape`, async () => {
|
||||
await scene.expectPixelColor([254, 254, 254], testPoint, 15)
|
||||
})
|
||||
|
||||
async function selectSketches() {
|
||||
await clickOnSketch1()
|
||||
await page.keyboard.down('Shift')
|
||||
await clickOnSketch2()
|
||||
await app.page.waitForTimeout(500)
|
||||
await page.keyboard.up('Shift')
|
||||
}
|
||||
|
||||
if (!shouldPreselect) {
|
||||
await test.step(`Go through the command bar flow without preselected sketches`, async () => {
|
||||
await toolbar.loftButton.click()
|
||||
await cmdBar.expectState({
|
||||
stage: 'arguments',
|
||||
currentArgKey: 'selection',
|
||||
currentArgValue: '',
|
||||
headerArguments: { Selection: '' },
|
||||
highlightedHeaderArg: 'selection',
|
||||
commandName: 'Loft',
|
||||
})
|
||||
await selectSketches()
|
||||
await cmdBar.progressCmdBar()
|
||||
await cmdBar.expectState({
|
||||
stage: 'review',
|
||||
headerArguments: { Selection: '2 faces' },
|
||||
commandName: 'Loft',
|
||||
})
|
||||
await cmdBar.progressCmdBar()
|
||||
})
|
||||
} else {
|
||||
await test.step(`Preselect the two sketches`, async () => {
|
||||
await selectSketches()
|
||||
})
|
||||
|
||||
await test.step(`Go through the command bar flow with preselected sketches`, async () => {
|
||||
await toolbar.loftButton.click()
|
||||
await cmdBar.progressCmdBar()
|
||||
await cmdBar.expectState({
|
||||
stage: 'review',
|
||||
headerArguments: { Selection: '2 faces' },
|
||||
commandName: 'Loft',
|
||||
})
|
||||
await cmdBar.progressCmdBar()
|
||||
})
|
||||
}
|
||||
|
||||
await test.step(`Confirm code is added to the editor, scene has changed`, async () => {
|
||||
await editor.expectEditor.toContain(loftDeclaration)
|
||||
await editor.expectState({
|
||||
diagnostics: [],
|
||||
activeLines: [loftDeclaration],
|
||||
highlightedCode: '',
|
||||
})
|
||||
await scene.expectPixelColor([89, 89, 89], testPoint, 15)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 40 KiB |
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 40 KiB |
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 38 KiB |
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 39 KiB |
@ -50,6 +50,7 @@ import {
|
||||
isSketchPipe,
|
||||
Selections,
|
||||
updateSelections,
|
||||
canLoftSelection,
|
||||
} from 'lib/selections'
|
||||
import { applyConstraintIntersect } from './Toolbar/Intersect'
|
||||
import { applyConstraintAbsDistance } from './Toolbar/SetAbsDistance'
|
||||
@ -569,6 +570,21 @@ export const ModelingMachineProvider = ({
|
||||
if (err(canSweep)) return false
|
||||
return canSweep
|
||||
},
|
||||
'has valid loft selection': ({ context: { selectionRanges } }) => {
|
||||
const hasNoSelection =
|
||||
selectionRanges.graphSelections.length === 0 ||
|
||||
isRangeBetweenCharacters(selectionRanges) ||
|
||||
isSelectionLastLine(selectionRanges, codeManager.code)
|
||||
|
||||
if (hasNoSelection) {
|
||||
const count = 2
|
||||
return doesSceneHaveSweepableSketch(kclManager.ast, count)
|
||||
}
|
||||
|
||||
const canLoft = canLoftSelection(selectionRanges)
|
||||
if (err(canLoft)) return false
|
||||
return canLoft
|
||||
},
|
||||
'has valid selection for deletion': ({
|
||||
context: { selectionRanges },
|
||||
}) => {
|
||||
|
@ -346,6 +346,37 @@ export function extrudeSketch(
|
||||
}
|
||||
}
|
||||
|
||||
export function loftSketches(
|
||||
node: Node<Program>,
|
||||
declarators: VariableDeclarator[]
|
||||
): {
|
||||
modifiedAst: Node<Program>
|
||||
pathToNode: PathToNode
|
||||
} {
|
||||
const modifiedAst = structuredClone(node)
|
||||
const name = findUniqueName(node, KCL_DEFAULT_CONSTANT_PREFIXES.LOFT)
|
||||
const elements = declarators.map((d) => createIdentifier(d.id.name))
|
||||
const loft = createCallExpressionStdLib('loft', [
|
||||
createArrayExpression(elements),
|
||||
])
|
||||
const declaration = createVariableDeclaration(name, loft)
|
||||
modifiedAst.body.push(declaration)
|
||||
const pathToNode: PathToNode = [
|
||||
['body', ''],
|
||||
[modifiedAst.body.length - 1, 'index'],
|
||||
['declarations', 'VariableDeclaration'],
|
||||
['0', 'index'],
|
||||
['init', 'VariableDeclarator'],
|
||||
['arguments', 'CallExpression'],
|
||||
[0, 'index'],
|
||||
]
|
||||
|
||||
return {
|
||||
modifiedAst,
|
||||
pathToNode,
|
||||
}
|
||||
}
|
||||
|
||||
export function revolveSketch(
|
||||
node: Node<Program>,
|
||||
pathToNode: PathToNode,
|
||||
|
@ -628,6 +628,18 @@ sketch002 = startSketchOn(extrude001, $seg01)
|
||||
const extrudable = doesSceneHaveSweepableSketch(ast)
|
||||
expect(extrudable).toBeTruthy()
|
||||
})
|
||||
it('finds sketch001 and sketch002 pipes to be lofted', async () => {
|
||||
const exampleCode = `sketch001 = startSketchOn('XZ')
|
||||
|> circle({ center = [0, 0], radius = 1 }, %)
|
||||
plane001 = offsetPlane('XZ', 2)
|
||||
sketch002 = startSketchOn(plane001)
|
||||
|> circle({ center = [0, 0], radius = 3 }, %)
|
||||
`
|
||||
const ast = parse(exampleCode)
|
||||
if (err(ast)) throw ast
|
||||
const extrudable = doesSceneHaveSweepableSketch(ast, 2)
|
||||
expect(extrudable).toBeTruthy()
|
||||
})
|
||||
it('find sketch002 NOT pipe to be extruded', async () => {
|
||||
const exampleCode = `sketch001 = startSketchOn('XZ')
|
||||
|> startProfileAt([3.29, 7.86], %)
|
||||
|
@ -975,7 +975,9 @@ export function hasSketchPipeBeenExtruded(selection: Selection, ast: Program) {
|
||||
if (
|
||||
node.type === 'CallExpression' &&
|
||||
node.callee.type === 'Identifier' &&
|
||||
(node.callee.name === 'extrude' || node.callee.name === 'revolve') &&
|
||||
(node.callee.name === 'extrude' ||
|
||||
node.callee.name === 'revolve' ||
|
||||
node.callee.name === 'loft') &&
|
||||
node.arguments?.[1]?.type === 'Identifier' &&
|
||||
node.arguments[1].name === varDec.id.name
|
||||
) {
|
||||
@ -988,7 +990,7 @@ export function hasSketchPipeBeenExtruded(selection: Selection, ast: Program) {
|
||||
}
|
||||
|
||||
/** File must contain at least one sketch that has not been extruded already */
|
||||
export function doesSceneHaveSweepableSketch(ast: Node<Program>) {
|
||||
export function doesSceneHaveSweepableSketch(ast: Node<Program>, count = 1) {
|
||||
const theMap: any = {}
|
||||
traverse(ast as any, {
|
||||
enter(node) {
|
||||
@ -1037,7 +1039,7 @@ export function doesSceneHaveSweepableSketch(ast: Node<Program>) {
|
||||
}
|
||||
},
|
||||
})
|
||||
return Object.keys(theMap).length > 0
|
||||
return Object.keys(theMap).length >= count
|
||||
}
|
||||
|
||||
export function getObjExprProperty(
|
||||
|
@ -31,6 +31,9 @@ export type ModelingCommandSchema = {
|
||||
// result: (typeof EXTRUSION_RESULTS)[number]
|
||||
distance: KclCommandValue
|
||||
}
|
||||
Loft: {
|
||||
selection: Selections
|
||||
}
|
||||
Revolve: {
|
||||
selection: Selections
|
||||
angle: KclCommandValue
|
||||
@ -260,6 +263,20 @@ export const modelingMachineCommandConfig: StateMachineCommandSetConfig<
|
||||
},
|
||||
},
|
||||
},
|
||||
Loft: {
|
||||
description: 'Create a 3D body by blending between two or more sketches',
|
||||
icon: 'loft',
|
||||
needsReview: true,
|
||||
args: {
|
||||
selection: {
|
||||
inputType: 'selection',
|
||||
selectionTypes: ['solid2D'],
|
||||
multiple: true,
|
||||
required: true,
|
||||
skip: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
// TODO: Update this configuration, copied from extrude for MVP of revolve, specifically the args.selection
|
||||
Revolve: {
|
||||
description: 'Create a 3D body by rotating a sketch region about an axis.',
|
||||
|
@ -52,6 +52,7 @@ export const ONBOARDING_PROJECT_NAME = 'Tutorial Project $nn'
|
||||
export const KCL_DEFAULT_CONSTANT_PREFIXES = {
|
||||
SKETCH: 'sketch',
|
||||
EXTRUDE: 'extrude',
|
||||
LOFT: 'loft',
|
||||
SEGMENT: 'seg',
|
||||
REVOLVE: 'revolve',
|
||||
PLANE: 'plane',
|
||||
|
@ -529,6 +529,10 @@ function nodeHasExtrude(node: CommonASTNode) {
|
||||
doesPipeHaveCallExp({
|
||||
calleeName: 'revolve',
|
||||
...node,
|
||||
}) ||
|
||||
doesPipeHaveCallExp({
|
||||
calleeName: 'loft',
|
||||
...node,
|
||||
})
|
||||
)
|
||||
}
|
||||
@ -559,6 +563,22 @@ export function canSweepSelection(selection: Selections) {
|
||||
)
|
||||
}
|
||||
|
||||
export function canLoftSelection(selection: Selections) {
|
||||
const commonNodes = selection.graphSelections.map((_, i) =>
|
||||
buildCommonNodeFromSelection(selection, i)
|
||||
)
|
||||
return (
|
||||
!!isCursorInSketchCommandRange(
|
||||
engineCommandManager.artifactGraph,
|
||||
selection
|
||||
) &&
|
||||
commonNodes.length > 1 &&
|
||||
commonNodes.every((n) => !hasSketchPipeBeenExtruded(n.selection, n.ast)) &&
|
||||
commonNodes.every((n) => nodeHasClose(n) || nodeHasCircle(n)) &&
|
||||
commonNodes.every((n) => !nodeHasExtrude(n))
|
||||
)
|
||||
}
|
||||
|
||||
// This accounts for non-geometry selections under "other"
|
||||
export type ResolvedSelectionType = Artifact['type'] | 'other'
|
||||
export type SelectionCountsByType = Map<ResolvedSelectionType, number>
|
||||
|
@ -139,9 +139,14 @@ export const toolbarConfig: Record<ToolbarModeName, ToolbarMode> = {
|
||||
},
|
||||
{
|
||||
id: 'loft',
|
||||
onClick: () => console.error('Loft not yet implemented'),
|
||||
onClick: ({ commandBarSend }) =>
|
||||
commandBarSend({
|
||||
type: 'Find and select command',
|
||||
data: { name: 'Loft', groupId: 'modeling' },
|
||||
}),
|
||||
disabled: (state) => !state.can({ type: 'Loft' }),
|
||||
icon: 'loft',
|
||||
status: 'kcl-only',
|
||||
status: 'available',
|
||||
title: 'Loft',
|
||||
hotkey: 'L',
|
||||
description:
|
||||
|
@ -44,6 +44,7 @@ import {
|
||||
addOffsetPlane,
|
||||
deleteFromSelection,
|
||||
extrudeSketch,
|
||||
loftSketches,
|
||||
revolveSketch,
|
||||
} from 'lang/modifyAst'
|
||||
import {
|
||||
@ -256,6 +257,7 @@ export type ModelingMachineEvent =
|
||||
| { type: 'Export'; data: ModelingCommandSchema['Export'] }
|
||||
| { type: 'Make'; data: ModelingCommandSchema['Make'] }
|
||||
| { type: 'Extrude'; data?: ModelingCommandSchema['Extrude'] }
|
||||
| { type: 'Loft'; data?: ModelingCommandSchema['Loft'] }
|
||||
| { type: 'Revolve'; data?: ModelingCommandSchema['Revolve'] }
|
||||
| { type: 'Fillet'; data?: ModelingCommandSchema['Fillet'] }
|
||||
| { type: 'Offset plane'; data: ModelingCommandSchema['Offset plane'] }
|
||||
@ -387,6 +389,7 @@ export const modelingMachine = setup({
|
||||
guards: {
|
||||
'Selection is on face': () => false,
|
||||
'has valid sweep selection': () => false,
|
||||
'has valid loft selection': () => false,
|
||||
'has valid edge treatment selection': () => false,
|
||||
'Has exportable geometry': () => false,
|
||||
'has valid selection for deletion': () => false,
|
||||
@ -1529,6 +1532,50 @@ export const modelingMachine = setup({
|
||||
updateAstResult.newAst
|
||||
)
|
||||
|
||||
if (updateAstResult?.selections) {
|
||||
editorManager.selectRange(updateAstResult?.selections)
|
||||
}
|
||||
}
|
||||
),
|
||||
loftAstMod: fromPromise(
|
||||
async ({
|
||||
input,
|
||||
}: {
|
||||
input: ModelingCommandSchema['Loft'] | undefined
|
||||
}) => {
|
||||
if (!input) return new Error('No input provided')
|
||||
// Extract inputs
|
||||
const ast = kclManager.ast
|
||||
const { selection } = input
|
||||
const declarators = selection.graphSelections.flatMap((s) => {
|
||||
const path = getNodePathFromSourceRange(ast, s?.codeRef.range)
|
||||
const nodeFromPath = getNodeFromPath<VariableDeclarator>(
|
||||
ast,
|
||||
path,
|
||||
'VariableDeclarator'
|
||||
)
|
||||
return err(nodeFromPath) ? [] : nodeFromPath.node
|
||||
})
|
||||
|
||||
// TODO: add better validation on selection
|
||||
if (!(declarators && declarators.length > 1)) {
|
||||
trap('Not enough sketches selected')
|
||||
}
|
||||
|
||||
// Perform the loft
|
||||
const loftSketchesRes = loftSketches(ast, declarators)
|
||||
const updateAstResult = await kclManager.updateAst(
|
||||
loftSketchesRes.modifiedAst,
|
||||
true,
|
||||
{
|
||||
focusPath: [loftSketchesRes.pathToNode],
|
||||
}
|
||||
)
|
||||
|
||||
await codeManager.updateEditorWithAstAndWriteToFile(
|
||||
updateAstResult.newAst
|
||||
)
|
||||
|
||||
if (updateAstResult?.selections) {
|
||||
editorManager.selectRange(updateAstResult?.selections)
|
||||
}
|
||||
@ -1570,6 +1617,11 @@ export const modelingMachine = setup({
|
||||
reenter: false,
|
||||
},
|
||||
|
||||
Loft: {
|
||||
target: 'Applying loft',
|
||||
reenter: true,
|
||||
},
|
||||
|
||||
Fillet: {
|
||||
target: 'idle',
|
||||
guard: 'has valid edge treatment selection',
|
||||
@ -2318,6 +2370,19 @@ export const modelingMachine = setup({
|
||||
onError: ['idle'],
|
||||
},
|
||||
},
|
||||
|
||||
'Applying loft': {
|
||||
invoke: {
|
||||
src: 'loftAstMod',
|
||||
id: 'loftAstMod',
|
||||
input: ({ event }) => {
|
||||
if (event.type !== 'Loft') return undefined
|
||||
return event.data
|
||||
},
|
||||
onDone: ['idle'],
|
||||
onError: ['idle'],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
initial: 'idle',
|
||||
|