diff --git a/e2e/playwright/basic-sketch.spec.ts b/e2e/playwright/basic-sketch.spec.ts index 09c1d3a7d..b3e3ff5b7 100644 --- a/e2e/playwright/basic-sketch.spec.ts +++ b/e2e/playwright/basic-sketch.spec.ts @@ -86,7 +86,7 @@ async function doBasicSketch(page: Page, openPanes: string[]) { |> startProfileAt(${commonPoints.startAt}, %) |> line([${commonPoints.num1}, 0], %) |> line([0, ${commonPoints.num1 + 0.01}], %) - |> line([-${commonPoints.num2}, 0], %)`) + |> lineTo([0, ${commonPoints.num3}], %)`) } // deselect line tool diff --git a/e2e/playwright/fixtures/editorFixture.ts b/e2e/playwright/fixtures/editorFixture.ts index be23d0af6..73718e251 100644 --- a/e2e/playwright/fixtures/editorFixture.ts +++ b/e2e/playwright/fixtures/editorFixture.ts @@ -1,6 +1,11 @@ import type { Page, Locator } from '@playwright/test' import { expect } from '@playwright/test' -import { sansWhitespace } from '../test-utils' +import { + closePane, + checkIfPaneIsOpen, + openPane, + sansWhitespace, +} from '../test-utils' interface EditorState { activeLines: Array @@ -11,6 +16,7 @@ interface EditorState { export class EditorFixture { public page: Page + private paneButtonTestId = 'code-pane-button' private diagnosticsTooltip!: Locator private diagnosticsGutterIcon!: Locator private codeContent!: Locator @@ -31,19 +37,32 @@ export class EditorFixture { private _expectEditorToContain = (not = false) => - ( + async ( code: string, { shouldNormalise = false, timeout = 5_000, }: { shouldNormalise?: boolean; timeout?: number } = {} ) => { + const wasPaneOpen = await this.checkIfPaneIsOpen() + if (!wasPaneOpen) { + await this.openPane() + } + const resetPane = async () => { + if (!wasPaneOpen) { + await this.closePane() + } + } if (!shouldNormalise) { const expectStart = expect(this.codeContent) if (not) { - return expectStart.not.toContainText(code, { timeout }) + const result = await expectStart.not.toContainText(code, { timeout }) + await resetPane() + return result } - return expectStart.toContainText(code, { timeout }) + const result = await expectStart.toContainText(code, { timeout }) + await resetPane() + return result } const normalisedCode = code.replaceAll(/\s+/g, '').trim() const expectStart = expect.poll( @@ -56,9 +75,13 @@ export class EditorFixture { } ) if (not) { - return expectStart.not.toContain(normalisedCode) + const result = await expectStart.not.toContain(normalisedCode) + await resetPane() + return result } - return expectStart.toContain(normalisedCode) + const result = await expectStart.toContain(normalisedCode) + await resetPane() + return result } expectEditor = { toContain: this._expectEditorToContain(), @@ -115,4 +138,13 @@ export class EditorFixture { code = code.replace(findCode, replaceCode) await this.codeContent.fill(code) } + checkIfPaneIsOpen() { + return checkIfPaneIsOpen(this.page, this.paneButtonTestId) + } + closePane() { + return closePane(this.page, this.paneButtonTestId) + } + openPane() { + return openPane(this.page, this.paneButtonTestId) + } } diff --git a/e2e/playwright/fixtures/fixtureSetup.ts b/e2e/playwright/fixtures/fixtureSetup.ts index 494115c53..8ccbd1a44 100644 --- a/e2e/playwright/fixtures/fixtureSetup.ts +++ b/e2e/playwright/fixtures/fixtureSetup.ts @@ -20,6 +20,7 @@ export class AuthenticatedApp { public readonly page: Page public readonly context: BrowserContext public readonly testInfo: TestInfo + public readonly viewPortSize = { width: 1000, height: 500 } constructor(context: BrowserContext, page: Page, testInfo: TestInfo) { this.page = page @@ -36,7 +37,7 @@ export class AuthenticatedApp { ;(window as any).playwrightSkipFilePicker = true }, code) - await this.page.setViewportSize({ width: 1000, height: 500 }) + await this.page.setViewportSize(this.viewPortSize) await u.waitForAuthSkipAppStart() } diff --git a/e2e/playwright/fixtures/sceneFixture.ts b/e2e/playwright/fixtures/sceneFixture.ts index 8772dffe9..3baee5dc8 100644 --- a/e2e/playwright/fixtures/sceneFixture.ts +++ b/e2e/playwright/fixtures/sceneFixture.ts @@ -10,7 +10,13 @@ import { } from '../test-utils' type mouseParams = { - pixelDiff: number + pixelDiff?: number +} +type mouseDragToParams = mouseParams & { + fromPoint: { x: number; y: number } +} +type mouseDragFromParams = mouseParams & { + toPoint: { x: number; y: number } } type SceneSerialised = { @@ -20,6 +26,13 @@ type SceneSerialised = { } } +type ClickHandler = (clickParams?: mouseParams) => Promise +type MoveHandler = (moveParams?: mouseParams) => Promise +type DragToHandler = (dragParams: mouseDragToParams) => Promise +type DragFromHandler = ( + dragParams: mouseDragFromParams +) => Promise + export class SceneFixture { public page: Page @@ -55,7 +68,7 @@ export class SceneFixture { x: number, y: number, { steps }: { steps: number } = { steps: 20 } - ) => + ): [ClickHandler, MoveHandler] => [ (clickParams?: mouseParams) => { if (clickParams?.pixelDiff) { @@ -78,6 +91,47 @@ export class SceneFixture { return this.page.mouse.move(x, y, { steps }) }, ] as const + makeDragHelpers = ( + x: number, + y: number, + { steps }: { steps: number } = { steps: 20 } + ): [DragToHandler, DragFromHandler] => + [ + (dragToParams: mouseDragToParams) => { + if (dragToParams?.pixelDiff) { + return doAndWaitForImageDiff( + this.page, + () => + this.page.dragAndDrop('#stream', '#stream', { + sourcePosition: dragToParams.fromPoint, + targetPosition: { x, y }, + }), + dragToParams.pixelDiff + ) + } + return this.page.dragAndDrop('#stream', '#stream', { + sourcePosition: dragToParams.fromPoint, + targetPosition: { x, y }, + }) + }, + (dragFromParams: mouseDragFromParams) => { + if (dragFromParams?.pixelDiff) { + return doAndWaitForImageDiff( + this.page, + () => + this.page.dragAndDrop('#stream', '#stream', { + sourcePosition: { x, y }, + targetPosition: dragFromParams.toPoint, + }), + dragFromParams.pixelDiff + ) + } + return this.page.dragAndDrop('#stream', '#stream', { + sourcePosition: { x, y }, + targetPosition: dragFromParams.toPoint, + }) + }, + ] as const /** Likely no where, there's a chance it will click something in the scene, depending what you have in the scene. * diff --git a/e2e/playwright/fixtures/toolbarFixture.ts b/e2e/playwright/fixtures/toolbarFixture.ts index c2092b1e9..fb8a908ad 100644 --- a/e2e/playwright/fixtures/toolbarFixture.ts +++ b/e2e/playwright/fixtures/toolbarFixture.ts @@ -7,6 +7,7 @@ export class ToolbarFixture { extrudeButton!: Locator startSketchBtn!: Locator + lineBtn!: Locator rectangleBtn!: Locator exitSketchBtn!: Locator editSketchBtn!: Locator @@ -24,6 +25,7 @@ export class ToolbarFixture { this.page = page this.extrudeButton = page.getByTestId('extrude') this.startSketchBtn = page.getByTestId('sketch') + this.lineBtn = page.getByTestId('line') this.rectangleBtn = page.getByTestId('corner-rectangle') this.exitSketchBtn = page.getByTestId('sketch-exit') this.editSketchBtn = page.getByText('Edit Sketch') diff --git a/e2e/playwright/point-click.spec.ts b/e2e/playwright/point-click.spec.ts index 0e0e559e8..fd8b6911f 100644 --- a/e2e/playwright/point-click.spec.ts +++ b/e2e/playwright/point-click.spec.ts @@ -451,3 +451,103 @@ sketch002 = startSketchOn(extrude001, seg03) } ) }) + +test(`Verify axis and origin snapping`, async ({ + app, + editor, + toolbar, + scene, +}) => { + // Constants and locators + // These are mappings from screenspace to KCL coordinates, + // until we merge in our coordinate system helpers + const xzPlane = [ + app.viewPortSize.width * 0.65, + app.viewPortSize.height * 0.3, + ] as const + const originSloppy = { + screen: [ + app.viewPortSize.width / 2 + 3, // 3px off the center of the screen + app.viewPortSize.height / 2, + ], + kcl: [0, 0], + } as const + const xAxisSloppy = { + screen: [ + app.viewPortSize.width * 0.75, + app.viewPortSize.height / 2 - 3, // 3px off the X-axis + ], + kcl: [16.95, 0], + } as const + const offYAxis = { + screen: [ + app.viewPortSize.width * 0.6, // Well off the Y-axis, out of snapping range + app.viewPortSize.height * 0.3, + ], + kcl: [6.78, 6.78], + } as const + const yAxisSloppy = { + screen: [ + app.viewPortSize.width / 2 + 5, // 5px off the Y-axis + app.viewPortSize.height * 0.3, + ], + kcl: [0, 6.78], + } as const + const [clickOnXzPlane, moveToXzPlane] = scene.makeMouseHelpers(...xzPlane) + const [clickOriginSloppy] = scene.makeMouseHelpers(...originSloppy.screen) + const [clickXAxisSloppy, moveXAxisSloppy] = scene.makeMouseHelpers( + ...xAxisSloppy.screen + ) + const [dragToOffYAxis, dragFromOffAxis] = scene.makeDragHelpers( + ...offYAxis.screen + ) + + const expectedCodeSnippets = { + sketchOnXzPlane: `sketch001 = startSketchOn('XZ')`, + pointAtOrigin: `startProfileAt([${originSloppy.kcl[0]}, ${originSloppy.kcl[1]}], %)`, + segmentOnXAxis: `lineTo([${xAxisSloppy.kcl[0]}, ${xAxisSloppy.kcl[1]}], %)`, + afterSegmentDraggedOffYAxis: `startProfileAt([${offYAxis.kcl[0]}, ${offYAxis.kcl[1]}], %)`, + afterSegmentDraggedOnYAxis: `startProfileAt([${yAxisSloppy.kcl[0]}, ${yAxisSloppy.kcl[1]}], %)`, + } + + await app.initialise() + + await test.step(`Start a sketch on the XZ plane`, async () => { + await editor.closePane() + await toolbar.startSketchPlaneSelection() + await moveToXzPlane() + await clickOnXzPlane() + // timeout wait for engine animation is unavoidable + await app.page.waitForTimeout(600) + await editor.expectEditor.toContain(expectedCodeSnippets.sketchOnXzPlane) + }) + await test.step(`Place a point a few pixels off the middle, verify it still snaps to 0,0`, async () => { + await clickOriginSloppy() + await editor.expectEditor.toContain(expectedCodeSnippets.pointAtOrigin) + }) + await test.step(`Add a segment on x-axis after moving the mouse a bit, verify it snaps`, async () => { + await moveXAxisSloppy() + await clickXAxisSloppy() + await editor.expectEditor.toContain(expectedCodeSnippets.segmentOnXAxis) + }) + await test.step(`Unequip line tool`, async () => { + await toolbar.lineBtn.click() + await expect(toolbar.lineBtn).not.toHaveAttribute('aria-pressed', 'true') + }) + await test.step(`Drag the origin point up and to the right, verify it's past snapping`, async () => { + await dragToOffYAxis({ + fromPoint: { x: originSloppy.screen[0], y: originSloppy.screen[1] }, + }) + await editor.expectEditor.toContain( + expectedCodeSnippets.afterSegmentDraggedOffYAxis + ) + }) + await test.step(`Drag the origin point left to the y-axis, verify it snaps back`, async () => { + await dragFromOffAxis({ + toPoint: { x: yAxisSloppy.screen[0], y: yAxisSloppy.screen[1] }, + }) + await editor.expectEditor.toContain( + expectedCodeSnippets.afterSegmentDraggedOnYAxis + ) + }) +}) diff --git a/e2e/playwright/sketch-tests.spec.ts b/e2e/playwright/sketch-tests.spec.ts index 3d66a92c9..b441c7cc2 100644 --- a/e2e/playwright/sketch-tests.spec.ts +++ b/e2e/playwright/sketch-tests.spec.ts @@ -637,7 +637,6 @@ test.describe('Sketch tests', () => { |> revolve({ axis: "X" }, %)`) }) test('Can add multiple sketches', async ({ page }) => { - test.skip(process.platform === 'darwin', 'Can add multiple sketches') const u = await getUtils(page) const viewportSize = { width: 1200, height: 500 } await page.setViewportSize(viewportSize) @@ -675,15 +674,16 @@ test.describe('Sketch tests', () => { await click00r(50, 0) await page.waitForTimeout(100) - codeStr += ` |> line(${toSU([50, 0])}, %)` + codeStr += ` |> lineTo(${toSU([50, 0])}, %)` await expect(u.codeLocator).toHaveText(codeStr) await click00r(0, 50) codeStr += ` |> line(${toSU([0, 50])}, %)` await expect(u.codeLocator).toHaveText(codeStr) - await click00r(-50, 0) - codeStr += ` |> line(${toSU([-50, 0])}, %)` + let clickCoords = await click00r(-50, 0) + expect(clickCoords).not.toBeUndefined() + codeStr += ` |> lineTo(${toSU(clickCoords!)}, %)` await expect(u.codeLocator).toHaveText(codeStr) // exit the sketch, reset relative clicker @@ -709,8 +709,10 @@ test.describe('Sketch tests', () => { codeStr += ` |> startProfileAt([2.03, 0], %)` await expect(u.codeLocator).toHaveText(codeStr) + // TODO: I couldn't use `toSU` here because of some rounding error causing + // it to be off by 0.01 await click00r(30, 0) - codeStr += ` |> line([2.04, 0], %)` + codeStr += ` |> lineTo([4.07, 0], %)` await expect(u.codeLocator).toHaveText(codeStr) await click00r(0, 30) diff --git a/e2e/playwright/test-network-and-connection-issues.spec.ts b/e2e/playwright/test-network-and-connection-issues.spec.ts index 583a7f954..d09571446 100644 --- a/e2e/playwright/test-network-and-connection-issues.spec.ts +++ b/e2e/playwright/test-network-and-connection-issues.spec.ts @@ -219,7 +219,7 @@ test.describe('Test network and connection issues', () => { |> startProfileAt([12.34, -12.34], %) |> line([12.34, 0], %) |> line([-12.34, 12.34], %) - |> line([-12.34, 0], %) + |> lineTo([0, -12.34], %) `) diff --git a/e2e/playwright/test-utils.ts b/e2e/playwright/test-utils.ts index 839fa9dba..8412e1db3 100644 --- a/e2e/playwright/test-utils.ts +++ b/e2e/playwright/test-utils.ts @@ -45,7 +45,9 @@ export const commonPoints = { startAt: '[7.19, -9.7]', num1: 7.25, num2: 14.44, -} + /** The Y-value of a common lineTo move we perform in tests */ + num3: -2.44, +} as const /** A semi-reliable color to check the default XZ plane on * in dark mode in the default camera position @@ -118,15 +120,32 @@ async function waitForDefaultPlanesToBeVisible(page: Page) { ) } -async function openPane(page: Page, testId: string) { - const locator = page.getByTestId(testId) - await expect(locator).toBeVisible() - const isOpen = (await locator?.getAttribute('aria-pressed')) === 'true' +export async function checkIfPaneIsOpen(page: Page, testId: string) { + const paneButtonLocator = page.getByTestId(testId) + await expect(paneButtonLocator).toBeVisible() + return (await paneButtonLocator?.getAttribute('aria-pressed')) === 'true' +} + +export async function openPane(page: Page, testId: string) { + const paneButtonLocator = page.getByTestId(testId) + await expect(paneButtonLocator).toBeVisible() + const isOpen = await checkIfPaneIsOpen(page, testId) if (!isOpen) { - await locator.click() - await expect(locator).toHaveAttribute('aria-pressed', 'true') + await paneButtonLocator.click() } + await expect(paneButtonLocator).toHaveAttribute('aria-pressed', 'true') +} + +export async function closePane(page: Page, testId: string) { + const paneButtonLocator = page.getByTestId(testId) + await expect(paneButtonLocator).toBeVisible() + const isOpen = await checkIfPaneIsOpen(page, testId) + + if (isOpen) { + await paneButtonLocator.click() + } + await expect(paneButtonLocator).toHaveAttribute('aria-pressed', 'false') } async function openKclCodePanel(page: Page) { diff --git a/e2e/playwright/testing-selections.spec.ts b/e2e/playwright/testing-selections.spec.ts index c162f910f..986fcb775 100644 --- a/e2e/playwright/testing-selections.spec.ts +++ b/e2e/playwright/testing-selections.spec.ts @@ -96,7 +96,7 @@ test.describe('Testing selections', () => { |> startProfileAt(${commonPoints.startAt}, %) |> line([${commonPoints.num1}, 0], %) |> line([0, ${commonPoints.num1 + 0.01}], %) - |> line([-${commonPoints.num2}, 0], %)`) + |> lineTo([0, ${commonPoints.num3}], %)`) // deselect line tool await page.getByRole('button', { name: 'line Line', exact: true }).click() @@ -157,7 +157,9 @@ test.describe('Testing selections', () => { await emptySpaceClick() // check the same selection again by putting cursor in code first then selecting axis - await page.getByText(` |> line([-${commonPoints.num2}, 0], %)`).click() + await page + .getByText(` |> lineTo([0, ${commonPoints.num3}], %)`) + .click() await page.keyboard.down('Shift') await constrainButton.click() await expect(absYButton).toBeDisabled() @@ -180,7 +182,9 @@ test.describe('Testing selections', () => { process.platform === 'linux' ? 'Control' : 'Meta' ) await page.waitForTimeout(100) - await page.getByText(` |> line([-${commonPoints.num2}, 0], %)`).click() + await page + .getByText(` |> lineTo([0, ${commonPoints.num3}], %)`) + .click() await expect(page.locator('.cm-cursor')).toHaveCount(2) await page.waitForTimeout(500) diff --git a/src/clientSideScene/sceneEntities.ts b/src/clientSideScene/sceneEntities.ts index 788b2fe0f..17225d083 100644 --- a/src/clientSideScene/sceneEntities.ts +++ b/src/clientSideScene/sceneEntities.ts @@ -19,6 +19,8 @@ import { import { ARROWHEAD, AXIS_GROUP, + DRAFT_POINT, + DRAFT_POINT_GROUP, getSceneScale, INTERSECTION_PLANE_LAYER, OnClickCallbackArgs, @@ -53,7 +55,7 @@ import { editorManager, } from 'lib/singletons' import { getNodeFromPath, getNodePathFromSourceRange } from 'lang/queryAst' -import { executeAst } from 'lang/langHelpers' +import { executeAst, ToolTip } from 'lang/langHelpers' import { createProfileStartHandle, SegmentUtils, @@ -314,6 +316,27 @@ export class SceneEntities { const intersectionPlane = this.scene.getObjectByName(RAYCASTABLE_PLANE) if (intersectionPlane) this.scene.remove(intersectionPlane) } + getDraftPoint() { + return this.scene.getObjectByName(DRAFT_POINT) + } + createDraftPoint({ point, group }: { point: Vector2; group: Group }) { + const dummy = new Mesh() + dummy.position.set(0, 0, 0) + const scale = sceneInfra.getClientSceneScaleFactor(dummy) + + const draftPoint = createProfileStartHandle({ + isDraft: true, + from: [point.x, point.y], + scale, + theme: sceneInfra._theme, + }) + draftPoint.layers.set(SKETCH_LAYER) + group.add(draftPoint) + } + removeDraftPoint() { + const draftPoint = this.getDraftPoint() + if (draftPoint) draftPoint.removeFromParent() + } setupNoPointsListener({ sketchDetails, @@ -322,22 +345,78 @@ export class SceneEntities { sketchDetails: SketchDetails afterClick: (args: OnClickCallbackArgs) => void }) { - // Create a THREEjs plane to raycast clicks onto + // TODO: Consolidate shared logic between this and setupSketch + // Which should just fire when the sketch mode is entered, + // instead of in these two separate XState states. this.createIntersectionPlane() + const draftPointGroup = new Group() + draftPointGroup.name = DRAFT_POINT_GROUP + sketchDetails.origin && + draftPointGroup.position.set(...sketchDetails.origin) + if (!(sketchDetails.yAxis && sketchDetails)) { + console.error('No sketch quaternion or sketch details found') + return + } + this.currentSketchQuaternion = quaternionFromUpNForward( + new Vector3(...sketchDetails.yAxis), + new Vector3(...sketchDetails.zAxis) + ) + draftPointGroup.setRotationFromQuaternion(this.currentSketchQuaternion) + this.scene.add(draftPointGroup) + const quaternion = quaternionFromUpNForward( new Vector3(...sketchDetails.yAxis), new Vector3(...sketchDetails.zAxis) ) // Position the click raycast plane - if (this.intersectionPlane) { - this.intersectionPlane.setRotationFromQuaternion(quaternion) - this.intersectionPlane.position.copy( - new Vector3(...(sketchDetails?.origin || [0, 0, 0])) - ) - } + this.intersectionPlane!.setRotationFromQuaternion(quaternion) + this.intersectionPlane!.position.copy( + new Vector3(...(sketchDetails?.origin || [0, 0, 0])) + ) sceneInfra.setCallbacks({ + onMove: (args) => { + if (!args.intersects.length) return + const axisIntersection = args.intersects.find( + (sceneObject) => + sceneObject.object.name === X_AXIS || + sceneObject.object.name === Y_AXIS + ) + if (!axisIntersection) return + const { intersectionPoint } = args + // We're hovering over an axis, so we should show a draft point + const snappedPoint = intersectionPoint.twoD.clone() + if (axisIntersection.object.name === X_AXIS) { + snappedPoint.setComponent(1, 0) + } else { + snappedPoint.setComponent(0, 0) + } + // Either create a new one or update the existing one + const draftPoint = this.getDraftPoint() + + if (!draftPoint) { + this.createDraftPoint({ + point: snappedPoint, + group: draftPointGroup, + }) + } else { + // Ignore if there are huge jumps in the mouse position, + // that is likely a strange behavior + if ( + draftPoint.position.distanceTo( + new Vector3(snappedPoint.x, snappedPoint.y, 0) + ) > 100 + ) { + return + } + draftPoint.position.set(snappedPoint.x, snappedPoint.y, 0) + } + }, + onMouseLeave: () => { + this.removeDraftPoint() + }, onClick: async (args) => { + this.removeDraftPoint() if (!args) return // If there is a valid camera interaction that matches, do that instead const interaction = sceneInfra.camControls.getInteractionType( @@ -347,10 +426,25 @@ export class SceneEntities { if (args.mouseEvent.which !== 1) return const { intersectionPoint } = args if (!intersectionPoint?.twoD || !sketchDetails?.sketchPathToNode) return + + // Snap to either or both axes + // if the click intersects their meshes + const yAxisIntersection = args.intersects.find( + (sceneObject) => sceneObject.object.name === Y_AXIS + ) + const xAxisIntersection = args.intersects.find( + (sceneObject) => sceneObject.object.name === X_AXIS + ) + + const snappedClickPoint = { + x: yAxisIntersection ? 0 : intersectionPoint.twoD.x, + y: xAxisIntersection ? 0 : intersectionPoint.twoD.y, + } + const addStartProfileAtRes = addStartProfileAt( kclManager.ast, sketchDetails.sketchPathToNode, - [intersectionPoint.twoD.x, intersectionPoint.twoD.y] + [snappedClickPoint.x, snappedClickPoint.y] ) if (trap(addStartProfileAtRes)) return @@ -358,6 +452,7 @@ export class SceneEntities { await kclManager.updateAst(modifiedAst, false) this.removeIntersectionPlane() + this.scene.remove(draftPointGroup) // Now perform the caller-specified action afterClick(args) @@ -430,12 +525,7 @@ export class SceneEntities { const dummy = new Mesh() // TODO: When we actually have sketch positions and rotations we can use them here. dummy.position.set(0, 0, 0) - const orthoFactor = orthoScale(sceneInfra.camControls.camera) - const factor = - (sceneInfra.camControls.camera instanceof OrthographicCamera - ? orthoFactor - : perspScale(sceneInfra.camControls.camera, dummy)) / - sceneInfra._baseUnitMultiplier + const scale = sceneInfra.getClientSceneScaleFactor(dummy) const segPathToNode = getNodePathFromSourceRange( maybeModdedAst, @@ -446,8 +536,9 @@ export class SceneEntities { from: sketch.start.from, id: sketch.start.__geoMeta.id, pathToNode: segPathToNode, - scale: factor, + scale, theme: sceneInfra._theme, + isDraft: false, }) _profileStart.layers.set(SKETCH_LAYER) _profileStart.traverse((child) => { @@ -523,7 +614,7 @@ export class SceneEntities { id: segment.__geoMeta.id, pathToNode: segPathToNode, isDraftSegment, - scale: factor, + scale, texture: sceneInfra.extraSegmentTexture, theme: sceneInfra._theme, isSelected, @@ -660,12 +751,14 @@ export class SceneEntities { const { intersectionPoint } = args let intersection2d = intersectionPoint?.twoD - const profileStart = args.intersects + const intersectsProfileStart = args.intersects .map(({ object }) => getParentGroup(object, [PROFILE_START])) .find((a) => a?.name === PROFILE_START) let modifiedAst - if (profileStart) { + + // Snapping logic for the profile start handle + if (intersectsProfileStart) { const lastSegment = sketch.paths.slice(-1)[0] modifiedAst = addCallExpressionsToPipe({ node: kclManager.ast, @@ -698,19 +791,39 @@ export class SceneEntities { }) if (trap(modifiedAst)) return Promise.reject(modifiedAst) } else if (intersection2d) { + const intersectsYAxis = args.intersects.find( + (sceneObject) => sceneObject.object.name === Y_AXIS + ) + const intersectsXAxis = args.intersects.find( + (sceneObject) => sceneObject.object.name === X_AXIS + ) + const lastSegment = sketch.paths.slice(-1)[0] + const snappedPoint = { + x: intersectsYAxis ? 0 : intersection2d.x, + y: intersectsXAxis ? 0 : intersection2d.y, + } + + let resolvedFunctionName: ToolTip = 'line' + + // This might need to become its own function if we want more + // case-based logic for different segment types + if (lastSegment.type === 'TangentialArcTo') { + resolvedFunctionName = 'tangentialArcTo' + } else if (snappedPoint.x === 0 || snappedPoint.y === 0) { + // We consider a point placed on axes or origin to be absolute + resolvedFunctionName = 'lineTo' + } + const tmp = addNewSketchLn({ node: kclManager.ast, programMemory: kclManager.programMemory, input: { type: 'straight-segment', from: [lastSegment.to[0], lastSegment.to[1]], - to: [intersection2d.x, intersection2d.y], + to: [snappedPoint.x, snappedPoint.y], }, - fnName: - lastSegment.type === 'TangentialArcTo' - ? 'tangentialArcTo' - : 'line', + fnName: resolvedFunctionName, pathToNode: sketchPathToNode, }) if (trap(tmp)) return Promise.reject(tmp) @@ -722,7 +835,7 @@ export class SceneEntities { } await kclManager.executeAstMock(modifiedAst) - if (profileStart) { + if (intersectsProfileStart) { sceneInfra.modelingSend({ type: 'CancelSketch' }) } else { await this.setUpDraftSegment( @@ -1229,15 +1342,30 @@ export class SceneEntities { variableDeclarationName: string } }) { - const profileStart = + const intersectsProfileStart = draftInfo && intersects .map(({ object }) => getParentGroup(object, [PROFILE_START])) .find((a) => a?.name === PROFILE_START) - const intersection2d = profileStart - ? new Vector2(profileStart.position.x, profileStart.position.y) + const intersection2d = intersectsProfileStart + ? new Vector2( + intersectsProfileStart.position.x, + intersectsProfileStart.position.y + ) : _intersection2d + const intersectsYAxis = intersects.find( + (sceneObject) => sceneObject.object.name === Y_AXIS + ) + const intersectsXAxis = intersects.find( + (sceneObject) => sceneObject.object.name === X_AXIS + ) + + const snappedPoint = new Vector2( + intersectsYAxis ? 0 : intersection2d.x, + intersectsXAxis ? 0 : intersection2d.y + ) + const group = getParentGroup(object, SEGMENT_BODIES_PLUS_PROFILE_START) const subGroup = getParentGroup(object, [ARROWHEAD, CIRCLE_CENTER_HANDLE]) if (!group) return @@ -1257,7 +1385,7 @@ export class SceneEntities { group.userData.from[0], group.userData.from[1], ] - const dragTo: [number, number] = [intersection2d.x, intersection2d.y] + const dragTo: [number, number] = [snappedPoint.x, snappedPoint.y] let modifiedAst = draftInfo ? draftInfo.truncatedAst : { ...kclManager.ast } const _node = getNodeFromPath>( diff --git a/src/clientSideScene/sceneInfra.ts b/src/clientSideScene/sceneInfra.ts index 5614ab77c..3959d239b 100644 --- a/src/clientSideScene/sceneInfra.ts +++ b/src/clientSideScene/sceneInfra.ts @@ -30,6 +30,7 @@ import { MouseState, SegmentOverlayPayload } from 'machines/modelingMachine' import { getAngle, throttle } from 'lib/utils' import { Themes } from 'lib/theme' import { CSS2DRenderer } from 'three/examples/jsm/renderers/CSS2DRenderer' +import { orthoScale, perspScale } from './helpers' type SendType = ReturnType['send'] @@ -49,6 +50,10 @@ export const RAYCASTABLE_PLANE = 'raycastable-plane' export const X_AXIS = 'xAxis' export const Y_AXIS = 'yAxis' +/** the THREEjs representation of the group surrounding a "snapped" point that is not yet placed */ +export const DRAFT_POINT_GROUP = 'draft-point-group' +/** the THREEjs representation of a "snapped" point that is not yet placed */ +export const DRAFT_POINT = 'draft-point' export const AXIS_GROUP = 'axisGroup' export const SKETCH_GROUP_SEGMENTS = 'sketch-group-segments' export const ARROWHEAD = 'arrowhead' @@ -60,6 +65,11 @@ export interface OnMouseEnterLeaveArgs { selected: Object3D dragSelected?: Object3D mouseEvent: MouseEvent + /** The intersection of the mouse with the THREEjs raycast plane */ + intersectionPoint?: { + twoD?: Vector2 + threeD?: Vector3 + } } interface OnDragCallbackArgs extends OnMouseEnterLeaveArgs { @@ -348,29 +358,42 @@ export class SceneInfra { window.removeEventListener('resize', this.onWindowResize) // Dispose of any other resources like geometries, materials, textures } + getClientSceneScaleFactor(meshOrGroup: Mesh | Group) { + const orthoFactor = orthoScale(this.camControls.camera) + const factor = + (this.camControls.camera instanceof OrthographicCamera + ? orthoFactor + : perspScale(this.camControls.camera, meshOrGroup)) / + this._baseUnitMultiplier + return factor + } getPlaneIntersectPoint = (): { twoD?: Vector2 threeD?: Vector3 intersection: Intersection> } | null => { + // Get the orientations from the camera and mouse position this.planeRaycaster.setFromCamera( this.currentMouseVector, this.camControls.camera ) + // Get the intersection of the ray with the default planes const planeIntersects = this.planeRaycaster.intersectObjects( this.scene.children, true ) - const recastablePlaneIntersect = planeIntersects.find( + if (!planeIntersects.length) return null + + // Find the intersection with the raycastable (or sketch) plane + const raycastablePlaneIntersection = planeIntersects.find( (intersect) => intersect.object.name === RAYCASTABLE_PLANE ) - if (!planeIntersects.length) return null - if (!recastablePlaneIntersect) return { intersection: planeIntersects[0] } - const planePosition = planeIntersects[0].object.position - const inversePlaneQuaternion = planeIntersects[0].object.quaternion - .clone() - .invert() - const intersectPoint = planeIntersects[0].point + if (!raycastablePlaneIntersection) + return { intersection: planeIntersects[0] } + const planePosition = raycastablePlaneIntersection.object.position + const inversePlaneQuaternion = + raycastablePlaneIntersection.object.quaternion.clone().invert() + const intersectPoint = raycastablePlaneIntersection.point let transformedPoint = intersectPoint.clone() if (transformedPoint) { transformedPoint.applyQuaternion(inversePlaneQuaternion) @@ -447,18 +470,26 @@ export class SceneInfra { if (intersects[0]) { const firstIntersectObject = intersects[0].object + const planeIntersectPoint = this.getPlaneIntersectPoint() + const intersectionPoint = { + twoD: planeIntersectPoint?.twoD, + threeD: planeIntersectPoint?.threeD, + } + if (this.hoveredObject !== firstIntersectObject) { const hoveredObj = this.hoveredObject this.hoveredObject = null await this.onMouseLeave({ selected: hoveredObj, mouseEvent: mouseEvent, + intersectionPoint, }) this.hoveredObject = firstIntersectObject await this.onMouseEnter({ selected: this.hoveredObject, dragSelected: this.selected?.object, mouseEvent: mouseEvent, + intersectionPoint, }) if (!this.selected) this.updateMouseState({ diff --git a/src/clientSideScene/segments.ts b/src/clientSideScene/segments.ts index 0758de58e..f2e47e09a 100644 --- a/src/clientSideScene/segments.ts +++ b/src/clientSideScene/segments.ts @@ -45,6 +45,7 @@ import { import { getTangentPointFromPreviousArc } from 'lib/utils2d' import { ARROWHEAD, + DRAFT_POINT, SceneInfra, SEGMENT_LENGTH_LABEL, SEGMENT_LENGTH_LABEL_OFFSET_PX, @@ -686,19 +687,20 @@ class CircleSegment implements SegmentUtils { export function createProfileStartHandle({ from, - id, - pathToNode, + isDraft = false, scale = 1, theme, isSelected, + ...rest }: { from: Coords2d - id: string - pathToNode: PathToNode scale?: number theme: Themes isSelected?: boolean -}) { +} & ( + | { isDraft: true } + | { isDraft: false; id: string; pathToNode: PathToNode } +)) { const group = new Group() const geometry = new BoxGeometry(12, 12, 12) // in pixels scaled later @@ -711,13 +713,12 @@ export function createProfileStartHandle({ group.userData = { type: PROFILE_START, - id, from, - pathToNode, isSelected, baseColor, + ...rest, } - group.name = PROFILE_START + group.name = isDraft ? DRAFT_POINT : PROFILE_START group.position.set(from[0], from[1], 0) group.scale.set(scale, scale, scale) return group diff --git a/src/lang/std/sketchcombos.ts b/src/lang/std/sketchcombos.ts index 21ec43b78..3486502a1 100644 --- a/src/lang/std/sketchcombos.ts +++ b/src/lang/std/sketchcombos.ts @@ -684,6 +684,14 @@ const transformMap: TransformMap = { tag ), }, + xAbs: { + tooltip: 'lineTo', + createNode: setAbsDistanceCreateNode('x'), + }, + yAbs: { + tooltip: 'lineTo', + createNode: setAbsDistanceCreateNode('y'), + }, }, xAbsolute: { equalLength: {