diff --git a/e2e/playwright/flow-tests.spec.ts b/e2e/playwright/flow-tests.spec.ts index a7eb2e937..2b8d38f98 100644 --- a/e2e/playwright/flow-tests.spec.ts +++ b/e2e/playwright/flow-tests.spec.ts @@ -175,7 +175,7 @@ async function doBasicSketch(page: Page, openPanes: string[]) { } // deselect line tool - await page.getByRole('button', { name: 'Line' }).click() + await page.getByRole('button', { name: 'Line', exact: true }).click() await page.waitForTimeout(500) const line1 = await u.getSegmentBodyCoords(`[data-overlay-index="${0}"]`, 0) @@ -203,7 +203,7 @@ async function doBasicSketch(page: Page, openPanes: string[]) { await expect(page.locator('.cm-cursor')).toHaveCount(2) } - await page.getByRole('button', { name: 'Constraints' }).click() + await page.getByRole('button', { name: 'Length: open menu' }).click() await page.getByRole('button', { name: 'Equal Length' }).click() // Open the code pane. @@ -452,7 +452,7 @@ test.describe('Testing Camera Movement', () => { // await expect(u.codeLocator).toHaveText(code) // click the line button - await page.getByRole('button', { name: 'Line' }).click() + await page.getByRole('button', { name: 'Line', exact: true }).click() const hoverOverNothing = async () => { // await u.canvasLocator.hover({position: {x: 700, y: 325}}) @@ -1462,7 +1462,9 @@ test.describe('Can create sketches on all planes and their back sides', () => { await page.mouse.click(clickCoords.x, clickCoords.y) await page.waitForTimeout(300) // wait for animation - await expect(page.getByRole('button', { name: 'Line' })).toBeVisible() + await expect( + page.getByRole('button', { name: 'Line', exact: true }) + ).toBeVisible() // draw a line const startXPx = 600 @@ -1472,7 +1474,7 @@ test.describe('Can create sketches on all planes and their back sides', () => { await expect(page.locator('.cm-content')).toHaveText(code) - await page.getByRole('button', { name: 'Line' }).click() + await page.getByRole('button', { name: 'Line', exact: true }).click() await u.openAndClearDebugPanel() await page.getByRole('button', { name: 'Exit Sketch' }).click() await u.expectCmdLog('[data-message-type="execution-done"]') @@ -1551,7 +1553,9 @@ test.describe('Copilot ghost text', () => { await expect(page.locator('.cm-ghostText')).not.toBeVisible() }) - test('copilot disabled in sketch mode no select plane', async ({ page }) => { + test.skip('copilot disabled in sketch mode no select plane', async ({ + page, + }) => { const u = await getUtils(page) // const PUR = 400 / 37.5 //pixeltoUnitRatio await page.setViewportSize({ width: 1200, height: 500 }) @@ -2098,7 +2102,7 @@ test.describe('Testing settings', () => { .hover() await page .getByRole('button', { - name: 'Roll back theme ; Has tooltip: Roll back to match default', + name: 'Roll back theme', }) .click() await expect(page.locator('select[name="app-theme"]')).toHaveValue('system') @@ -2150,7 +2154,7 @@ test.describe('Testing settings', () => { .hover() await page .getByRole('button', { - name: 'Roll back theme ; Has tooltip: Roll back to match default', + name: 'Roll back theme', }) .click() await expect(page.locator('select[name="app-theme"]')).toHaveValue('system') @@ -2564,7 +2568,7 @@ test.describe('Testing selections', () => { |> line([-${commonPoints.num2}, 0], %)`) // deselect line tool - await page.getByRole('button', { name: 'Line' }).click() + await page.getByRole('button', { name: 'Line', exact: true }).click() await u.closeDebugPanel() const selectionSequence = async () => { @@ -2589,8 +2593,10 @@ test.describe('Testing selections', () => { // click a segment hold shift and click an axis, see that a relevant constraint is enabled await topHorzSegmentClick() await page.keyboard.down('Shift') - const constrainButton = page.getByRole('button', { name: 'Constraints' }) - const absYButton = page.getByRole('button', { name: 'ABS Y' }) + const constrainButton = page.getByRole('button', { + name: 'Length: open menu', + }) + const absYButton = page.getByRole('button', { name: 'Absolute Y' }) await constrainButton.click() await expect(absYButton).toBeDisabled() await page.waitForTimeout(100) @@ -3414,21 +3420,6 @@ const extrude001 = extrude(50, sketch001) await expect( page.getByRole('button', { name: 'Edit Sketch' }) ).not.toBeVisible() - - // selecting an editable sketch but clicking "start sketch" should start a new sketch and not edit the existing one - await page.getByText(selectionsSnippets.extrudeAndEditAllowed).click() - await page.getByRole('button', { name: 'Start Sketch' }).click() - await page.waitForTimeout(200) - await page.getByTestId('KCL Code').click() - await page.waitForTimeout(200) - await page.mouse.click(734, 134) - await page.waitForTimeout(100) - await page.getByTestId('KCL Code').click() - // expect main content to contain `sketch005` i.e. started a new sketch - await page.waitForTimeout(300) - await expect(page.locator('.cm-content')).toHaveText( - /sketch001 = startSketchOn\('XZ'\)/ - ) }) test('Deselecting line tool should mean nothing happens on click', async ({ @@ -3465,7 +3456,7 @@ const extrude001 = extrude(50, sketch001) let previousCodeContent = await page.locator('.cm-content').innerText() // deselect the line tool by clicking it - await page.getByRole('button', { name: 'Line' }).click() + await page.getByRole('button', { name: 'Line', exact: true }).click() await page.mouse.click(700, 200) await page.waitForTimeout(100) @@ -3478,7 +3469,7 @@ const extrude001 = extrude(50, sketch001) await expect(page.locator('.cm-content')).toHaveText(previousCodeContent) // select line tool again - await page.getByRole('button', { name: 'Line' }).click() + await page.getByRole('button', { name: 'Line', exact: true }).click() await u.closeDebugPanel() @@ -3811,13 +3802,24 @@ const extrude001 = extrude(distance001, sketch001)`.replace( const sketchButton = page.getByRole('button', { name: 'Start Sketch' }) const cmdBarButton = page.getByRole('button', { name: 'Commands' }) const rectangleToolCommand = page.getByRole('option', { - name: 'Rectangle', + name: 'rectangle', + }) + const rectangleToolButton = page.getByRole('button', { + name: 'Corner rectangle', + exact: true, + }) + const lineToolCommand = page.getByRole('option', { + name: 'Line', + }) + const lineToolButton = page.getByRole('button', { + name: 'Line', + exact: true, }) - const rectangleToolButton = page.getByRole('button', { name: 'Rectangle' }) - const lineToolCommand = page.getByRole('option', { name: 'Line' }) - const lineToolButton = page.getByRole('button', { name: 'Line' }) const arcToolCommand = page.getByRole('option', { name: 'Tangential Arc' }) - const arcToolButton = page.getByRole('button', { name: 'Tangential Arc' }) + const arcToolButton = page.getByRole('button', { + name: 'Tangential Arc', + exact: true, + }) // Start a sketch await sketchButton.click() @@ -3864,7 +3866,7 @@ test.describe('Regression tests', () => { await u.waitForAuthSkipAppStart() // expand variables section - const variablesTabButton = page.getByTestId('Variables') + const variablesTabButton = page.getByTestId('variables-pane-button') await variablesTabButton.click() // can find sketch001 in the variables summary (pretty-json-container, makes sure we're not looking in the code editor) @@ -3889,7 +3891,7 @@ test.describe('Regression tests', () => { await u.waitForAuthSkipAppStart() - const variablesTabButton = page.getByTestId('Variables') + const variablesTabButton = page.getByTestId('variables-pane-button') await variablesTabButton.click() // expect to see "myVar:5" await expect( @@ -4150,7 +4152,7 @@ test.describe('Sketch tests', () => { await u.expectCmdLog('[data-message-type="execution-done"]', 10_000) await page.waitForTimeout(100) - await page.getByRole('button', { name: 'Line' }).click() + await page.getByRole('button', { name: 'Line', exact: true }).click() await page.waitForTimeout(100) await page.mouse.click(700, 200) @@ -4177,9 +4179,7 @@ test.describe('Sketch tests', () => { page.getByRole('button', { name: 'Exit Sketch' }) ).toBeVisible() - await expect( - page.getByText('click plane or face to sketch on') - ).toBeVisible() + await expect(page.getByText('select a plane or face')).toBeVisible() await page.keyboard.press('Escape') await expect( @@ -4730,7 +4730,7 @@ test.describe('Sketch tests', () => { await expect(page.locator('.cm-content')).toHaveText(code) // Assert the tool was unequipped await expect( - page.getByRole('button', { name: 'Line' }) + page.getByRole('button', { name: 'Line', exact: true }) ).not.toHaveAttribute('aria-pressed', 'true') // exit sketch @@ -4892,8 +4892,7 @@ test.describe('Testing constraints', () => { await page.mouse.click(834, 244) await page.keyboard.up('Shift') - await page.getByRole('button', { name: 'Constraints', exact: true }).click() - await page.getByRole('button', { name: 'length', exact: true }).click() + await page.getByRole('button', { name: 'Length', exact: true }).click() await page.getByText('Add constraining value').click() await expect(page.locator('.cm-content')).toHaveText( @@ -4947,12 +4946,10 @@ const part002 = startSketchOn('XZ') await page.waitForTimeout(100) // this wait is needed for webkit - not sure why await page .getByRole('button', { - name: 'Constraints', + name: 'Length: open menu', }) .click() - await page - .getByRole('button', { name: 'remove constraints', exact: true }) - .click() + await page.getByRole('button', { name: 'remove constraints' }).click() await page.getByText('line([39.13, 68.63], %)').click() const activeLinesContent = await page.locator('.cm-activeLine').all() @@ -5013,11 +5010,11 @@ const part002 = startSketchOn('XZ') await page.keyboard.up('Shift') await page .getByRole('button', { - name: 'Constraints', + name: 'Length: open menu', }) .click() await page - .getByRole('button', { name: 'perpendicular distance', exact: true }) + .getByRole('button', { name: 'Perpendicular Distance' }) .click() const createNewVariableCheckbox = page.getByTestId( @@ -5112,12 +5109,10 @@ const part002 = startSketchOn('XZ') await page.keyboard.up('Shift') await page .getByRole('button', { - name: 'Constraints', + name: 'Length: open menu', }) .click() - await page - .getByRole('button', { name: constraint, exact: true }) - .click() + await page.getByRole('button', { name: constraint }).click() const createNewVariableCheckbox = page.getByTestId( 'create-new-variable-checkbox' @@ -5158,25 +5153,25 @@ const part002 = startSketchOn('XZ') { testName: 'Add variable', addVariable: true, - constraint: 'ABS X', + constraint: 'Absolute X', value: 'xDis001, 61.34', }, { testName: 'No variable', addVariable: false, - constraint: 'ABS X', + constraint: 'Absolute X', value: '154.9, 61.34', }, { testName: 'Add variable', addVariable: true, - constraint: 'ABS Y', + constraint: 'Absolute Y', value: '154.9, yDis001', }, { testName: 'No variable', addVariable: false, - constraint: 'ABS Y', + constraint: 'Absolute Y', value: '154.9, 61.34', }, ] as const @@ -5212,7 +5207,7 @@ const part002 = startSketchOn('XZ') u.getSegmentBodyCoords(`[data-overlay-index="${2}"]`), ]) - if (constraint === 'ABS X') { + if (constraint === 'Absolute X') { await page.mouse.click(600, 130) } else { await page.mouse.click(900, 250) @@ -5223,7 +5218,7 @@ const part002 = startSketchOn('XZ') await page.keyboard.up('Shift') await page .getByRole('button', { - name: 'Constraints', + name: 'Length: open menu', }) .click() await page @@ -5331,10 +5326,10 @@ const part002 = startSketchOn('XZ') await page.keyboard.up('Shift') await page .getByRole('button', { - name: 'Constraints', + name: 'Length: open menu', }) .click() - await page.getByTestId('angle').click() + await page.getByTestId('dropdown-constraint-angle').click() const createNewVariableCheckbox = page.getByTestId( 'create-new-variable-checkbox' @@ -5432,10 +5427,10 @@ const part002 = startSketchOn('XZ') await page.mouse.click(line3.x, line3.y) await page .getByRole('button', { - name: 'Constraints', + name: 'Length: open menu', }) .click() - await page.getByTestId(constraint).click() + await page.getByTestId('dropdown-constraint-' + constraint).click() if (!addVariable) { await page.getByTestId('create-new-variable-checkbox').click() @@ -5523,7 +5518,7 @@ const part002 = startSketchOn('XZ') await expect(activeLinesContent).toHaveLength(codeAfter.length) const constraintMenuButton = page.getByRole('button', { - name: 'Constraints', + name: 'Length: open menu', }) const constraintButton = page .getByRole('button', { @@ -5606,7 +5601,7 @@ const part002 = startSketchOn('XZ') await page.mouse.click(line3.x - 3, line3.y + 20) await page.keyboard.up('Shift') const constraintMenuButton = page.getByRole('button', { - name: 'Constraints', + name: 'Length: open menu', }) const constraintButton = page.getByRole('button', { name: constraintName, @@ -5683,7 +5678,7 @@ const part002 = startSketchOn('XZ') await page.mouse.click(axisClick.x, axisClick.y) await page.keyboard.up('Shift') const constraintMenuButton = page.getByRole('button', { - name: 'Constraints', + name: 'Length: open menu', }) const constraintButton = page.getByRole('button', { name: constraintName, @@ -5742,10 +5737,10 @@ const part002 = startSketchOn('XZ') await page .getByRole('button', { - name: 'Constraints', + name: 'Length: open menu', }) .click() - await page.getByRole('button', { name: 'horizontal', exact: true }).click() + await page.getByRole('button', { name: 'Horizontal', exact: true }).click() let activeLinesContent = await page.locator('.cm-activeLine').all() await expect(activeLinesContent[0]).toHaveText(`|> xLine(3.13, %)`) @@ -5766,13 +5761,13 @@ const part002 = startSketchOn('XZ') await page.waitForTimeout(300) await page .getByRole('button', { - name: 'Constraints', + name: 'Length: open menu', }) .click() // await expect(page.getByRole('button', { name: 'length', exact: true })).toBeVisible() await page.waitForTimeout(200) // await page.getByRole('button', { name: 'length', exact: true }).click() - await page.locator('[data-testid="length"]').click() + await page.getByTestId('dropdown-constraint-length').click() await page.getByLabel('length Value').fill('10') await page.getByRole('button', { name: 'Add constraining value' }).click() @@ -7064,6 +7059,8 @@ test.describe('Test network and connection issues', () => { await u.waitForAuthSkipAppStart() + const networkToggle = page.getByTestId('network-toggle') + // This is how we wait until the stream is online await expect( page.getByRole('button', { name: 'Start Sketch' }) @@ -7077,7 +7074,7 @@ test.describe('Test network and connection issues', () => { await expect(networkPopover).not.toBeVisible() // (First check) Expect the network to be up - await expect(page.getByText('Network Health (Connected)')).toBeVisible() + await expect(networkToggle).toContainText('Connected') // Click the network widget await networkWidget.click() @@ -7099,7 +7096,7 @@ test.describe('Test network and connection issues', () => { }) // Expect the network to be down - await expect(page.getByText('Network Health (Offline)')).toBeVisible() + await expect(networkToggle).toContainText('Offline') // Click the network widget await networkWidget.click() @@ -7125,7 +7122,7 @@ test.describe('Test network and connection issues', () => { ).not.toBeDisabled({ timeout: 15000 }) // (Second check) expect the network to be up - await expect(page.getByText('Network Health (Connected)')).toBeVisible() + await expect(networkToggle).toContainText('Connected') }) test('Engine disconnect & reconnect in sketch mode', async ({ @@ -7137,6 +7134,8 @@ test.describe('Test network and connection issues', () => { browserName === 'webkit', 'Skip on Safari until `window.tearDown` is working there' ) + const networkToggle = page.getByTestId('network-toggle') + const u = await getUtils(page) await page.setViewportSize({ width: 1200, height: 500 }) const PUR = 400 / 37.5 //pixeltoUnitRatio @@ -7179,7 +7178,7 @@ test.describe('Test network and connection issues', () => { |> line([${commonPoints.num1}, 0], %)`) // Expect the network to be up - await expect(page.getByText('Network Health (Connected)')).toBeVisible() + await expect(networkToggle).toContainText('Connected') // simulate network down await u.emulateNetworkConditions({ @@ -7191,7 +7190,7 @@ test.describe('Test network and connection issues', () => { }) // Expect the network to be down - await expect(page.getByText('Network Health (Offline)')).toBeVisible() + await expect(networkToggle).toContainText('Offline') // Ensure we are not in sketch mode await expect( @@ -7216,7 +7215,7 @@ test.describe('Test network and connection issues', () => { ).not.toBeDisabled({ timeout: 15000 }) // Expect the network to be up - await expect(page.getByText('Network Health (Connected)')).toBeVisible() + await expect(networkToggle).toContainText('Connected') await expect(page.getByTestId('loading-stream')).not.toBeAttached() // Click off the code pane. @@ -7233,7 +7232,7 @@ test.describe('Test network and connection issues', () => { await page.waitForTimeout(150) // Click the line tool - await page.getByRole('button', { name: 'Line' }).click() + await page.getByRole('button', { name: 'Line', exact: true }).click() await page.waitForTimeout(150) @@ -7260,7 +7259,7 @@ test.describe('Test network and connection issues', () => { page.getByRole('button', { name: 'Exit Sketch' }) ).toBeVisible() await expect( - page.getByRole('button', { name: 'Line' }) + page.getByRole('button', { name: 'Line', exact: true }) ).not.toHaveAttribute('aria-pressed', 'true') // Exit sketch @@ -7665,7 +7664,7 @@ test('Keyboard shortcuts can be viewed through the help menu', async ({ .waitFor({ state: 'visible' }) // Open the help menu - await page.getByRole('button', { name: 'Help', exact: false }).click() + await page.getByRole('button', { name: 'Help and resources' }).click() // Open the keyboard shortcuts await page.getByRole('button', { name: 'Keyboard Shortcuts' }).click() @@ -7689,8 +7688,11 @@ test('First escape in tool pops you out of tool, second exits sketch mode', asyn await u.expectCmdLog('[data-message-type="execution-done"]') await u.closeDebugPanel() - const lineButton = page.getByRole('button', { name: 'Line' }) - const arcButton = page.getByRole('button', { name: 'Tangential Arc' }) + const lineButton = page.getByRole('button', { name: 'Line', exact: true }) + const arcButton = page.getByRole('button', { + name: 'Tangential Arc', + exact: true, + }) // Test these hotkeys perform actions when // focus is on the canvas @@ -7702,6 +7704,7 @@ test('First escape in tool pops you out of tool, second exits sketch mode', asyn await page.mouse.move(800, 300) await page.mouse.click(800, 300) await page.waitForTimeout(1000) + await expect(lineButton).toBeVisible() await expect(lineButton).toHaveAttribute('aria-pressed', 'true') // Draw a line @@ -7771,9 +7774,12 @@ test('Basic default modeling and sketch hotkeys work', async ({ page }) => { await u.closeDebugPanel() const codePane = page.getByRole('textbox').locator('div') - const codePaneButton = page.getByTestId('KCL Code') - const lineButton = page.getByRole('button', { name: 'Line' }) - const arcButton = page.getByRole('button', { name: 'Tangential Arc' }) + const codePaneButton = page.getByTestId('code-pane-button') + const lineButton = page.getByRole('button', { name: 'Line', exact: true }) + const arcButton = page.getByRole('button', { + name: 'Tangential Arc', + exact: true, + }) const extrudeButton = page.getByRole('button', { name: 'Extrude' }) // Test that the hotkeys do nothing when @@ -7794,7 +7800,7 @@ test('Basic default modeling and sketch hotkeys work', async ({ page }) => { await page.mouse.click(600, 250) // work-around: to stop "keyboard.press('s')" from typing in the editor even when it should be blurred - await page.getByRole('button', { name: 'Commands ⌘K' }).click() + await page.getByRole('button', { name: 'Commands' }).click() await page.waitForTimeout(100) await page.keyboard.press('Escape') await page.waitForTimeout(100) diff --git a/e2e/playwright/snapshot-tests.spec.ts b/e2e/playwright/snapshot-tests.spec.ts index 5f37d09b0..5bbe69eb5 100644 --- a/e2e/playwright/snapshot-tests.spec.ts +++ b/e2e/playwright/snapshot-tests.spec.ts @@ -431,7 +431,9 @@ test('Draft segments should look right', async ({ page, context }) => { |> line([7.25, 0], %)` await expect(page.locator('.cm-content')).toHaveText(code) - await page.getByRole('button', { name: 'Tangential Arc' }).click() + await page + .getByRole('button', { name: 'Tangential Arc', exact: true }) + .click() await page.mouse.move(startXPx + PUR * 30, 500 - PUR * 20, { steps: 10 }) @@ -475,8 +477,10 @@ test('Draft rectangles should look right', async ({ page, context }) => { const startXPx = 600 // Equip the rectangle tool - await page.getByRole('button', { name: 'Line' }).click() - await page.getByRole('button', { name: 'Rectangle' }).click() + await page.getByRole('button', { name: 'Line', exact: true }).click() + await page + .getByRole('button', { name: 'Corner rectangle', exact: true }) + .click() // Draw the rectangle await page.mouse.click(startXPx + PUR * 20, 500 - PUR * 30) @@ -535,7 +539,9 @@ test.describe('Client side scene scale should match engine scale', () => { |> line([7.25, 0], %)` await expect(u.codeLocator).toHaveText(code) - await page.getByRole('button', { name: 'Tangential Arc' }).click() + await page + .getByRole('button', { name: 'Tangential Arc', exact: true }) + .click() await page.waitForTimeout(100) await page.mouse.click(startXPx + PUR * 30, 500 - PUR * 20) @@ -545,7 +551,9 @@ test.describe('Client side scene scale should match engine scale', () => { await expect(u.codeLocator).toHaveText(code) // click tangential arc tool again to unequip it - await page.getByRole('button', { name: 'Tangential Arc' }).click() + await page + .getByRole('button', { name: 'Tangential Arc', exact: true }) + .click() await page.waitForTimeout(100) // screen shot should show the sketch @@ -634,7 +642,9 @@ test.describe('Client side scene scale should match engine scale', () => { |> line([184.3, 0], %)` await expect(u.codeLocator).toHaveText(code) - await page.getByRole('button', { name: 'Tangential Arc' }).click() + await page + .getByRole('button', { name: 'Tangential Arc', exact: true }) + .click() await page.waitForTimeout(100) await page.mouse.click(startXPx + PUR * 30, 500 - PUR * 20) @@ -643,7 +653,9 @@ test.describe('Client side scene scale should match engine scale', () => { |> tangentialArcTo([551.2, -62.01], %)` await expect(u.codeLocator).toHaveText(code) - await page.getByRole('button', { name: 'Tangential Arc' }).click() + await page + .getByRole('button', { name: 'Tangential Arc', exact: true }) + .click() await page.waitForTimeout(100) // screen shot should show the sketch diff --git a/e2e/playwright/snapshot-tests.spec.ts-snapshots/Client-side-scene-scale-should-match-engine-scale-Inch-scale-1-Google-Chrome-linux.png b/e2e/playwright/snapshot-tests.spec.ts-snapshots/Client-side-scene-scale-should-match-engine-scale-Inch-scale-1-Google-Chrome-linux.png index 3f482e5dd..33062d7cf 100644 Binary files a/e2e/playwright/snapshot-tests.spec.ts-snapshots/Client-side-scene-scale-should-match-engine-scale-Inch-scale-1-Google-Chrome-linux.png and b/e2e/playwright/snapshot-tests.spec.ts-snapshots/Client-side-scene-scale-should-match-engine-scale-Inch-scale-1-Google-Chrome-linux.png differ diff --git a/e2e/playwright/snapshot-tests.spec.ts-snapshots/Client-side-scene-scale-should-match-engine-scale-Inch-scale-2-Google-Chrome-linux.png b/e2e/playwright/snapshot-tests.spec.ts-snapshots/Client-side-scene-scale-should-match-engine-scale-Inch-scale-2-Google-Chrome-linux.png index 813be687f..8b4a8b843 100644 Binary files a/e2e/playwright/snapshot-tests.spec.ts-snapshots/Client-side-scene-scale-should-match-engine-scale-Inch-scale-2-Google-Chrome-linux.png and b/e2e/playwright/snapshot-tests.spec.ts-snapshots/Client-side-scene-scale-should-match-engine-scale-Inch-scale-2-Google-Chrome-linux.png differ diff --git a/e2e/playwright/snapshot-tests.spec.ts-snapshots/Client-side-scene-scale-should-match-engine-scale-Millimeter-scale-1-Google-Chrome-linux.png b/e2e/playwright/snapshot-tests.spec.ts-snapshots/Client-side-scene-scale-should-match-engine-scale-Millimeter-scale-1-Google-Chrome-linux.png index 837dcb00f..2c42d7a6d 100644 Binary files a/e2e/playwright/snapshot-tests.spec.ts-snapshots/Client-side-scene-scale-should-match-engine-scale-Millimeter-scale-1-Google-Chrome-linux.png and b/e2e/playwright/snapshot-tests.spec.ts-snapshots/Client-side-scene-scale-should-match-engine-scale-Millimeter-scale-1-Google-Chrome-linux.png differ diff --git a/e2e/playwright/snapshot-tests.spec.ts-snapshots/Client-side-scene-scale-should-match-engine-scale-Millimeter-scale-2-Google-Chrome-linux.png b/e2e/playwright/snapshot-tests.spec.ts-snapshots/Client-side-scene-scale-should-match-engine-scale-Millimeter-scale-2-Google-Chrome-linux.png index 6ead3c3f5..f7cca18b9 100644 Binary files a/e2e/playwright/snapshot-tests.spec.ts-snapshots/Client-side-scene-scale-should-match-engine-scale-Millimeter-scale-2-Google-Chrome-linux.png and b/e2e/playwright/snapshot-tests.spec.ts-snapshots/Client-side-scene-scale-should-match-engine-scale-Millimeter-scale-2-Google-Chrome-linux.png differ diff --git a/e2e/playwright/snapshot-tests.spec.ts-snapshots/Draft-rectangles-should-look-right-1-Google-Chrome-linux.png b/e2e/playwright/snapshot-tests.spec.ts-snapshots/Draft-rectangles-should-look-right-1-Google-Chrome-linux.png index 55e52e3ae..72f306ed1 100644 Binary files a/e2e/playwright/snapshot-tests.spec.ts-snapshots/Draft-rectangles-should-look-right-1-Google-Chrome-linux.png and b/e2e/playwright/snapshot-tests.spec.ts-snapshots/Draft-rectangles-should-look-right-1-Google-Chrome-linux.png differ diff --git a/e2e/playwright/snapshot-tests.spec.ts-snapshots/Draft-segments-should-look-right-1-Google-Chrome-linux.png b/e2e/playwright/snapshot-tests.spec.ts-snapshots/Draft-segments-should-look-right-1-Google-Chrome-linux.png index 50121562b..9eb4ac2bd 100644 Binary files a/e2e/playwright/snapshot-tests.spec.ts-snapshots/Draft-segments-should-look-right-1-Google-Chrome-linux.png and b/e2e/playwright/snapshot-tests.spec.ts-snapshots/Draft-segments-should-look-right-1-Google-Chrome-linux.png differ diff --git a/e2e/playwright/snapshot-tests.spec.ts-snapshots/Draft-segments-should-look-right-2-Google-Chrome-linux.png b/e2e/playwright/snapshot-tests.spec.ts-snapshots/Draft-segments-should-look-right-2-Google-Chrome-linux.png index fc9cd52f6..e8a8ad2f6 100644 Binary files a/e2e/playwright/snapshot-tests.spec.ts-snapshots/Draft-segments-should-look-right-2-Google-Chrome-linux.png and b/e2e/playwright/snapshot-tests.spec.ts-snapshots/Draft-segments-should-look-right-2-Google-Chrome-linux.png differ diff --git a/e2e/playwright/snapshot-tests.spec.ts-snapshots/Grid-visibility-Grid-turned-off-1-Google-Chrome-linux.png b/e2e/playwright/snapshot-tests.spec.ts-snapshots/Grid-visibility-Grid-turned-off-1-Google-Chrome-linux.png index c87e52b26..d9974fa77 100644 Binary files a/e2e/playwright/snapshot-tests.spec.ts-snapshots/Grid-visibility-Grid-turned-off-1-Google-Chrome-linux.png and b/e2e/playwright/snapshot-tests.spec.ts-snapshots/Grid-visibility-Grid-turned-off-1-Google-Chrome-linux.png differ diff --git a/e2e/playwright/snapshot-tests.spec.ts-snapshots/Grid-visibility-Grid-turned-on-1-Google-Chrome-linux.png b/e2e/playwright/snapshot-tests.spec.ts-snapshots/Grid-visibility-Grid-turned-on-1-Google-Chrome-linux.png index 53a4ec95d..5b68ec143 100644 Binary files a/e2e/playwright/snapshot-tests.spec.ts-snapshots/Grid-visibility-Grid-turned-on-1-Google-Chrome-linux.png and b/e2e/playwright/snapshot-tests.spec.ts-snapshots/Grid-visibility-Grid-turned-on-1-Google-Chrome-linux.png differ diff --git a/e2e/playwright/snapshot-tests.spec.ts-snapshots/Sketch-on-face-with-none-z-up-1-Google-Chrome-linux.png b/e2e/playwright/snapshot-tests.spec.ts-snapshots/Sketch-on-face-with-none-z-up-1-Google-Chrome-linux.png index 44a9afc82..c726617b3 100644 Binary files a/e2e/playwright/snapshot-tests.spec.ts-snapshots/Sketch-on-face-with-none-z-up-1-Google-Chrome-linux.png and b/e2e/playwright/snapshot-tests.spec.ts-snapshots/Sketch-on-face-with-none-z-up-1-Google-Chrome-linux.png differ diff --git a/e2e/playwright/snapshot-tests.spec.ts-snapshots/Zoom-to-fit-on-load---solid-2d-1-Google-Chrome-linux.png b/e2e/playwright/snapshot-tests.spec.ts-snapshots/Zoom-to-fit-on-load---solid-2d-1-Google-Chrome-linux.png index b0cde33b7..bc3c94a7d 100644 Binary files a/e2e/playwright/snapshot-tests.spec.ts-snapshots/Zoom-to-fit-on-load---solid-2d-1-Google-Chrome-linux.png and b/e2e/playwright/snapshot-tests.spec.ts-snapshots/Zoom-to-fit-on-load---solid-2d-1-Google-Chrome-linux.png differ diff --git a/e2e/playwright/snapshot-tests.spec.ts-snapshots/Zoom-to-fit-on-load---solid-3d-1-Google-Chrome-linux.png b/e2e/playwright/snapshot-tests.spec.ts-snapshots/Zoom-to-fit-on-load---solid-3d-1-Google-Chrome-linux.png index 4f5ee2644..af1d8b0e0 100644 Binary files a/e2e/playwright/snapshot-tests.spec.ts-snapshots/Zoom-to-fit-on-load---solid-3d-1-Google-Chrome-linux.png and b/e2e/playwright/snapshot-tests.spec.ts-snapshots/Zoom-to-fit-on-load---solid-3d-1-Google-Chrome-linux.png differ diff --git a/e2e/playwright/snapshot-tests.spec.ts-snapshots/extrude-on-default-planes-should-be-stable--XY-1-Google-Chrome-linux.png b/e2e/playwright/snapshot-tests.spec.ts-snapshots/extrude-on-default-planes-should-be-stable--XY-1-Google-Chrome-linux.png index 41b639aa3..04ccc0ac3 100644 Binary files a/e2e/playwright/snapshot-tests.spec.ts-snapshots/extrude-on-default-planes-should-be-stable--XY-1-Google-Chrome-linux.png and b/e2e/playwright/snapshot-tests.spec.ts-snapshots/extrude-on-default-planes-should-be-stable--XY-1-Google-Chrome-linux.png differ diff --git a/e2e/playwright/snapshot-tests.spec.ts-snapshots/extrude-on-default-planes-should-be-stable--XZ-1-Google-Chrome-linux.png b/e2e/playwright/snapshot-tests.spec.ts-snapshots/extrude-on-default-planes-should-be-stable--XZ-1-Google-Chrome-linux.png index 847df3860..d7362934b 100644 Binary files a/e2e/playwright/snapshot-tests.spec.ts-snapshots/extrude-on-default-planes-should-be-stable--XZ-1-Google-Chrome-linux.png and b/e2e/playwright/snapshot-tests.spec.ts-snapshots/extrude-on-default-planes-should-be-stable--XZ-1-Google-Chrome-linux.png differ diff --git a/e2e/playwright/snapshot-tests.spec.ts-snapshots/extrude-on-default-planes-should-be-stable--YZ-1-Google-Chrome-linux.png b/e2e/playwright/snapshot-tests.spec.ts-snapshots/extrude-on-default-planes-should-be-stable--YZ-1-Google-Chrome-linux.png index a7f624c82..60c31b2c3 100644 Binary files a/e2e/playwright/snapshot-tests.spec.ts-snapshots/extrude-on-default-planes-should-be-stable--YZ-1-Google-Chrome-linux.png and b/e2e/playwright/snapshot-tests.spec.ts-snapshots/extrude-on-default-planes-should-be-stable--YZ-1-Google-Chrome-linux.png differ diff --git a/e2e/playwright/snapshot-tests.spec.ts-snapshots/extrude-on-default-planes-should-be-stable-XY-1-Google-Chrome-linux.png b/e2e/playwright/snapshot-tests.spec.ts-snapshots/extrude-on-default-planes-should-be-stable-XY-1-Google-Chrome-linux.png index 8b20d67f8..8b4db660e 100644 Binary files a/e2e/playwright/snapshot-tests.spec.ts-snapshots/extrude-on-default-planes-should-be-stable-XY-1-Google-Chrome-linux.png and b/e2e/playwright/snapshot-tests.spec.ts-snapshots/extrude-on-default-planes-should-be-stable-XY-1-Google-Chrome-linux.png differ diff --git a/e2e/playwright/snapshot-tests.spec.ts-snapshots/extrude-on-default-planes-should-be-stable-XZ-1-Google-Chrome-linux.png b/e2e/playwright/snapshot-tests.spec.ts-snapshots/extrude-on-default-planes-should-be-stable-XZ-1-Google-Chrome-linux.png index 72dc9efb9..dbcda0f45 100644 Binary files a/e2e/playwright/snapshot-tests.spec.ts-snapshots/extrude-on-default-planes-should-be-stable-XZ-1-Google-Chrome-linux.png and b/e2e/playwright/snapshot-tests.spec.ts-snapshots/extrude-on-default-planes-should-be-stable-XZ-1-Google-Chrome-linux.png differ diff --git a/e2e/playwright/snapshot-tests.spec.ts-snapshots/extrude-on-default-planes-should-be-stable-YZ-1-Google-Chrome-linux.png b/e2e/playwright/snapshot-tests.spec.ts-snapshots/extrude-on-default-planes-should-be-stable-YZ-1-Google-Chrome-linux.png index 679794a1f..c69bea0d0 100644 Binary files a/e2e/playwright/snapshot-tests.spec.ts-snapshots/extrude-on-default-planes-should-be-stable-YZ-1-Google-Chrome-linux.png and b/e2e/playwright/snapshot-tests.spec.ts-snapshots/extrude-on-default-planes-should-be-stable-YZ-1-Google-Chrome-linux.png differ diff --git a/e2e/playwright/test-utils.ts b/e2e/playwright/test-utils.ts index 14e226d3f..43cd31e68 100644 --- a/e2e/playwright/test-utils.ts +++ b/e2e/playwright/test-utils.ts @@ -21,7 +21,7 @@ async function waitForPageLoad(page: Page) { timeout: 20_000, }) - await expect(page.getByTestId('start-sketch')).toBeEnabled({ + await expect(page.getByRole('button', { name: 'Start Sketch' })).toBeEnabled({ timeout: 20_000, }) } @@ -58,8 +58,9 @@ async function waitForDefaultPlanesToBeVisible(page: Page) { } async function openKclCodePanel(page: Page) { - const paneLocator = page.getByTestId('KCL Code') - const isOpen = (await paneLocator?.getAttribute('aria-pressed')) === 'true' + const paneLocator = page.getByTestId('code-pane-button') + const ariaSelected = await paneLocator?.getAttribute('aria-pressed') + const isOpen = ariaSelected === 'true' if (!isOpen) { await paneLocator.click() @@ -68,8 +69,10 @@ async function openKclCodePanel(page: Page) { } async function closeKclCodePanel(page: Page) { - const paneLocator = page.getByTestId('KCL Code') - const isOpen = (await paneLocator?.getAttribute('aria-pressed')) === 'true' + const paneLocator = page.getByTestId('code-pane-button') + const ariaSelected = await paneLocator?.getAttribute('aria-pressed') + const isOpen = ariaSelected === 'true' + if (isOpen) { await paneLocator.click() await expect(paneLocator).not.toHaveAttribute('aria-pressed', 'true') @@ -77,7 +80,8 @@ async function closeKclCodePanel(page: Page) { } async function openDebugPanel(page: Page) { - const debugLocator = page.getByTestId('Debug') + const debugLocator = page.getByTestId('debug-pane-button') + await expect(debugLocator).toBeVisible() const isOpen = (await debugLocator?.getAttribute('aria-pressed')) === 'true' if (!isOpen) { @@ -87,7 +91,8 @@ async function openDebugPanel(page: Page) { } async function closeDebugPanel(page: Page) { - const debugLocator = page.getByTestId('Debug') + const debugLocator = page.getByTestId('debug-pane-button') + await expect(debugLocator).toBeVisible() const isOpen = (await debugLocator?.getAttribute('aria-pressed')) === 'true' if (isOpen) { await debugLocator.click() diff --git a/src/Toolbar.tsx b/src/Toolbar.tsx index 591c44c9a..633538d2a 100644 --- a/src/Toolbar.tsx +++ b/src/Toolbar.tsx @@ -1,4 +1,4 @@ -import { WheelEvent, useRef, useMemo } from 'react' +import { useRef, useMemo, memo } from 'react' import { isCursorInSketchCommandRange } from 'lang/util' import { engineCommandManager, kclManager } from 'lib/singletons' import { useModelingContext } from 'hooks/useModelingContext' @@ -12,11 +12,14 @@ import { ActionButtonDropdown } from 'components/ActionButtonDropdown' import { useHotkeys } from 'react-hotkeys-hook' import Tooltip from 'components/Tooltip' import { useAppState } from 'AppState' +import { CustomIcon } from 'components/CustomIcon' import { - canRectangleTool, - isEditingExistingSketch, -} from 'machines/modelingMachine' -import { DEV } from 'env' + toolbarConfig, + ToolbarItem, + ToolbarItemCallbackProps, + ToolbarItemResolved, + ToolbarModeName, +} from 'lib/toolbar' export function Toolbar({ className = '', @@ -25,12 +28,14 @@ export function Toolbar({ const { state, send, context } = useModelingContext() const { commandBarSend } = useCommandsContext() const iconClassName = - 'group-disabled:text-chalkboard-50 group-enabled:group-hover:!text-primary dark:group-enabled:group-hover:!text-inherit group-pressed:!text-chalkboard-10 group-ui-open:!text-chalkboard-10 dark:group-ui-open:!text-chalkboard-10' - const bgClassName = - 'group-disabled:!bg-transparent group-enabled:group-hover:bg-primary/10 dark:group-enabled:group-hover:bg-primary group-pressed:bg-primary group-ui-open:bg-primary' - const buttonClassName = - 'bg-chalkboard-10 dark:bg-chalkboard-100 enabled:hover:bg-chalkboard-10 dark:enabled:hover:bg-chalkboard-100 pressed:!border-primary ui-open:!border-primary' - const pathId = useMemo(() => { + 'group-disabled:text-chalkboard-50 !text-inherit dark:group-enabled:group-hover:!text-inherit' + const bgClassName = '!bg-transparent' + const buttonBgClassName = + 'bg-chalkboard-transparent dark:bg-transparent disabled:bg-transparent dark:disabled:bg-transparent enabled:hover:bg-chalkboard-10 dark:enabled:hover:bg-chalkboard-100 pressed:!bg-primary pressed:enabled:hover:!text-chalkboard-10' + const buttonBorderClassName = + '!border-transparent hover:!border-chalkboard-20 dark:enabled:hover:!border-primary pressed:!border-primary ui-open:!border-primary' + + const sketchPathId = useMemo(() => { if (!isSingleCursorInPipe(context.selectionRanges, kclManager.ast)) { return false } @@ -51,401 +56,292 @@ export function Toolbar({ isExecuting || !isStreamReady - const disableLineButton = - state.matches('Sketch.Rectangle tool.Awaiting second corner') || - disableAllButtons - useHotkeys( - 'l', - () => - state.matches('Sketch.Line tool') - ? send('CancelSketch') - : send({ - type: 'change tool', - data: { tool: 'line' }, - }), - { enabled: !disableLineButton, scopes: ['sketch'] } - ) - const disableTangentialArc = - (!isEditingExistingSketch(context) && - !state.matches('Sketch.Tangential arc to')) || - disableAllButtons - useHotkeys( - 'a', - () => - state.matches('Sketch.Tangential arc to') - ? send('CancelSketch') - : send({ - type: 'change tool', - data: { tool: 'tangentialArc' }, - }), - { enabled: !disableTangentialArc, scopes: ['sketch'] } - ) - const disableRectangle = - (!canRectangleTool(context) && !state.matches('Sketch.Rectangle tool')) || - disableAllButtons - useHotkeys( - 'r', - () => - state.matches('Sketch.Rectangle tool') - ? send('CancelSketch') - : send({ - type: 'change tool', - data: { tool: 'rectangle' }, - }), - { enabled: !disableRectangle, scopes: ['sketch'] } - ) - useHotkeys( - 's', - () => - state.nextEvents.includes('Enter sketch') && pathId - ? send({ type: 'Enter sketch' }) - : send({ type: 'Enter sketch', data: { forceNewSketch: true } }), - { enabled: !disableAllButtons, scopes: ['modeling'] } - ) - useHotkeys( - 'esc', - () => - ['Sketch no face', 'Sketch.SketchIdle'].some(state.matches) - ? send('Cancel') - : send('CancelSketch'), - { enabled: !disableAllButtons, scopes: ['sketch'] } - ) - useHotkeys( - 'e', - () => - commandBarSend({ - type: 'Find and select command', - data: { name: 'Extrude', groupId: 'modeling' }, - }), - { enabled: !disableAllButtons, scopes: ['modeling'] } - ) - const disableFillet = !state.can('Fillet') || disableAllButtons - useHotkeys( - 'f', - () => - commandBarSend({ - type: 'Find and select command', - data: { name: 'Fillet', groupId: 'modeling' }, - }), - { enabled: !disableFillet, scopes: ['modeling'] } + const currentMode = + (Object.entries(toolbarConfig).find(([_, mode]) => + mode.check(state) + )?.[0] as ToolbarModeName) || 'modeling' + + /** These are the props that will be passed to the callbacks in the toolbar config + * They are memoized to prevent unnecessary re-renders, + * but they still get a lot of churn from the state machine + * so I think there's a lot of room for improvement here + */ + const configCallbackProps: ToolbarItemCallbackProps = useMemo( + () => ({ + modelingStateMatches: state.matches, + modelingSend: send, + commandBarSend, + sketchPathId, + }), + [state.matches, send, commandBarSend, sketchPathId] ) - function handleToolbarButtonsWheelEvent(ev: WheelEvent) { - const span = toolbarButtonsRef.current - if (!span) { - return + /** + * Resolve all the callbacks and values for the current mode, + * so we don't need to worry about the other modes + */ + const currentModeItems: ( + | ToolbarItemResolved + | ToolbarItemResolved[] + | 'break' + )[] = useMemo(() => { + return toolbarConfig[currentMode].items.map((maybeIconConfig) => { + if (maybeIconConfig === 'break') { + return 'break' + } else if (Array.isArray(maybeIconConfig)) { + return maybeIconConfig.map(resolveItemConfig) + } else { + return resolveItemConfig(maybeIconConfig) + } + }) + + function resolveItemConfig( + maybeIconConfig: ToolbarItem + ): ToolbarItemResolved { + return { + ...maybeIconConfig, + title: + typeof maybeIconConfig.title === 'string' + ? maybeIconConfig.title + : maybeIconConfig.title(configCallbackProps), + description: maybeIconConfig.description, + links: maybeIconConfig.links || [], + isActive: maybeIconConfig.isActive?.(state), + hotkey: + typeof maybeIconConfig.hotkey === 'string' + ? maybeIconConfig.hotkey + : maybeIconConfig.hotkey?.(state), + disabled: + disableAllButtons || + maybeIconConfig.status !== 'available' || + maybeIconConfig.disabled?.(state) === true, + disableHotkey: maybeIconConfig.disableHotkey?.(state), + status: maybeIconConfig.status, + } } + }, [currentMode, disableAllButtons, configCallbackProps]) - span.scrollLeft = span.scrollLeft += ev.deltaY - } - const nextEvents = useMemo(() => state.nextEvents, [state.nextEvents]) - const splitMenuItems = useMemo( - () => - nextEvents - .filter( - (eventName) => - eventName.includes('Make segment') || - eventName.includes('Constrain') - ) - .sort((a, b) => { - const aisEnabled = nextEvents - .filter((event) => state.can(event as any)) - .includes(a) - const bIsEnabled = nextEvents - .filter((event) => state.can(event as any)) - .includes(b) - if (aisEnabled && !bIsEnabled) { - return -1 - } - if (!aisEnabled && bIsEnabled) { - return 1 - } - return 0 - }) - .map((eventName) => ({ - label: eventName - .replace('Make segment ', '') - .replace('Constrain ', ''), - onClick: () => send(eventName), - disabled: - !nextEvents - .filter((event) => state.can(event as any)) - .includes(eventName) || disableAllButtons, - })), - - [JSON.stringify(nextEvents), state] - ) return ( - +
    - {nextEvents.includes('Enter sketch') && ( -
  • - - send({ type: 'Enter sketch', data: { forceNewSketch: true } }) - } - iconStart={{ - icon: 'sketch', - iconClassName, - bgClassName, - }} - disabled={disableAllButtons} - > - Start Sketch - - Shortcut: S - - -
  • - )} - {nextEvents.includes('Enter sketch') && pathId && ( -
  • - send({ type: 'Enter sketch' })} - iconStart={{ - icon: 'sketch', - iconClassName, - bgClassName, - }} - disabled={disableAllButtons} - > - Edit Sketch - - Shortcut: S - - -
  • - )} - {nextEvents.includes('Cancel') && !state.matches('idle') && ( -
  • - send({ type: 'Cancel' })} - iconStart={{ - icon: 'arrowLeft', - iconClassName, - bgClassName, - }} - disabled={disableAllButtons} - > - Exit Sketch - - Shortcut: Esc - - -
  • - )} - {state.matches('Sketch no face') && ( -
  • -
    click plane or face to sketch on
    -
  • - )} - {state.matches('Sketch') && !state.matches('idle') && ( - <> -
  • - { + if (maybeIconConfig === 'break') { + return ( +
    + ) + } else if (Array.isArray(maybeIconConfig)) { + return ( + - state?.matches('Sketch.Line tool') - ? send('CancelSketch') - : send({ - type: 'change tool', - data: { tool: 'line' }, - }) + key={maybeIconConfig[0].id} + data-testid={maybeIconConfig[0].id + '-dropdown'} + id={maybeIconConfig[0].id + '-dropdown'} + name={maybeIconConfig[0].title} + className={ + 'group/wrapper ' + + buttonBorderClassName + + ' !bg-transparent relative group !gap-0' } - aria-pressed={state?.matches('Sketch.Line tool')} - iconStart={{ - icon: 'line', - iconClassName, - bgClassName, - }} - disabled={disableLineButton} + splitMenuItems={maybeIconConfig.map((itemConfig) => ({ + id: itemConfig.id, + label: itemConfig.title, + hotkey: itemConfig.hotkey, + onClick: () => itemConfig.onClick(configCallbackProps), + disabled: + disableAllButtons || + itemConfig.status !== 'available' || + itemConfig.disabled === true, + status: itemConfig.status, + }))} > - Line - + maybeIconConfig[0].onClick(configCallbackProps) + } > - Shortcut: L - - -
  • -
  • - - state.matches('Sketch.Tangential arc to') - ? send('CancelSketch') - : send({ - type: 'change tool', - data: { tool: 'tangentialArc' }, - }) - } - aria-pressed={state.matches('Sketch.Tangential arc to')} - iconStart={{ - icon: 'arc', - iconClassName, - bgClassName, - }} - disabled={disableTangentialArc} - > - Tangential Arc - - Shortcut: A - - -
  • -
  • - - state.matches('Sketch.Rectangle tool') - ? send('CancelSketch') - : send({ - type: 'change tool', - data: { tool: 'rectangle' }, - }) - } - aria-pressed={state.matches('Sketch.Rectangle tool')} - iconStart={{ - icon: 'rectangle', - iconClassName, - bgClassName, - }} - disabled={disableRectangle} - title={ - canRectangleTool(context) - ? 'Rectangle' - : 'Can only be used when a sketch is empty currently' - } - > - Rectangle - - Shortcut: R - - -
  • - - )} - {state.matches('Sketch.SketchIdle') && - nextEvents.filter( - (eventName) => - eventName.includes('Make segment') || - eventName.includes('Constrain') - ).length > 0 && ( - - Constraints - - )} - {state.matches('idle') && ( -
  • + + + + ) + } + const itemConfig = maybeIconConfig + + return ( - commandBarSend({ - type: 'Find and select command', - data: { name: 'Extrude', groupId: 'modeling' }, - }) - } - disabled={!state.can('Extrude') || disableAllButtons} - title={ - state.can('Extrude') - ? 'extrude' - : 'sketches need to be closed, or not already extruded' - } + key={itemConfig.id} + id={itemConfig.id} + data-testid={itemConfig.id} iconStart={{ - icon: 'extrude', - iconClassName, - bgClassName, + icon: itemConfig.icon, + className: iconClassName, + bgClassName: bgClassName, }} - > - Extrude - - Shortcut: E - - -
  • - )} - {state.matches('idle') && (DEV || (window as any)._enableFillet) && ( -
  • - - commandBarSend({ - type: 'Find and select command', - data: { name: 'Fillet', groupId: 'modeling' }, - }) + className={ + 'pressed:!text-chalkboard-10 pressed:enabled:hovered:!text-chalkboard-10 ' + + buttonBorderClassName + + ' ' + + buttonBgClassName + + (!itemConfig.showTitle ? ' !px-0' : '') } - disabled={disableFillet} - title={disableFillet ? 'fillet' : "edge can't be filleted"} - iconStart={{ - icon: 'fillet', // todo: add fillet icon - iconClassName, - bgClassName, - }} + name={itemConfig.title} + aria-description={itemConfig.description} + aria-pressed={itemConfig.isActive} + disabled={ + disableAllButtons || + itemConfig.status !== 'available' || + itemConfig.disabled + } + onClick={() => itemConfig.onClick(configCallbackProps)} > - Fillet - - Shortcut: F - + -
  • - )} + ) + })}
+ {state.matches('Sketch no face') && ( +
+

Select a plane or face to start sketching

+
+ )}
) } + +/** + * The single button and dropdown button share content, so we extract it here + * It contains a tooltip with the title, description, and links + * and a hotkey listener + */ +const ToolbarItemContents = memo(function ToolbarItemContents({ + itemConfig, + configCallbackProps, +}: { + itemConfig: ToolbarItemResolved + configCallbackProps: ToolbarItemCallbackProps +}) { + useHotkeys( + itemConfig.hotkey || '', + () => { + itemConfig.onClick(configCallbackProps) + }, + { + enabled: + itemConfig.status === 'available' && + !!itemConfig.hotkey && + !itemConfig.disabled && + !itemConfig.disableHotkey, + } + ) + + return ( + <> + + {itemConfig.title} + + +
+ + {itemConfig.title} + + {itemConfig.status === 'available' && itemConfig.hotkey ? ( + {itemConfig.hotkey} + ) : itemConfig.status === 'kcl-only' ? ( + <> + + KCL code only + + + + ) : ( + itemConfig.status === 'unavailable' && ( + <> + + In development + + + + ) + )} +
+

{itemConfig.description}

+ {itemConfig.links.length > 0 && ( + <> +
+ + + )} +
+ + ) +}) diff --git a/src/components/ActionButtonDropdown.tsx b/src/components/ActionButtonDropdown.tsx index d8b5790b1..21ee82f91 100644 --- a/src/components/ActionButtonDropdown.tsx +++ b/src/components/ActionButtonDropdown.tsx @@ -1,56 +1,93 @@ import { Popover } from '@headlessui/react' -import { ActionButton, ActionButtonProps } from './ActionButton' +import { ActionButtonProps } from './ActionButton' +import { CustomIcon } from './CustomIcon' -type ActionButtonSplitProps = Omit & { +type ActionButtonSplitProps = ActionButtonProps & { Element: 'button' } & { + name?: string splitMenuItems: { + id: string label: string shortcut?: string onClick: () => void disabled?: boolean + status?: 'available' | 'unavailable' | 'kcl-only' }[] } export function ActionButtonDropdown({ splitMenuItems, className, + children, ...props }: ActionButtonSplitProps) { + const baseClassNames = `action-button p-0 m-0 group mono text-xs leading-none flex items-center gap-2 rounded-sm border-solid border border-chalkboard-30 hover:border-chalkboard-40 enabled:dark:border-chalkboard-70 dark:hover:border-chalkboard-60 dark:bg-chalkboard-90/50 text-chalkboard-100 dark:text-chalkboard-10` return ( - - - - {splitMenuItems.map((item) => ( -
  • - -
  • - ))} -
    + + {({ close }) => ( + <> + {children} + + + + {props.name ? props.name + ': ' : ''}open menu + + + + {splitMenuItems.map((item) => ( +
  • + +
  • + ))} +
    + + )}
    ) } diff --git a/src/components/AppHeader.tsx b/src/components/AppHeader.tsx index 358317974..7543af27f 100644 --- a/src/components/AppHeader.tsx +++ b/src/components/AppHeader.tsx @@ -30,7 +30,7 @@ export const AppHeader = ({ className={ 'w-full grid ' + styles.header + - ' overlaid-panes sticky top-0 z-20 px-2 items-center ' + + ' overlaid-panes sticky top-0 z-20 px-2 items-start ' + className } > diff --git a/src/components/CustomIcon.tsx b/src/components/CustomIcon.tsx index a9e0bf81f..2c9eee93b 100644 --- a/src/components/CustomIcon.tsx +++ b/src/components/CustomIcon.tsx @@ -6,7 +6,7 @@ const CustomIconMap = { @@ -71,6 +71,46 @@ const CustomIconMap = { /> ), + booleanExclude: ( + + + + ), + booleanIntersect: ( + + + + ), + booleanSubtract: ( + + + + ), + booleanUnion: ( + + + + ), bug: ( ), + chamfer3d: ( + + + + ), + circle: ( + + + + ), clipboardCheckmark: ( @@ -203,6 +263,16 @@ const CustomIconMap = { /> ), + fillet3d: ( + + + + ), file: ( ), + hole: ( + + + + ), horizontal: ( @@ -331,6 +411,36 @@ const CustomIconMap = { /> ), + lockClosed: ( + + + + ), + lockOpen: ( + + + + ), + loft: ( + + + + ), 'make-variable': ( ), + mirror: ( + + + + ), move: ( ), + plane: ( + + + + ), plus: ( ), + polygon: ( + + + + ), questionMark: ( ), + revolve: ( + + + + ), search: ( ), + shell: ( + + + + ), sketch: ( ), + spline: ( + + + + ), + sweep: ( + + + + ), tangent: ( ), + text: ( + + + + ), 'three-dots': ( - + Help and resources + Help and resources
    diff --git a/src/components/LowerRightControls.tsx b/src/components/LowerRightControls.tsx index 1c503e9e4..674553327 100644 --- a/src/components/LowerRightControls.tsx +++ b/src/components/LowerRightControls.tsx @@ -80,7 +80,9 @@ export function LowerRightControls({ name="bug" className={`w-5 h-5 ${linkOverrideClassName}`} /> - Report a bug + + Report a bug + - Settings + Settings + + Settings + diff --git a/src/components/ModelingSidebar/ModelingSidebar.tsx b/src/components/ModelingSidebar/ModelingSidebar.tsx index 4b75ac323..c24c5bdac 100644 --- a/src/components/ModelingSidebar/ModelingSidebar.tsx +++ b/src/components/ModelingSidebar/ModelingSidebar.tsx @@ -203,7 +203,8 @@ function ModelingPaneButton({ ) diff --git a/src/components/NetworkHealthIndicator.tsx b/src/components/NetworkHealthIndicator.tsx index 95663f44e..2a5af365f 100644 --- a/src/components/NetworkHealthIndicator.tsx +++ b/src/components/NetworkHealthIndicator.tsx @@ -95,7 +95,6 @@ export const NetworkHealthIndicator = () => { } data-testid="network-toggle" > - Network Health { 'rounded-sm ' + overallConnectionStateColor[overallState].bg } /> - + Network Health ({NETWORK_HEALTH_TEXT[overallState]}) diff --git a/src/components/ProjectSidebarMenu.tsx b/src/components/ProjectSidebarMenu.tsx index 676c41847..5b992b011 100644 --- a/src/components/ProjectSidebarMenu.tsx +++ b/src/components/ProjectSidebarMenu.tsx @@ -127,7 +127,10 @@ function ProjectMenuPopover({ <> Export current part {!findCommand(exportCommandInfo) && ( - + Awaiting engine connection )} diff --git a/src/components/Tooltip.module.css b/src/components/Tooltip.module.css index accd6cb44..a1fd8dcaf 100644 --- a/src/components/Tooltip.module.css +++ b/src/components/Tooltip.module.css @@ -1,16 +1,11 @@ /* Adapted from https://github.com/argyleink/gui-challenges/blob/main/tooltips/tool-tip.css */ -.tooltip { +.tooltipWrapper { /* Used to power spacing and layout for RTL languages */ --isRTL: -1; /* internal CSS vars */ --_delay: 200ms; - --_triangle-width: 8px; - --_triangle-height: 12px; - --_p-inline-arrow-alignment: calc( - 50% + calc(var(--isRTL) * var(--_triangle-width) / 2) - ); --_p-block: 4px; --_bg: var(--chalkboard-10); --_shadow-alpha: 8%; @@ -18,26 +13,21 @@ pointer-events: none; user-select: none; + visibility: hidden; + position: absolute; + z-index: 1; /* The parts that will be transitioned */ opacity: 0; transform: translate(var(--_x, 0), var(--_y, 0)); transition: transform 0.15s ease-out, opacity 0.11s ease-out; +} - position: absolute; - z-index: 1; +.tooltip { + @apply relative; inline-size: max-content; - max-inline-size: 25ch; - text-align: start; - font-family: var(--mono-font-family); - text-transform: none; - font-size: 0.9rem; - font-weight: normal; - line-height: initial; - letter-spacing: 0; padding: var(--_p-block) calc(2 * var(--_p-block)); margin: 0; - border-radius: 3px; background: var(--_bg); @apply text-chalkboard-110; will-change: filter; @@ -57,26 +47,28 @@ } /* :has and :is are pretty fresh CSS pseudo-selectors, may not see full support */ -:has(> .tooltip) { +:has(> .tooltipWrapper) { position: relative; } -:is(:hover, :active) > .tooltip { +:is(:hover, :active) > .tooltipWrapper { + visibility: visible; opacity: 1; transition-delay: var(--_delay); } -:is(:focus-visible) > .tooltip.withFocus { +:is(:focus-visible) > .tooltipWrapper.withFocus { + visibility: visible; opacity: 1; } -.tooltip:focus-visible { +*:focus-visible .tooltipWrapper { --_delay: 0 !important; } /* prepend some prose for screen readers only */ -.tooltip::before { - content: '; Has tooltip: '; +.tooltip::before, +.tooltip::after { clip: rect(1px, 1px, 1px, 1px); clip-path: inset(50%); height: 1px; @@ -87,20 +79,19 @@ position: absolute; } +.tooltip::before { + content: '; Has tooltip: '; +} + /* Sometimes there's no visible label, * so we'll use the tooltip as the label */ .tooltip:only-child::before { - content: 'Tooltip:'; + content: ''; } -.caret { - width: 8px; - height: var(--_triangle-height); - position: absolute; - z-index: -1; - transform-origin: center center; - color: var(--_bg); +.tooltip:only-child::after { + content: ' (tooltip)'; } .top, @@ -108,129 +99,80 @@ text-align: center; } -.tooltip.top { +.tooltipWrapper.top { inset-inline-start: 50%; - inset-block-end: calc(100% + var(--_p-block) + var(--_triangle-height)); + inset-block-end: 100%; --_x: calc(50% * var(--isRTL)); } -.top .caret { - inset-block-start: calc(100% - 1px); - inset-inline-start: 50%; - transform: translateX(-50%); -} - -.tooltip.top-right { - inset-inline-end: var(--_p-inline-arrow-alignment); - inset-block-end: calc(100% + var(--_p-block) + var(--_triangle-height)); -} - -/* The corner caret SVG is bottom-right oriented by default */ -.top-right .caret { - inset-block-start: calc(100% - 1px); +.tooltipWrapper.top-right { + inset-block-end: 100%; inset-inline-end: 0; } -.tooltip.right { - inset-inline-start: calc(100% + var(--_triangle-height)); +.tooltipWrapper.right { + inset-inline-start: 100%; inset-block-end: 50%; --_y: 50%; } -.right .caret { - inset-inline-end: calc(100% - 1px); - inset-block-start: 50%; - transform: translateY(-50%) rotate(90deg); -} - -.tooltip.bottom-right { - inset-inline-end: var(--_p-inline-arrow-alignment); - inset-block-start: calc(100% + var(--_p-block) + var(--_triangle-height)); -} - -.bottom-right .caret { - inset-block-end: calc(100% - 1px); +.tooltipWrapper.bottom-right { + inset-block-start: 100%; inset-inline-end: 0; - transform: rotate(180deg) scaleX(-1); } -.tooltip.bottom { +.tooltipWrapper.bottom { --_x: calc(50% * var(--isRTL)); inset-inline-start: 50%; - inset-block-start: calc(100% + var(--_p-block) + var(--_triangle-height)); + inset-block-start: 100%; } -.bottom .caret { - inset-block-end: calc(100% - 1px); - inset-inline-start: 50%; - transform: translateX(-50%) scaleY(-1); +.tooltipWrapper.bottom-left { + inset-block-start: 100%; } -.tooltip.bottom-left { - inset-inline-start: var(--_p-inline-arrow-alignment); - inset-block-start: calc(100% + var(--_p-block) + var(--_triangle-height)); -} - -.bottom-left .caret { - inset-block-end: calc(100% - 1px); - inset-inline-start: 0; - transform: rotate(180deg); -} - -.tooltip.left { - inset-inline-end: calc( - 100% + var(--_p-inline-arrow-alignment) + var(--_triangle-height) - ); +.tooltipWrapper.left { + inset-inline-end: 100%; inset-block-end: 50%; --_y: 50%; } -.left .caret { - inset-inline-start: calc(100% - 1px); - inset-block-start: 50%; - transform: translateY(-50%) rotate(-90deg); -} - -.tooltip.top-left { - inset-inline-start: var(--_p-inline-arrow-alignment); - inset-block-end: calc(100% + var(--_p-block) + var(--_triangle-height)); -} - -.top-left .caret { - inset-block-start: calc(100% - 1px); - inset-inline-start: 0; - transform: rotate(-90deg); +.tooltipWrapper.top-left { + inset-block-end: 100%; } @media (prefers-reduced-motion: no-preference) { /* TOP || BLOCK-START */ - :has(> :is(.tooltip.top, .tooltip.top-left, .tooltip.top-right)):not( - :hover, - :active - ) - .tooltip { + :has( + > :is( + .tooltipWrapper.top, + .tooltipWrapper.top-left, + .tooltipWrapper.top-right + ) + ):not(:hover, :active) + .tooltipWrapper { --_y: 3px; } /* RIGHT || INLINE-END */ - :has(> :is(.tooltip.right)):not(:hover, :active) .tooltip { + :has(> :is(.tooltipWrapper.right)):not(:hover, :active) .tooltipWrapper { --_x: calc(var(--isRTL) * -3px * -1); } /* BOTTOM || BLOCK-END */ :has( > :is( - .tooltip.bottom, - .tooltip.tooltip.bottom-left, - .tooltip.bottom-right + .tooltipWrapper.bottom, + .tooltipWrapper.bottom-left, + .tooltipWrapper.bottom-right ) ):not(:hover, :active) - .tooltip { + .tooltipWrapper { --_y: -3px; } /* BOTTOM || BLOCK-END */ - :has(> :is(.tooltip.left)):not(:hover, :active) .tooltip { + :has(> :is(.tooltipWrapper.left)):not(:hover, :active) .tooltipWrapper { --_x: calc(var(--isRTL) * 3px * -1); } } diff --git a/src/components/Tooltip.tsx b/src/components/Tooltip.tsx index a0189f7f4..58c4d3f3b 100644 --- a/src/components/Tooltip.tsx +++ b/src/components/Tooltip.tsx @@ -3,7 +3,6 @@ // eslint-disable-next-line css-modules/no-unused-class import styles from './Tooltip.module.css' -const SIDES = ['top', 'bottom', 'left', 'right'] as const type TopOrBottom = 'top' | 'bottom' type LeftOrRight = 'left' | 'right' type Corner = `${TopOrBottom}-${LeftOrRight}` @@ -11,53 +10,36 @@ type TooltipPosition = TopOrBottom | LeftOrRight | Corner interface TooltipProps extends React.PropsWithChildren { position?: TooltipPosition - className?: string + wrapperClassName?: string + contentClassName?: string delay?: number hoverOnly?: boolean + inert?: boolean } export default function Tooltip({ children, position = 'top', - className, + wrapperClassName: className, + contentClassName, delay = 200, hoverOnly = false, + inert = true, }: TooltipProps) { return (
    - {children} -
    - {SIDES.includes(position as any) ? ( - - - - ) : ( - - - - )} +
    + {children}
    ) diff --git a/src/lib/toolbar.ts b/src/lib/toolbar.ts new file mode 100644 index 000000000..ca1b6c202 --- /dev/null +++ b/src/lib/toolbar.ts @@ -0,0 +1,714 @@ +import { CustomIconName } from 'components/CustomIcon' +import { DEV } from 'env' +import { commandBarMachine } from 'machines/commandBarMachine' +import { + canRectangleTool, + isEditingExistingSketch, + modelingMachine, +} from 'machines/modelingMachine' +import { EventFrom, StateFrom } from 'xstate' + +export type ToolbarModeName = 'modeling' | 'sketching' + +type ToolbarMode = { + check: (state: StateFrom) => boolean + items: (ToolbarItem | ToolbarItem[] | 'break')[] +} + +export interface ToolbarItemCallbackProps { + modelingStateMatches: StateFrom['matches'] + modelingSend: (event: EventFrom) => void + commandBarSend: (event: EventFrom) => void + sketchPathId: string | false +} + +export type ToolbarItem = { + id: string + onClick: (props: ToolbarItemCallbackProps) => void + icon?: CustomIconName + status: 'available' | 'unavailable' | 'kcl-only' + disabled?: (state: StateFrom) => boolean + disableHotkey?: (state: StateFrom) => boolean + title: string | ((props: ToolbarItemCallbackProps) => string) + showTitle?: boolean + hotkey?: + | string + | ((state: StateFrom) => string | string[]) + description: string + links: { label: string; url: string }[] + isActive?: (state: StateFrom) => boolean +} + +export type ToolbarItemResolved = Omit< + ToolbarItem, + 'disabled' | 'disableHotkey' | 'hotkey' | 'isActive' | 'title' +> & { + title: string + disabled?: boolean + disableHotkey?: boolean + hotkey?: string | string[] + isActive?: boolean +} + +export const toolbarConfig: Record = { + modeling: { + check: (state) => + !(state.matches('Sketch') || state.matches('Sketch no face')), + items: [ + { + id: 'sketch', + onClick: ({ modelingSend, sketchPathId }) => + !sketchPathId + ? modelingSend({ + type: 'Enter sketch', + data: { forceNewSketch: true }, + }) + : modelingSend({ type: 'Enter sketch' }), + icon: 'sketch', + status: 'available', + disabled: (state) => !state.matches('idle'), + title: ({ sketchPathId }) => + `${sketchPathId ? 'Edit' : 'Start'} Sketch`, + showTitle: true, + hotkey: 'S', + description: 'Start drawing a 2D sketch', + links: [ + { label: 'KCL docs', url: 'https://zoo.dev/docs/kcl/startSketchOn' }, + ], + }, + 'break', + { + id: 'extrude', + onClick: ({ commandBarSend }) => + commandBarSend({ + type: 'Find and select command', + data: { name: 'Extrude', groupId: 'modeling' }, + }), + disabled: (state) => !state.can('Extrude'), + icon: 'extrude', + status: 'available', + title: 'Extrude', + hotkey: 'E', + description: 'Pull a sketch into 3D along its normal or perpendicular.', + links: [{ label: 'KCL docs', url: 'https://zoo.dev/docs/kcl/extrude' }], + }, + { + id: 'revolve', + onClick: () => console.error('Revolve not yet implemented'), + icon: 'revolve', + status: 'kcl-only', + title: 'Revolve', + hotkey: 'R', + description: + 'Create a 3D body by rotating a sketch region about an axis.', + links: [ + { label: 'KCL docs', url: 'https://zoo.dev/docs/kcl/revolve' }, + { + label: 'KCL example', + url: 'https://zoo.dev/docs/kcl-samples/ball-bearing', + }, + ], + }, + { + id: 'sweep', + onClick: () => console.error('Sweep not yet implemented'), + icon: 'sweep', + status: 'unavailable', + title: 'Sweep', + hotkey: 'W', + description: + 'Create a 3D body by moving a sketch region along an arbitrary path.', + links: [ + { + label: 'GitHub discussion', + url: 'https://github.com/KittyCAD/modeling-app/discussions/498', + }, + ], + }, + { + id: 'loft', + onClick: () => console.error('Loft not yet implemented'), + icon: 'loft', + status: 'unavailable', + title: 'Loft', + hotkey: 'L', + description: + 'Create a 3D body by blending between two or more sketches.', + links: [ + { + label: 'GitHub discussion', + url: 'https://github.com/KittyCAD/modeling-app/discussions/613', + }, + ], + }, + 'break', + { + id: 'fillet3d', + onClick: ({ commandBarSend }) => + commandBarSend({ + type: 'Find and select command', + data: { name: 'Fillet', groupId: 'modeling' }, + }), + icon: 'fillet3d', + status: DEV ? 'available' : 'kcl-only', + disabled: (state) => !state.can('Fillet'), + title: 'Fillet', + hotkey: 'F', + description: 'Round the edges of a 3D solid.', + links: [{ label: 'KCL docs', url: 'https://zoo.dev/docs/kcl/fillet' }], + }, + { + id: 'chamfer', + onClick: () => console.error('Chamfer not yet implemented'), + icon: 'chamfer3d', + status: 'kcl-only', + title: 'Chamfer', + hotkey: 'C', + description: 'Bevel the edges of a 3D solid.', + links: [{ label: 'KCL docs', url: 'https://zoo.dev/docs/kcl/chamfer' }], + }, + { + id: 'shell', + onClick: () => console.error('Shell not yet implemented'), + icon: 'shell', + status: 'kcl-only', + title: 'Shell', + description: 'Hollow out a 3D solid.', + links: [{ label: 'KCL docs', url: 'https://zoo.dev/docs/kcl/shell' }], + }, + { + id: 'hole', + onClick: () => console.error('Hole not yet implemented'), + icon: 'hole', + status: 'unavailable', + title: 'Hole', + description: 'Create a hole in a 3D solid.', + links: [], + }, + 'break', + [ + { + id: 'boolean-union', + onClick: () => console.error('Boolean union not yet implemented'), + icon: 'booleanUnion', + status: 'unavailable', + title: 'Union', + hotkey: 'Shift + B U', + description: 'Combine two or more solids into a single solid.', + links: [ + { + label: 'GitHub discussion', + url: 'https://github.com/KittyCAD/modeling-app/discussions/509', + }, + ], + }, + { + id: 'boolean-subtract', + onClick: () => console.error('Boolean subtract not yet implemented'), + icon: 'booleanSubtract', + status: 'unavailable', + title: 'Subtract', + hotkey: 'Shift + B S', + description: 'Subtract one solid from another.', + links: [ + { + label: 'GitHub discussion', + url: 'https://github.com/KittyCAD/modeling-app/discussions/510', + }, + ], + }, + { + id: 'boolean-intersect', + onClick: () => console.error('Boolean intersect not yet implemented'), + icon: 'booleanIntersect', + status: 'unavailable', + title: 'Intersect', + hotkey: 'Shift + B I', + description: 'Create a solid from the intersection of two solids.', + links: [ + { + label: 'GitHub discussion', + url: 'https://github.com/KittyCAD/modeling-app/discussions/511', + }, + ], + }, + ], + [ + { + id: 'plane-offset', + onClick: () => + console.error('Plane through normal not yet implemented'), + icon: 'plane', + status: 'unavailable', + title: 'Offset plane', + description: 'Create a plane parallel to an existing plane.', + links: [], + }, + { + id: 'plane-points', + onClick: () => + console.error('Plane through points not yet implemented'), + status: 'unavailable', + title: '3-point plane', + description: 'Create a plane from three points.', + links: [], + }, + ], + ], + }, + sketching: { + check: (state) => + state.matches('Sketch') || state.matches('Sketch no face'), + items: [ + { + id: 'sketch-exit', + onClick: ({ modelingSend }) => + modelingSend({ + type: 'Cancel', + }), + disableHotkey: (state) => + !( + state.matches('Sketch.SketchIdle') || + state.matches('Sketch no face') + ), + icon: 'arrowLeft', + status: 'available', + title: 'Exit sketch', + showTitle: true, + hotkey: 'Esc', + description: 'Exit the current sketch', + links: [], + }, + 'break', + { + id: 'line', + onClick: ({ modelingStateMatches: matches, modelingSend }) => + modelingSend({ + type: 'change tool', + data: { + tool: !matches('Sketch.Line tool') ? 'line' : 'none', + }, + }), + icon: 'line', + status: 'available', + disabled: (state) => + state.matches('Sketch no face') || + state.matches('Sketch.Rectangle tool.Awaiting second corner'), + title: 'Line', + hotkey: (state) => + state.matches('Sketch.Line tool') ? ['Esc', 'L'] : 'L', + description: 'Start drawing straight lines', + links: [], + isActive: (state) => state.matches('Sketch.Line tool'), + }, + [ + { + id: 'tangential-arc', + onClick: ({ modelingStateMatches, modelingSend }) => + modelingSend({ + type: 'change tool', + data: { + tool: !modelingStateMatches('Sketch.Tangential arc to') + ? 'tangentialArc' + : 'none', + }, + }), + icon: 'arc', + status: 'available', + disabled: (state) => + !isEditingExistingSketch(state.context) && + !state.matches('Sketch.Tangential arc to'), + title: 'Tangential Arc', + hotkey: (state) => + state.matches('Sketch.Tangential arc to') ? ['Esc', 'A'] : 'A', + description: 'Start drawing an arc tangent to the current segment', + links: [], + isActive: (state) => state.matches('Sketch.Tangential arc to'), + }, + { + id: 'three-point-arc', + onClick: () => console.error('Three-point arc not yet implemented'), + icon: 'arc', + status: 'unavailable', + title: 'Three-point Arc', + showTitle: false, + description: 'Draw a circular arc defined by three points', + links: [ + { + label: 'GitHub issue', + url: 'https://github.com/KittyCAD/modeling-app/issues/1659', + }, + ], + }, + ], + { + id: 'spline', + onClick: () => console.error('Spline not yet implemented'), + icon: 'spline', + status: 'unavailable', + title: 'Spline', + showTitle: false, + description: 'Draw a spline curve through a series of points', + links: [], + }, + 'break', + [ + { + id: 'circle-center', + onClick: () => console.error('Center circle not yet implemented'), + icon: 'circle', + status: 'unavailable', + title: 'Center circle', + showTitle: false, + description: 'Start drawing a circle from its center', + links: [ + { + label: 'GitHub issue', + url: 'https://github.com/KittyCAD/modeling-app/issues/1501', + }, + ], + }, + { + id: 'circle-three-points', + onClick: () => + console.error('Three-point circle not yet implemented'), + icon: 'circle', + status: 'unavailable', + disabled: () => true, + title: 'Three-point circle', + showTitle: false, + description: 'Draw a circle defined by three points', + links: [], + }, + ], + [ + { + id: 'corner-rectangle', + onClick: ({ modelingStateMatches, modelingSend }) => + modelingSend({ + type: 'change tool', + data: { + tool: !modelingStateMatches('Sketch.Rectangle tool') + ? 'rectangle' + : 'none', + }, + }), + icon: 'rectangle', + status: 'available', + disabled: (state) => + !canRectangleTool(state.context) && + !state.matches('Sketch.Rectangle tool'), + title: 'Corner rectangle', + hotkey: (state) => + state.matches('Sketch.Rectangle tool') ? ['Esc', 'R'] : 'R', + description: 'Start drawing a rectangle', + links: [], + isActive: (state) => state.matches('Sketch.Rectangle tool'), + }, + { + id: 'center-rectangle', + onClick: () => console.error('Center rectangle not yet implemented'), + icon: 'rectangle', + status: 'unavailable', + title: 'Center rectangle', + showTitle: false, + description: 'Start drawing a rectangle from its center', + links: [], + }, + ], + { + id: 'polygon', + onClick: () => console.error('Polygon not yet implemented'), + icon: 'polygon', + status: 'unavailable', + title: 'Polygon', + showTitle: false, + description: 'Draw a polygon with a specified number of sides', + links: [], + }, + { + id: 'text', + onClick: () => console.error('Text not yet implemented'), + icon: 'text', + status: 'unavailable', + title: 'Text', + showTitle: false, + description: 'Add text to your sketch as geometry.', + links: [], + }, + 'break', + { + id: 'mirror', + onClick: () => console.error('Mirror not yet implemented'), + icon: 'mirror', + status: 'unavailable', + title: 'Mirror', + showTitle: false, + description: 'Mirror sketch entities about a line or axis', + links: [], + }, + [ + { + id: 'constraint-length', + disabled: (state) => + !( + state.matches('Sketch.SketchIdle') && + state.nextEvents.includes('Constrain length') && + state.can('Constrain length') + ), + onClick: ({ modelingSend }) => + modelingSend({ type: 'Constrain length' }), + icon: 'dimension', + status: 'available', + title: 'Length', + showTitle: false, + description: 'Constrain the length of a straight segment', + links: [], + }, + { + id: 'constraint-angle', + disabled: (state) => + !( + state.matches('Sketch.SketchIdle') && + state.nextEvents.includes('Constrain angle') && + state.can('Constrain angle') + ), + onClick: ({ modelingSend }) => + modelingSend({ type: 'Constrain angle' }), + status: 'available', + title: 'Angle', + showTitle: false, + description: 'Constrain the angle between two segments', + links: [], + }, + { + id: 'constraint-vertical', + disabled: (state) => + !( + state.matches('Sketch.SketchIdle') && + state.nextEvents.includes('Make segment vertical') && + state.can('Make segment vertical') + ), + onClick: ({ modelingSend }) => + modelingSend({ type: 'Make segment vertical' }), + status: 'available', + title: 'Vertical', + showTitle: false, + description: + 'Constrain a straight segment to be vertical relative to the sketch', + links: [], + }, + { + id: 'constraint-horizontal', + disabled: (state) => + !( + state.matches('Sketch.SketchIdle') && + state.nextEvents.includes('Make segment horizontal') && + state.can('Make segment horizontal') + ), + onClick: ({ modelingSend }) => + modelingSend({ type: 'Make segment horizontal' }), + status: 'available', + title: 'Horizontal', + showTitle: false, + description: + 'Constrain a straight segment to be horizontal relative to the sketch', + links: [], + }, + { + id: 'constraint-parallel', + disabled: (state) => + !( + state.matches('Sketch.SketchIdle') && + state.nextEvents.includes('Constrain parallel') && + state.can('Constrain parallel') + ), + onClick: ({ modelingSend }) => + modelingSend({ type: 'Constrain parallel' }), + status: 'available', + title: 'Parallel', + showTitle: false, + description: 'Constrain two segments to be parallel', + links: [], + }, + { + id: 'constraint-equal-length', + disabled: (state) => + !( + state.matches('Sketch.SketchIdle') && + state.nextEvents.includes('Constrain equal length') && + state.can('Constrain equal length') + ), + onClick: ({ modelingSend }) => + modelingSend({ type: 'Constrain equal length' }), + status: 'available', + title: 'Equal length', + showTitle: false, + description: 'Constrain two segments to be equal length', + links: [], + }, + { + id: 'constraint-horizontal-distance', + disabled: (state) => + !( + state.matches('Sketch.SketchIdle') && + state.nextEvents.includes('Constrain horizontal distance') && + state.can('Constrain horizontal distance') + ), + onClick: ({ modelingSend }) => + modelingSend({ type: 'Constrain horizontal distance' }), + status: 'available', + title: 'Horizontal distance', + showTitle: false, + description: 'Constrain the horizontal distance between two points', + links: [], + }, + { + id: 'constraint-vertical-distance', + disabled: (state) => + !( + state.matches('Sketch.SketchIdle') && + state.nextEvents.includes('Constrain vertical distance') && + state.can('Constrain vertical distance') + ), + onClick: ({ modelingSend }) => + modelingSend({ type: 'Constrain vertical distance' }), + status: 'available', + title: 'Vertical distance', + showTitle: false, + description: 'Constrain the vertical distance between two points', + links: [], + }, + { + id: 'constraint-absolute-x', + disabled: (state) => + !( + state.matches('Sketch.SketchIdle') && + state.nextEvents.includes('Constrain ABS X') && + state.can('Constrain ABS X') + ), + onClick: ({ modelingSend }) => + modelingSend({ type: 'Constrain ABS X' }), + status: 'available', + title: 'Absolute X', + showTitle: false, + description: 'Constrain the x-coordinate of a point', + links: [], + }, + { + id: 'constraint-absolute-y', + disabled: (state) => + !( + state.matches('Sketch.SketchIdle') && + state.nextEvents.includes('Constrain ABS Y') && + state.can('Constrain ABS Y') + ), + onClick: ({ modelingSend }) => + modelingSend({ type: 'Constrain ABS Y' }), + status: 'available', + title: 'Absolute Y', + showTitle: false, + description: 'Constrain the y-coordinate of a point', + links: [], + }, + { + id: 'constraint-perpendicular-distance', + disabled: (state) => + !( + state.matches('Sketch.SketchIdle') && + state.nextEvents.includes('Constrain perpendicular distance') && + state.can('Constrain perpendicular distance') + ), + onClick: ({ modelingSend }) => + modelingSend({ type: 'Constrain perpendicular distance' }), + status: 'available', + title: 'Perpendicular distance', + showTitle: false, + description: + 'Constrain the perpendicular distance between two segments', + links: [], + }, + { + id: 'constraint-align-horizontal', + disabled: (state) => + !( + state.matches('Sketch.SketchIdle') && + state.nextEvents.includes('Constrain horizontally align') && + state.can('Constrain horizontally align') + ), + onClick: ({ modelingSend }) => + modelingSend({ type: 'Constrain horizontally align' }), + status: 'available', + title: 'Horizontally align', + showTitle: false, + description: 'Align the ends of two or more segments horizontally', + links: [], + }, + { + id: 'constraint-align-vertical', + disabled: (state) => + !( + state.matches('Sketch.SketchIdle') && + state.nextEvents.includes('Constrain vertically align') && + state.can('Constrain vertically align') + ), + onClick: ({ modelingSend }) => + modelingSend({ type: 'Constrain vertically align' }), + status: 'available', + title: 'Vertically align', + showTitle: false, + description: 'Align the ends of two or more segments vertically', + links: [], + }, + { + id: 'snap-to-x', + disabled: (state) => + !( + state.matches('Sketch.SketchIdle') && + state.nextEvents.includes('Constrain snap to X') && + state.can('Constrain snap to X') + ), + onClick: ({ modelingSend }) => + modelingSend({ type: 'Constrain snap to X' }), + status: 'available', + title: 'Snap to X', + showTitle: false, + description: 'Snap a point to an x-coordinate', + links: [], + }, + { + id: 'snap-to-y', + disabled: (state) => + !( + state.matches('Sketch.SketchIdle') && + state.nextEvents.includes('Constrain snap to Y') && + state.can('Constrain snap to Y') + ), + onClick: ({ modelingSend }) => + modelingSend({ type: 'Constrain snap to Y' }), + status: 'available', + title: 'Snap to Y', + showTitle: false, + description: 'Snap a point to a y-coordinate', + links: [], + }, + { + id: 'constraint-remove', + disabled: (state) => + !( + state.matches('Sketch.SketchIdle') && + state.nextEvents.includes('Constrain remove constraints') && + state.can('Constrain remove constraints') + ), + onClick: ({ modelingSend }) => + modelingSend({ type: 'Constrain remove constraints' }), + status: 'available', + title: 'Remove constraints', + showTitle: false, + description: 'Remove all constraints from the segment', + links: [], + }, + ], + ], + }, +}