Add menu item and hotkey to center view on current selection (#4068)

* tentatively adding this

* Update src/components/ModelingMachineProvider.tsx

Co-authored-by: Jonathan Tran <jonnytran@gmail.com>

* Show shortcut in UI dialog

* Move command into modelingMachine action

* Add a menu item to the view menu

* Switch gizmo tests to use "deprecated" test setup in prep for new fixture-based test

* Add e2e test for center view to selection

* Bump @kittycad/lib to latest and fix tsc

* Bump @kittycad/lib to v2.0.7 to fix electron building

* A snapshot a day keeps the bugs away! 📷🐛 (OS: windows-latest)

* A snapshot a day keeps the bugs away! 📷🐛 (OS: ubuntu-latest)

* A snapshot a day keeps the bugs away! 📷🐛 (OS: windows-latest)

---------

Co-authored-by: Jonathan Tran <jonnytran@gmail.com>
Co-authored-by: Frank Noirot <frank@kittycad.io>
Co-authored-by: 49fl <ircsurfer33@gmail.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Frank Noirot <frank@zoo.dev>
This commit is contained in:
Mike Farrell
2024-10-04 13:47:44 -07:00
committed by GitHub
parent ec8cacb788
commit d104ca2b05
15 changed files with 192 additions and 29 deletions

View File

@ -13,6 +13,13 @@ type mouseParams = {
pixelDiff: number pixelDiff: number
} }
type SceneSerialised = {
camera: {
position: [number, number, number]
target: [number, number, number]
}
}
export class SceneFixture { export class SceneFixture {
public page: Page public page: Page
@ -22,6 +29,22 @@ export class SceneFixture {
this.page = page this.page = page
this.reConstruct(page) this.reConstruct(page)
} }
private _serialiseScene = async (): Promise<SceneSerialised> => {
const camera = await this.getCameraInfo()
return {
camera,
}
}
expectState = async (expected: SceneSerialised) => {
return expect
.poll(() => this._serialiseScene(), {
message: `Expected scene state to match`,
})
.toEqual(expected)
}
reConstruct = (page: Page) => { reConstruct = (page: Page) => {
this.page = page this.page = page
@ -31,7 +54,7 @@ export class SceneFixture {
makeMouseHelpers = ( makeMouseHelpers = (
x: number, x: number,
y: number, y: number,
{ steps }: { steps: number } = { steps: 5000 } { steps }: { steps: number } = { steps: 20 }
) => ) =>
[ [
(clickParams?: mouseParams) => { (clickParams?: mouseParams) => {
@ -87,6 +110,36 @@ export class SceneFixture {
) )
await closeDebugPanel(this.page) await closeDebugPanel(this.page)
} }
/** Forces a refresh of the camera position and target displayed
* in the debug panel and then returns the values of the fields
*/
async getCameraInfo() {
await openAndClearDebugPanel(this.page)
await sendCustomCmd(this.page, {
type: 'modeling_cmd_req',
cmd_id: uuidv4(),
cmd: {
type: 'default_camera_get_settings',
},
})
await this.waitForExecutionDone()
const position = await Promise.all([
this.page.getByTestId('cam-x-position').inputValue().then(Number),
this.page.getByTestId('cam-y-position').inputValue().then(Number),
this.page.getByTestId('cam-z-position').inputValue().then(Number),
])
const target = await Promise.all([
this.page.getByTestId('cam-x-target').inputValue().then(Number),
this.page.getByTestId('cam-y-target').inputValue().then(Number),
this.page.getByTestId('cam-z-target').inputValue().then(Number),
])
await closeDebugPanel(this.page)
return {
position,
target,
}
}
waitForExecutionDone = async () => { waitForExecutionDone = async () => {
await expect(this.exeIndicator).toBeVisible() await expect(this.exeIndicator).toBeVisible()
} }
@ -114,4 +167,17 @@ export class SceneFixture {
) )
}) })
} }
get gizmo() {
return this.page.locator('[aria-label*=gizmo]')
}
async clickGizmoMenuItem(name: string) {
await this.gizmo.click({ button: 'right' })
const buttonToTest = this.page.getByRole('button', {
name: name,
})
await expect(buttonToTest).toBeVisible()
await buttonToTest.click()
}
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 39 KiB

View File

@ -1,18 +1,18 @@
import { test, expect } from '@playwright/test' import { _test, _expect } from './playwright-deprecated'
import { test } from './fixtures/fixtureSetup'
import { getUtils, setup, tearDown } from './test-utils' import { getUtils, setup, tearDown } from './test-utils'
import { uuidv4 } from 'lib/utils' import { uuidv4 } from 'lib/utils'
import { TEST_CODE_GIZMO } from './storageStates' import { TEST_CODE_GIZMO } from './storageStates'
test.beforeEach(async ({ context, page }, testInfo) => { _test.beforeEach(async ({ context, page }, testInfo) => {
await setup(context, page, testInfo) await setup(context, page, testInfo)
}) })
test.afterEach(async ({ page }, testInfo) => { _test.afterEach(async ({ page }, testInfo) => {
await tearDown(page, testInfo) await tearDown(page, testInfo)
}) })
test.describe('Testing Gizmo', () => { _test.describe('Testing Gizmo', () => {
const cases = [ const cases = [
{ {
testDescription: 'top view', testDescription: 'top view',
@ -57,7 +57,7 @@ test.describe('Testing Gizmo', () => {
expectedCameraTarget, expectedCameraTarget,
testDescription, testDescription,
} of cases) { } of cases) {
test(`check ${testDescription}`, async ({ page, browserName }) => { _test(`check ${testDescription}`, async ({ page, browserName }) => {
const u = await getUtils(page) const u = await getUtils(page)
await page.addInitScript((TEST_CODE_GIZMO) => { await page.addInitScript((TEST_CODE_GIZMO) => {
localStorage.setItem('persistCode', TEST_CODE_GIZMO) localStorage.setItem('persistCode', TEST_CODE_GIZMO)
@ -117,30 +117,30 @@ test.describe('Testing Gizmo', () => {
await Promise.all([ await Promise.all([
// position // position
expect(page.getByTestId('cam-x-position')).toHaveValue( _expect(page.getByTestId('cam-x-position')).toHaveValue(
expectedCameraPosition.x.toString() expectedCameraPosition.x.toString()
), ),
expect(page.getByTestId('cam-y-position')).toHaveValue( _expect(page.getByTestId('cam-y-position')).toHaveValue(
expectedCameraPosition.y.toString() expectedCameraPosition.y.toString()
), ),
expect(page.getByTestId('cam-z-position')).toHaveValue( _expect(page.getByTestId('cam-z-position')).toHaveValue(
expectedCameraPosition.z.toString() expectedCameraPosition.z.toString()
), ),
// target // target
expect(page.getByTestId('cam-x-target')).toHaveValue( _expect(page.getByTestId('cam-x-target')).toHaveValue(
expectedCameraTarget.x.toString() expectedCameraTarget.x.toString()
), ),
expect(page.getByTestId('cam-y-target')).toHaveValue( _expect(page.getByTestId('cam-y-target')).toHaveValue(
expectedCameraTarget.y.toString() expectedCameraTarget.y.toString()
), ),
expect(page.getByTestId('cam-z-target')).toHaveValue( _expect(page.getByTestId('cam-z-target')).toHaveValue(
expectedCameraTarget.z.toString() expectedCameraTarget.z.toString()
), ),
]) ])
}) })
} }
test('Context menu and popover menu', async ({ page }) => { _test('Context menu and popover menu', async ({ page }) => {
const testCase = { const testCase = {
testDescription: 'Right view', testDescription: 'Right view',
expectedCameraPosition: { x: 5660.02, y: -152, z: 26 }, expectedCameraPosition: { x: 5660.02, y: -152, z: 26 },
@ -196,7 +196,7 @@ test.describe('Testing Gizmo', () => {
const buttonToTest = page.getByRole('button', { const buttonToTest = page.getByRole('button', {
name: testCase.testDescription, name: testCase.testDescription,
}) })
await expect(buttonToTest).toBeVisible() await _expect(buttonToTest).toBeVisible()
await buttonToTest.click() await buttonToTest.click()
// Now assert we've moved to the correct view // Now assert we've moved to the correct view
@ -215,23 +215,23 @@ test.describe('Testing Gizmo', () => {
await Promise.all([ await Promise.all([
// position // position
expect(page.getByTestId('cam-x-position')).toHaveValue( _expect(page.getByTestId('cam-x-position')).toHaveValue(
testCase.expectedCameraPosition.x.toString() testCase.expectedCameraPosition.x.toString()
), ),
expect(page.getByTestId('cam-y-position')).toHaveValue( _expect(page.getByTestId('cam-y-position')).toHaveValue(
testCase.expectedCameraPosition.y.toString() testCase.expectedCameraPosition.y.toString()
), ),
expect(page.getByTestId('cam-z-position')).toHaveValue( _expect(page.getByTestId('cam-z-position')).toHaveValue(
testCase.expectedCameraPosition.z.toString() testCase.expectedCameraPosition.z.toString()
), ),
// target // target
expect(page.getByTestId('cam-x-target')).toHaveValue( _expect(page.getByTestId('cam-x-target')).toHaveValue(
testCase.expectedCameraTarget.x.toString() testCase.expectedCameraTarget.x.toString()
), ),
expect(page.getByTestId('cam-y-target')).toHaveValue( _expect(page.getByTestId('cam-y-target')).toHaveValue(
testCase.expectedCameraTarget.y.toString() testCase.expectedCameraTarget.y.toString()
), ),
expect(page.getByTestId('cam-z-target')).toHaveValue( _expect(page.getByTestId('cam-z-target')).toHaveValue(
testCase.expectedCameraTarget.z.toString() testCase.expectedCameraTarget.z.toString()
), ),
]) ])
@ -242,8 +242,60 @@ test.describe('Testing Gizmo', () => {
const gizmoPopoverButton = page.getByRole('button', { const gizmoPopoverButton = page.getByRole('button', {
name: 'view settings', name: 'view settings',
}) })
await expect(gizmoPopoverButton).toBeVisible() await _expect(gizmoPopoverButton).toBeVisible()
await gizmoPopoverButton.click() await gizmoPopoverButton.click()
await expect(buttonToTest).toBeVisible() await _expect(buttonToTest).toBeVisible()
})
})
test.describe(`Testing gizmo, fixture-based`, () => {
test('Center on selection from menu', async ({
app,
cmdBar,
editor,
toolbar,
scene,
}) => {
test.skip(
process.platform === 'win32',
'Fails on windows in CI, can not be replicated locally on windows.'
)
await test.step(`Setup`, async () => {
const file = await app.getInputFile('test-circle-extrude.kcl')
await app.initialise(file)
await scene.expectState({
camera: {
position: [4982.21, -23865.37, 13810.64],
target: [4982.21, 0, 2737.1],
},
})
})
const [clickCircle, moveToCircle] = scene.makeMouseHelpers(582, 217)
await test.step(`Select an edge of this circle`, async () => {
const circleSnippet =
'circle({ center: [318.33, 168.1], radius: 182.8 }, %)'
await moveToCircle()
await clickCircle()
await editor.expectState({
activeLines: [circleSnippet.slice(-5)],
highlightedCode: circleSnippet,
diagnostics: [],
})
})
await test.step(`Center on selection from menu`, async () => {
await scene.clickGizmoMenuItem('Center view on selection')
})
await test.step(`Verify the camera moved`, async () => {
await scene.expectState({
camera: {
position: [0, -23865.37, 11073.54],
target: [0, 0, 0],
},
})
})
}) })
}) })

View File

@ -26,7 +26,7 @@
"@fortawesome/react-fontawesome": "^0.2.0", "@fortawesome/react-fontawesome": "^0.2.0",
"@headlessui/react": "^1.7.19", "@headlessui/react": "^1.7.19",
"@headlessui/tailwindcss": "^0.2.0", "@headlessui/tailwindcss": "^0.2.0",
"@kittycad/lib": "^2.0.1", "@kittycad/lib": "2.0.7",
"@lezer/highlight": "^1.2.1", "@lezer/highlight": "^1.2.1",
"@lezer/lr": "^1.4.1", "@lezer/lr": "^1.4.1",
"@react-hook/resize-observer": "^2.0.1", "@react-hook/resize-observer": "^2.0.1",

View File

@ -893,6 +893,7 @@ export class CameraControls {
type: 'zoom_to_fit', type: 'zoom_to_fit',
object_ids: [], // leave empty to zoom to all objects object_ids: [], // leave empty to zoom to all objects
padding: 0.2, // padding around the objects padding: 0.2, // padding around the objects
animated: false, // don't animate the zoom for now
}, },
}) })
} }

View File

@ -28,6 +28,7 @@ import {
import { Popover } from '@headlessui/react' import { Popover } from '@headlessui/react'
import { CustomIcon } from './CustomIcon' import { CustomIcon } from './CustomIcon'
import { reportRejection } from 'lib/trap' import { reportRejection } from 'lib/trap'
import { useModelingContext } from 'hooks/useModelingContext'
const CANVAS_SIZE = 80 const CANVAS_SIZE = 80
const FRUSTUM_SIZE = 0.5 const FRUSTUM_SIZE = 0.5
@ -62,6 +63,7 @@ export default function Gizmo() {
const raycasterIntersect = useRef<Intersection<Object3D> | null>(null) const raycasterIntersect = useRef<Intersection<Object3D> | null>(null)
const cameraPassiveUpdateTimer = useRef(0) const cameraPassiveUpdateTimer = useRef(0)
const raycasterPassiveUpdateTimer = useRef(0) const raycasterPassiveUpdateTimer = useRef(0)
const { send: modelingSend } = useModelingContext()
const menuItems = useMemo( const menuItems = useMemo(
() => [ () => [
...Object.entries(axisNamesSemantic).map(([axisName, axisSemantic]) => ( ...Object.entries(axisNamesSemantic).map(([axisName, axisSemantic]) => (
@ -76,6 +78,7 @@ export default function Gizmo() {
{axisSemantic} view {axisSemantic} view
</ContextMenuItem> </ContextMenuItem>
)), )),
<ContextMenuDivider />,
<ContextMenuItem <ContextMenuItem
onClick={() => { onClick={() => {
sceneInfra.camControls.resetCameraPosition().catch(reportRejection) sceneInfra.camControls.resetCameraPosition().catch(reportRejection)
@ -83,6 +86,13 @@ export default function Gizmo() {
> >
Reset view Reset view
</ContextMenuItem>, </ContextMenuItem>,
<ContextMenuItem
onClick={() => {
modelingSend({ type: 'Center camera on selection' })
}}
>
Center view on selection
</ContextMenuItem>,
<ContextMenuDivider />, <ContextMenuDivider />,
<ContextMenuItemRefresh />, <ContextMenuItemRefresh />,
], ],

View File

@ -83,6 +83,7 @@ import {
} from 'lang/std/engineConnection' } from 'lang/std/engineConnection'
import { submitAndAwaitTextToKcl } from 'lib/textToCad' import { submitAndAwaitTextToKcl } from 'lib/textToCad'
import { useFileContext } from 'hooks/useFileContext' import { useFileContext } from 'hooks/useFileContext'
import { uuidv4 } from 'lib/utils'
type MachineContext<T extends AnyStateMachine> = { type MachineContext<T extends AnyStateMachine> = {
state: StateFrom<T> state: StateFrom<T>
@ -243,6 +244,17 @@ export const ModelingMachineProvider = ({
return {} return {}
}, },
}), }),
'Center camera on selection': () => {
engineCommandManager
.sendSceneCommand({
type: 'modeling_cmd_req',
cmd_id: uuidv4(),
cmd: {
type: 'default_camera_center_to_selection',
},
})
.catch(reportRejection)
},
'Set sketchDetails': assign(({ context: { sketchDetails }, event }) => { 'Set sketchDetails': assign(({ context: { sketchDetails }, event }) => {
if (event.type !== 'Delete segment') return {} if (event.type !== 'Delete segment') return {}
if (!sketchDetails) return {} if (!sketchDetails) return {}
@ -1037,6 +1049,11 @@ export const ModelingMachineProvider = ({
modelingSend({ type: 'Delete selection' }) modelingSend({ type: 'Delete selection' })
}) })
// Allow ctrl+alt+c to center to selection
useHotkeys(['mod + alt + c'], () => {
modelingSend({ type: 'Center camera on selection' })
})
useStateMachineCommands({ useStateMachineCommands({
machineId: 'modeling', machineId: 'modeling',
state: modelingState, state: modelingState,

View File

@ -282,6 +282,7 @@ export class KclManager {
type: 'zoom_to_fit', type: 'zoom_to_fit',
object_ids: zoomObjectId ? [zoomObjectId] : [], // leave empty to zoom to all objects object_ids: zoomObjectId ? [zoomObjectId] : [], // leave empty to zoom to all objects
padding: 0.1, // padding around the objects padding: 0.1, // padding around the objects
animated: false, // don't animate the zoom for now
}, },
}) })
} }

View File

@ -145,6 +145,13 @@ export const interactionMap: Record<
description: description:
'Available while modeling with either a face selected or an empty selection, when not typing in the code editor.', 'Available while modeling with either a face selected or an empty selection, when not typing in the code editor.',
}, },
{
name: 'center-on-selection',
sequence: `${PRIMARY}+Alt+C`,
title: 'Center on selection',
description:
'Centers the view on the selected geometry, or everything if nothing is selected.',
},
], ],
'Code Editor': [ 'Code Editor': [
{ {

View File

@ -49,6 +49,7 @@ if (typeof window !== 'undefined') {
type: 'zoom_to_fit', type: 'zoom_to_fit',
object_ids: [], // leave empty to zoom to all objects object_ids: [], // leave empty to zoom to all objects
padding: 0.2, // padding around the objects padding: 0.2, // padding around the objects
animated: false, // don't animate the zoom for now
}, },
}) })
} }

View File

@ -249,7 +249,7 @@ export async function submitAndAwaitTextToKcl({
export async function sendTelemetry( export async function sendTelemetry(
id: string, id: string,
feedback: Models['AiFeedback_type'], feedback: Models['MlFeedback_type'],
token?: string token?: string
): Promise<void> { ): Promise<void> {
const url = const url =

View File

@ -252,6 +252,9 @@ export type ModelingMachineEvent =
type: 'Set Segment Overlays' type: 'Set Segment Overlays'
data: SegmentOverlayPayload data: SegmentOverlayPayload
} }
| {
type: 'Center camera on selection'
}
| { | {
type: 'Delete segment' type: 'Delete segment'
data: PathToNode data: PathToNode
@ -938,6 +941,7 @@ export const modelingMachine = setup({
'Set selection': () => {}, 'Set selection': () => {},
'Set mouse state': () => {}, 'Set mouse state': () => {},
'Set Segment Overlays': () => {}, 'Set Segment Overlays': () => {},
'Center camera on selection': () => {},
'Engine export': () => {}, 'Engine export': () => {},
'Submit to Text-to-CAD API': () => {}, 'Submit to Text-to-CAD API': () => {},
'Set sketchDetails': () => {}, 'Set sketchDetails': () => {},
@ -2105,6 +2109,10 @@ export const modelingMachine = setup({
reenter: false, reenter: false,
actions: 'Set Segment Overlays', actions: 'Set Segment Overlays',
}, },
'Center camera on selection': {
reenter: false,
actions: 'Center camera on selection',
},
}, },
}) })

View File

@ -2075,10 +2075,10 @@
"@jridgewell/resolve-uri" "^3.1.0" "@jridgewell/resolve-uri" "^3.1.0"
"@jridgewell/sourcemap-codec" "^1.4.14" "@jridgewell/sourcemap-codec" "^1.4.14"
"@kittycad/lib@^2.0.1": "@kittycad/lib@2.0.7":
version "2.0.1" version "2.0.7"
resolved "https://registry.yarnpkg.com/@kittycad/lib/-/lib-2.0.1.tgz#d3f1c80d9903452b0b9df378c72ed1e83b19a73d" resolved "https://registry.yarnpkg.com/@kittycad/lib/-/lib-2.0.7.tgz#63e9c81fc7705c9d0c5fab5939e5d839ec6f393b"
integrity sha512-VYunezWS+cNZbdKfVkB3zg2YbDCQEb/AjzER85+yyDAlTU5PL4paQDpNlEI6icSglDGRUIR4Er/bRFj68r3UQg== integrity sha512-P26rRZ0KF8C3zhEG2beLlkTJhTPtJF6Nn1wg7w1MxXNvK9RZF6P7DcXqdIh7nJGQt72+JrXoPmApB8Z/R1gQRg==
dependencies: dependencies:
openapi-types "^12.0.0" openapi-types "^12.0.0"
ts-node "^10.9.1" ts-node "^10.9.1"