diff --git a/e2e/playwright/flow-tests.spec.ts b/e2e/playwright/flow-tests.spec.ts index d81fc0b4c..28af93ea4 100644 --- a/e2e/playwright/flow-tests.spec.ts +++ b/e2e/playwright/flow-tests.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from '@playwright/test' +import { test, expect, Page } from '@playwright/test' import { makeTemplate, getUtils } from './test-utils' import waitOn from 'wait-on' import { roundOff, uuidv4 } from 'lib/utils' @@ -12,6 +12,7 @@ import { TEST_SETTINGS_ONBOARDING_START, } from './storageStates' import * as TOML from '@iarna/toml' +import { LineInputsType } from 'lang/std/sketchcombos' import { Coords2d } from 'lang/std/sketch' import { KCL_DEFAULT_LENGTH } from 'lib/constants' import { EngineCommand } from 'lang/std/engineConnection' @@ -1127,7 +1128,7 @@ test('Selections work on fresh and edited sketch', async ({ page }) => { const xAxisClick = () => page.mouse.click(700, 253).then(() => page.waitForTimeout(100)) const emptySpaceClick = () => - page.mouse.click(728, 343).then(() => page.waitForTimeout(100)) + page.mouse.click(700, 343).then(() => page.waitForTimeout(100)) const topHorzSegmentClick = () => page.mouse.click(709, 290).then(() => page.waitForTimeout(100)) const bottomHorzSegmentClick = () => @@ -1228,6 +1229,7 @@ test('Selections work on fresh and edited sketch', async ({ page }) => { await page.keyboard.down('Shift') await page.waitForTimeout(100) await topHorzSegmentClick() + await page.waitForTimeout(100) await page.keyboard.up('Shift') await constrainButton.click() @@ -2047,8 +2049,6 @@ test('Can edit segments by dragging their handles', async ({ page }) => { await page.waitForTimeout(100) const startPX = [665, 458] - const lineEndPX = [842, 458] - const arcEndPX = [971, 342] const dragPX = 30 @@ -2060,6 +2060,8 @@ test('Can edit segments by dragging their handles', async ({ page }) => { const step5 = { steps: 5 } + await expect(page.getByTestId('segment-overlay')).toHaveCount(2) + // drag startProfieAt handle await page.mouse.move(startPX[0], startPX[1]) await page.mouse.down() @@ -2070,22 +2072,22 @@ test('Can edit segments by dragging their handles', async ({ page }) => { prevContent = await page.locator('.cm-content').innerText() // drag line handle - await page.mouse.move(lineEndPX[0] + dragPX, lineEndPX[1] - dragPX) + await page.waitForTimeout(100) + + const lineEnd = await u.getBoundingBox('[data-overlay-index="0"]') + await page.mouse.move(lineEnd.x - 5, lineEnd.y) await page.mouse.down() - await page.mouse.move( - lineEndPX[0] + dragPX * 2, - lineEndPX[1] - dragPX * 2, - step5 - ) + await page.mouse.move(lineEnd.x + dragPX, lineEnd.y - dragPX, step5) await page.mouse.up() await page.waitForTimeout(100) await expect(page.locator('.cm-content')).not.toHaveText(prevContent) prevContent = await page.locator('.cm-content').innerText() // drag tangentialArcTo handle - await page.mouse.move(arcEndPX[0], arcEndPX[1]) + const tangentEnd = await u.getBoundingBox('[data-overlay-index="1"]') + await page.mouse.move(tangentEnd.x, tangentEnd.y - 5) await page.mouse.down() - await page.mouse.move(arcEndPX[0] + dragPX, arcEndPX[1] - dragPX, step5) + await page.mouse.move(tangentEnd.x + dragPX, tangentEnd.y - dragPX, step5) await page.mouse.up() await page.waitForTimeout(100) await expect(page.locator('.cm-content')).not.toHaveText(prevContent) @@ -2094,8 +2096,8 @@ test('Can edit segments by dragging their handles', async ({ page }) => { await expect(page.locator('.cm-content')) .toHaveText(`const part001 = startSketchOn('XZ') |> startProfileAt([6.44, -12.07], %) - |> line([14.04, 2.03], %) - |> tangentialArcTo([27.19, -4.2], %)`) + |> line([14.72, 1.97], %) + |> tangentialArcTo([26.92, -3.32], %)`) }) const doSnapAtDifferentScales = async ( @@ -2424,6 +2426,997 @@ test('Extrude from command bar selects extrude line after', async ({ ) }) +test.describe('Testing segment overlays', () => { + test.describe('Hover over a segment should show its overlay, hovering over the input overlays should show its popover, clicking the input overlay should constrain/unconstrain it:\nfor the following segments', () => { + /** + * Clicks on an constrained element + * @param {Page} page - The page to perform the action on + * @param {Object} options - The options for the action + * @param {Object} options.hoverPos - The position to hover over + * @param {Object} options.constraintType - The type of constraint + * @param {number} options.ang - The angle + * @param {number} options.steps - The number of steps to perform + */ + const _clickConstrained = + (page: Page) => + async ({ + hoverPos, + constraintType, + expectBeforeUnconstrained, + expectAfterUnconstrained, + expectFinal, + ang = 45, + steps = 6, + }: { + hoverPos: { x: number; y: number } + constraintType: + | 'horizontal' + | 'vertical' + | 'tangentialWithPrevious' + | LineInputsType + expectBeforeUnconstrained: string + expectAfterUnconstrained: string + expectFinal: string + ang?: number + steps?: number + }) => { + await expect(page.getByText('Added variable')).not.toBeVisible() + const [x, y] = [ + Math.cos((ang * Math.PI) / 180) * 45, + Math.sin((ang * Math.PI) / 180) * 45, + ] + + await page.mouse.move(hoverPos.x + x, hoverPos.y + y) + await page.mouse.move(hoverPos.x, hoverPos.y, { steps }) + await expect(page.locator('.cm-content')).toContainText( + expectBeforeUnconstrained + ) + const constrainedLocator = page.locator( + `[data-constraint-type="${constraintType}"][data-is-constrained="true"]` + ) + await expect(constrainedLocator).toBeVisible() + await constrainedLocator.hover() + await expect( + await page.getByTestId('constraint-symbol-popover').count() + ).toBeGreaterThan(0) + await constrainedLocator.click() + await expect(page.locator('.cm-content')).toContainText( + expectAfterUnconstrained + ) + const unconstrainedLocator = page.locator( + `[data-constraint-type="${constraintType}"][data-is-constrained="false"]` + ) + await expect(unconstrainedLocator).toBeVisible() + await unconstrainedLocator.hover() + await expect( + await page.getByTestId('constraint-symbol-popover').count() + ).toBeGreaterThan(0) + await unconstrainedLocator.click() + await page.getByText('Add variable').click() + await expect(page.locator('.cm-content')).toContainText(expectFinal) + } + + /** + * Clicks on an unconstrained element + * @param {Page} page - The page to perform the action on + * @param {Object} options - The options for the action + * @param {Object} options.hoverPos - The position to hover over + * @param {Object} options.constraintType - The type of constraint + * @param {number} options.ang - The angle + * @param {number} options.steps - The number of steps to perform + */ + const _clickUnconstrained = + (page: Page) => + async ({ + hoverPos, + constraintType, + expectBeforeUnconstrained, + expectAfterUnconstrained, + expectFinal, + ang = 45, + steps = 5, + }: { + hoverPos: { x: number; y: number } + constraintType: + | 'horizontal' + | 'vertical' + | 'tangentialWithPrevious' + | LineInputsType + expectBeforeUnconstrained: string + expectAfterUnconstrained: string + expectFinal: string + ang?: number + steps?: number + }) => { + const [x, y] = [ + Math.cos((ang * Math.PI) / 180) * 45, + Math.sin((ang * Math.PI) / 180) * 45, + ] + await page.mouse.move(hoverPos.x + x, hoverPos.y + y) + + await expect(page.getByText('Added variable')).not.toBeVisible() + await page.mouse.move(hoverPos.x, hoverPos.y, { steps }) + await expect(page.locator('.cm-content')).toContainText( + expectBeforeUnconstrained + ) + const unconstrainedLocator = page.locator( + `[data-constraint-type="${constraintType}"][data-is-constrained="false"]` + ) + await expect(unconstrainedLocator).toBeVisible() + await unconstrainedLocator.hover() + await expect( + await page.getByTestId('constraint-symbol-popover').count() + ).toBeGreaterThan(0) + await unconstrainedLocator.click() + await page.getByText('Add variable').click() + await expect(page.locator('.cm-content')).toContainText( + expectAfterUnconstrained + ) + await expect(page.getByText('Added variable')).not.toBeVisible() + await page.mouse.move(hoverPos.x, hoverPos.y, { steps }) + const constrainedLocator = page.locator( + `[data-constraint-type="${constraintType}"][data-is-constrained="true"]` + ) + await expect(constrainedLocator).toBeVisible() + await constrainedLocator.hover() + await expect( + await page.getByTestId('constraint-symbol-popover').count() + ).toBeGreaterThan(0) + await constrainedLocator.click() + await expect(page.locator('.cm-content')).toContainText(expectFinal) + } + test('for segments [line, angledLine, lineTo, xLineTo]', async ({ + page, + }) => { + await page.addInitScript(async () => { + localStorage.setItem( + 'persistCode', + `const part001 = startSketchOn('XZ') + |> startProfileAt([0, 0], %) + |> line([0.5, -14 + 0], %) + |> angledLine({ angle: 3 + 0, length: 32 + 0 }, %) + |> lineTo([33, 11.5 + 0], %) + |> xLineTo(9 - 5, %) + |> yLineTo(-10.77, %, 'a') + |> xLine(26.04, %) + |> yLine(21.14 + 0, %) + |> angledLineOfXLength({ angle: 181 + 0, length: 23.14 }, %) + |> angledLineOfYLength({ angle: -91, length: 19 + 0 }, %) + |> angledLineToX({ angle: 3 + 0, to: 26 }, %) + |> angledLineToY({ angle: 89, to: 9.14 + 0 }, %) + |> angledLineThatIntersects({ + angle: 4.14, + intersectTag: 'a', + offset: 9 + }, %) + |> tangentialArcTo([3.14 + 13, 3.14], %) + ` + ) + localStorage.setItem('playwright', 'true') + }) + const u = await getUtils(page) + await page.setViewportSize({ width: 1200, height: 500 }) + await page.goto('/') + await u.waitForAuthSkipAppStart() + + await page.waitForTimeout(1000) + await u.openAndClearDebugPanel() + await u.sendCustomCmd({ + type: 'modeling_cmd_req', + cmd_id: uuidv4(), + cmd: { + type: 'default_camera_look_at', + vantage: { x: 0, y: -1250, z: 580 }, + center: { x: 0, y: 0, z: 0 }, + up: { x: 0, y: 0, z: 1 }, + }, + }) + await page.waitForTimeout(100) + await u.sendCustomCmd({ + type: 'modeling_cmd_req', + cmd_id: uuidv4(), + cmd: { + type: 'default_camera_get_settings', + }, + }) + await page.waitForTimeout(100) + + await page.getByText('xLineTo(9 - 5, %)').click() + await page.waitForTimeout(100) + await page.getByRole('button', { name: 'Edit Sketch' }).click() + await page.waitForTimeout(500) + + await expect(page.getByTestId('segment-overlay')).toHaveCount(13) + + const clickUnconstrained = _clickUnconstrained(page) + const clickConstrained = _clickConstrained(page) + + const line = await u.getBoundingBox(`[data-overlay-index="${0}"]`) + console.log('line1') + await clickConstrained({ + hoverPos: { x: line.x, y: line.y - 10 }, + constraintType: 'yRelative', + expectBeforeUnconstrained: '|> line([0.5, -14 + 0], %)', + expectAfterUnconstrained: '|> line([0.5, -14], %)', + expectFinal: '|> line([0.5, yRel001], %)', + ang: 135, + }) + console.log('line2') + await clickUnconstrained({ + hoverPos: { x: line.x, y: line.y - 10 }, + constraintType: 'xRelative', + expectBeforeUnconstrained: '|> line([0.5, yRel001], %)', + expectAfterUnconstrained: 'line([xRel001, yRel001], %)', + expectFinal: '|> line([0.5, yRel001], %)', + ang: -45, + }) + + const angledLine = await u.getBoundingBox(`[data-overlay-index="1"]`) + console.log('angledLine1') + await clickConstrained({ + hoverPos: { x: angledLine.x - 10, y: angledLine.y }, + constraintType: 'angle', + expectBeforeUnconstrained: + 'angledLine({ angle: 3 + 0, length: 32 + 0 }, %)', + expectAfterUnconstrained: 'angledLine({ angle: 3, length: 32 + 0 }, %)', + expectFinal: 'angledLine({ angle: angle001, length: 32 + 0 }, %)', + }) + console.log('angledLine2') + await clickConstrained({ + hoverPos: { x: angledLine.x - 10, y: angledLine.y }, + constraintType: 'length', + expectBeforeUnconstrained: + 'angledLine({ angle: angle001, length: 32 + 0 }, %)', + expectAfterUnconstrained: + 'angledLine({ angle: angle001, length: 32 }, %)', + expectFinal: 'angledLine({ angle: angle001, length: len001 }, %)', + }) + + await page.mouse.move(700, 250) + for (let i = 0; i < 5; i++) { + await page.mouse.wheel(0, 100) + await page.waitForTimeout(25) + } + await page.waitForTimeout(200) + + const lineTo = await u.getBoundingBox(`[data-overlay-index="2"]`) + console.log('lineTo1') + await clickConstrained({ + hoverPos: { x: lineTo.x, y: lineTo.y + 21 }, + constraintType: 'yAbsolute', + expectBeforeUnconstrained: 'lineTo([33, 11.5 + 0], %)', + expectAfterUnconstrained: 'lineTo([33, 11.5], %)', + expectFinal: 'lineTo([33, yAbs001], %)', + steps: 8, + ang: 55, + }) + console.log('lineTo2') + await clickUnconstrained({ + hoverPos: { x: lineTo.x, y: lineTo.y + 25 }, + constraintType: 'xAbsolute', + expectBeforeUnconstrained: 'lineTo([33, yAbs001], %)', + expectAfterUnconstrained: 'lineTo([xAbs001, yAbs001], %)', + expectFinal: 'lineTo([33, yAbs001], %)', + steps: 8, + }) + + const xLineTo = await u.getBoundingBox(`[data-overlay-index="3"]`) + console.log('xlineTo1') + await clickConstrained({ + hoverPos: { x: xLineTo.x + 15, y: xLineTo.y }, + constraintType: 'xAbsolute', + expectBeforeUnconstrained: 'xLineTo(9 - 5, %)', + expectAfterUnconstrained: 'xLineTo(4, %)', + expectFinal: 'xLineTo(xAbs002, %)', + ang: -45, + steps: 8, + }) + }) + test('for segments [yLineTo, xLine]', async ({ page }) => { + await page.addInitScript(async () => { + localStorage.setItem( + 'persistCode', + `const yRel001 = -14 +const xRel001 = 0.5 +const angle001 = 3 +const len001 = 32 +const yAbs001 = 11.5 +const xAbs001 = 33 +const xAbs002 = 4 +const part001 = startSketchOn('XZ') + |> startProfileAt([0, 0], %) + |> line([0.5, yRel001], %) + |> angledLine({ angle: angle001, length: len001 }, %) + |> lineTo([33, yAbs001], %) + |> xLineTo(xAbs002, %) + |> yLineTo(-10.77, %, 'a') + |> xLine(26.04, %) + |> yLine(21.14 + 0, %) + |> angledLineOfXLength({ angle: 181 + 0, length: 23.14 }, %) + ` + ) + localStorage.setItem('playwright', 'true') + }) + const u = await getUtils(page) + await page.setViewportSize({ width: 1200, height: 500 }) + await page.goto('/') + await u.waitForAuthSkipAppStart() + + await page.getByText('xLine(26.04, %)').click() + await page.waitForTimeout(100) + await page.getByRole('button', { name: 'Edit Sketch' }).click() + await page.waitForTimeout(500) + + await expect(page.getByTestId('segment-overlay')).toHaveCount(8) + + const clickUnconstrained = _clickUnconstrained(page) + + await page.mouse.move(700, 250) + for (let i = 0; i < 7; i++) { + await page.mouse.wheel(0, 100) + await page.waitForTimeout(25) + } + + await page.waitForTimeout(300) + + const yLineTo = await u.getBoundingBox(`[data-overlay-index="4"]`) + console.log('ylineTo1') + await clickUnconstrained({ + hoverPos: { x: yLineTo.x, y: yLineTo.y - 30 }, + constraintType: 'yAbsolute', + expectBeforeUnconstrained: "yLineTo(-10.77, %, 'a')", + expectAfterUnconstrained: "yLineTo(yAbs002, %, 'a')", + expectFinal: "yLineTo(-10.77, %, 'a')", + }) + + const xLine = await u.getBoundingBox(`[data-overlay-index="5"]`) + console.log('xline') + await clickUnconstrained({ + hoverPos: { x: xLine.x - 25, y: xLine.y }, + constraintType: 'xRelative', + expectBeforeUnconstrained: 'xLine(26.04, %)', + expectAfterUnconstrained: 'xLine(xRel002, %)', + expectFinal: 'xLine(26.04, %)', + steps: 10, + ang: 50, + }) + }) + test('for segments [yLine, angledLineOfXLength, angledLineOfYLength]', async ({ + page, + }) => { + await page.addInitScript(async () => { + localStorage.setItem( + 'persistCode', + `const part001 = startSketchOn('XZ') + |> startProfileAt([0, 0], %) + |> line([0.5, -14 + 0], %) + |> angledLine({ angle: 3 + 0, length: 32 + 0 }, %) + |> lineTo([33, 11.5 + 0], %) + |> xLineTo(9 - 5, %) + |> yLineTo(-10.77, %, 'a') + |> xLine(26.04, %) + |> yLine(21.14 + 0, %) + |> angledLineOfXLength({ angle: 181 + 0, length: 23.14 }, %) + |> angledLineOfYLength({ angle: -91, length: 19 + 0 }, %) + |> angledLineToX({ angle: 3 + 0, to: 26 }, %) + |> angledLineToY({ angle: 89, to: 9.14 + 0 }, %) + |> angledLineThatIntersects({ + angle: 4.14, + intersectTag: 'a', + offset: 9 + }, %) + |> tangentialArcTo([3.14 + 13, 3.14], %) + ` + ) + localStorage.setItem('playwright', 'true') + }) + const u = await getUtils(page) + await page.setViewportSize({ width: 1200, height: 500 }) + await page.goto('/') + await u.waitForAuthSkipAppStart() + + await page.getByText('xLineTo(9 - 5, %)').click() + await page.waitForTimeout(100) + await page.getByRole('button', { name: 'Edit Sketch' }).click() + await page.waitForTimeout(500) + + await expect(page.getByTestId('segment-overlay')).toHaveCount(13) + + const clickUnconstrained = _clickUnconstrained(page) + const clickConstrained = _clickConstrained(page) + + const yLine = await u.getBoundingBox(`[data-overlay-index="6"]`) + console.log('yline1') + await clickConstrained({ + hoverPos: { x: yLine.x, y: yLine.y + 20 }, + constraintType: 'yRelative', + expectBeforeUnconstrained: 'yLine(21.14 + 0, %)', + expectAfterUnconstrained: 'yLine(21.14, %)', + expectFinal: 'yLine(yRel001, %)', + }) + + const angledLineOfXLength = await u.getBoundingBox( + `[data-overlay-index="7"]` + ) + console.log('angledLineOfXLength1') + await clickConstrained({ + hoverPos: { x: angledLineOfXLength.x + 20, y: angledLineOfXLength.y }, + constraintType: 'angle', + expectBeforeUnconstrained: + 'angledLineOfXLength({ angle: 181 + 0, length: 23.14 }, %)', + expectAfterUnconstrained: + 'angledLineOfXLength({ angle: -179, length: 23.14 }, %)', + expectFinal: + 'angledLineOfXLength({ angle: angle001, length: 23.14 }, %)', + }) + console.log('angledLineOfXLength2') + await clickUnconstrained({ + hoverPos: { x: angledLineOfXLength.x + 25, y: angledLineOfXLength.y }, + constraintType: 'xRelative', + expectBeforeUnconstrained: + 'angledLineOfXLength({ angle: angle001, length: 23.14 }, %)', + expectAfterUnconstrained: + 'angledLineOfXLength({ angle: angle001, length: xRel001 }, %)', + expectFinal: + 'angledLineOfXLength({ angle: angle001, length: 23.14 }, %)', + steps: 7, + }) + + const angledLineOfYLength = await u.getBoundingBox( + `[data-overlay-index="8"]` + ) + console.log('angledLineOfYLength1') + await clickUnconstrained({ + hoverPos: { x: angledLineOfYLength.x, y: angledLineOfYLength.y - 20 }, + constraintType: 'angle', + expectBeforeUnconstrained: + 'angledLineOfYLength({ angle: -91, length: 19 + 0 }, %)', + expectAfterUnconstrained: + 'angledLineOfYLength({ angle: angle002, length: 19 + 0 }, %)', + expectFinal: 'angledLineOfYLength({ angle: -91, length: 19 + 0 }, %)', + ang: 135, + steps: 6, + }) + console.log('angledLineOfYLength2') + await clickConstrained({ + hoverPos: { x: angledLineOfYLength.x, y: angledLineOfYLength.y - 20 }, + constraintType: 'yRelative', + expectBeforeUnconstrained: + 'angledLineOfYLength({ angle: -91, length: 19 + 0 }, %)', + expectAfterUnconstrained: + 'angledLineOfYLength({ angle: -91, length: 19 }, %)', + expectFinal: 'angledLineOfYLength({ angle: -91, length: yRel002 }, %)', + ang: -45, + steps: 7, + }) + }) + test('for segments [angledLineToX, angledLineToY, angledLineThatIntersects]', async ({ + page, + }) => { + test.skip(process.platform !== 'darwin', 'too flakey on ubuntu') + await page.addInitScript(async () => { + localStorage.setItem( + 'persistCode', + `const part001 = startSketchOn('XZ') + |> startProfileAt([0, 0], %) + |> line([0.5, -14 + 0], %) + |> angledLine({ angle: 3 + 0, length: 32 + 0 }, %) + |> lineTo([33, 11.5 + 0], %) + |> xLineTo(9 - 5, %) + |> yLineTo(-10.77, %, 'a') + |> xLine(26.04, %) + |> yLine(21.14 + 0, %) + |> angledLineOfXLength({ angle: 181 + 0, length: 23.14 }, %) + |> angledLineOfYLength({ angle: -91, length: 19 + 0 }, %) + |> angledLineToX({ angle: 3 + 0, to: 26 }, %) + |> angledLineToY({ angle: 89, to: 9.14 + 0 }, %) + |> angledLineThatIntersects({ + angle: 4.14, + intersectTag: 'a', + offset: 9 + }, %) + |> tangentialArcTo([3.14 + 13, 1.14], %) + ` + ) + localStorage.setItem('playwright', 'true') + }) + const u = await getUtils(page) + await page.setViewportSize({ width: 1200, height: 500 }) + await page.goto('/') + await u.waitForAuthSkipAppStart() + + await page.getByText('xLineTo(9 - 5, %)').click() + await page.waitForTimeout(100) + await page.getByRole('button', { name: 'Edit Sketch' }).click() + await page.waitForTimeout(500) + + await expect(page.getByTestId('segment-overlay')).toHaveCount(13) + + const clickUnconstrained = _clickUnconstrained(page) + const clickConstrained = _clickConstrained(page) + + const angledLineToX = await u.getBoundingBox(`[data-overlay-index="9"]`) + console.log('angledLineToX') + await clickConstrained({ + hoverPos: { x: angledLineToX.x - 20, y: angledLineToX.y }, + constraintType: 'angle', + expectBeforeUnconstrained: 'angledLineToX({ angle: 3 + 0, to: 26 }, %)', + expectAfterUnconstrained: 'angledLineToX({ angle: 3, to: 26 }, %)', + expectFinal: 'angledLineToX({ angle: angle001, to: 26 }, %)', + }) + console.log('angledLineToX2') + await clickUnconstrained({ + hoverPos: { x: angledLineToX.x - 20, y: angledLineToX.y }, + constraintType: 'xAbsolute', + expectBeforeUnconstrained: + 'angledLineToX({ angle: angle001, to: 26 }, %)', + expectAfterUnconstrained: + 'angledLineToX({ angle: angle001, to: xAbs001 }, %)', + expectFinal: 'angledLineToX({ angle: angle001, to: 26 }, %)', + }) + + const angledLineToY = await u.getBoundingBox(`[data-overlay-index="10"]`) + console.log('angledLineToY') + await clickUnconstrained({ + hoverPos: { x: angledLineToY.x, y: angledLineToY.y + 20 }, + constraintType: 'angle', + expectBeforeUnconstrained: + 'angledLineToY({ angle: 89, to: 9.14 + 0 }, %)', + expectAfterUnconstrained: + 'angledLineToY({ angle: angle002, to: 9.14 + 0 }, %)', + expectFinal: 'angledLineToY({ angle: 89, to: 9.14 + 0 }, %)', + steps: process.platform === 'darwin' ? 8 : 9, + ang: 135, + }) + console.log('angledLineToY2') + await clickConstrained({ + hoverPos: { x: angledLineToY.x, y: angledLineToY.y + 20 }, + constraintType: 'yAbsolute', + expectBeforeUnconstrained: + 'angledLineToY({ angle: 89, to: 9.14 + 0 }, %)', + expectAfterUnconstrained: 'angledLineToY({ angle: 89, to: 9.14 }, %)', + expectFinal: 'angledLineToY({ angle: 89, to: yAbs001 }, %)', + ang: 135, + }) + + const angledLineThatIntersects = await u.getBoundingBox( + `[data-overlay-index="11"]` + ) + console.log('angledLineThatIntersects') + await clickUnconstrained({ + hoverPos: { + x: angledLineThatIntersects.x + 20, + y: angledLineThatIntersects.y, + }, + constraintType: 'angle', + expectBeforeUnconstrained: `angledLineThatIntersects({ + angle: 4.14, + intersectTag: 'a', + offset: 9 + }, %)`, + expectAfterUnconstrained: `angledLineThatIntersects({ + angle: angle003, + intersectTag: 'a', + offset: 9 + }, %)`, + expectFinal: `angledLineThatIntersects({ + angle: -176, + offset: 9, + intersectTag: 'a' + }, %)`, + ang: -45, + }) + console.log('angledLineThatIntersects2') + await clickUnconstrained({ + hoverPos: { + x: angledLineThatIntersects.x + 20, + y: angledLineThatIntersects.y, + }, + constraintType: 'intersectionOffset', + expectBeforeUnconstrained: `angledLineThatIntersects({ + angle: -176, + offset: 9, + intersectTag: 'a' + }, %)`, + expectAfterUnconstrained: `angledLineThatIntersects({ + angle: -176, + offset: perpDist001, + intersectTag: 'a' + }, %)`, + expectFinal: `angledLineThatIntersects({ + angle: -176, + offset: 9, + intersectTag: 'a' + }, %)`, + ang: -25, + }) + }) + test('for segment [tangentialArcTo]', async ({ page }) => { + await page.addInitScript(async () => { + localStorage.setItem( + 'persistCode', + `const part001 = startSketchOn('XZ') + |> startProfileAt([0, 0], %) + |> line([0.5, -14 + 0], %) + |> angledLine({ angle: 3 + 0, length: 32 + 0 }, %) + |> lineTo([33, 11.5 + 0], %) + |> xLineTo(9 - 5, %) + |> yLineTo(-10.77, %, 'a') + |> xLine(26.04, %) + |> yLine(21.14 + 0, %) + |> angledLineOfXLength({ angle: 181 + 0, length: 23.14 }, %) + |> angledLineOfYLength({ angle: -91, length: 19 + 0 }, %) + |> angledLineToX({ angle: 3 + 0, to: 26 }, %) + |> angledLineToY({ angle: 89, to: 9.14 + 0 }, %) + |> angledLineThatIntersects({ + angle: 4.14, + intersectTag: 'a', + offset: 9 + }, %) + |> tangentialArcTo([3.14 + 13, -3.14], %) + ` + ) + localStorage.setItem('playwright', 'true') + }) + const u = await getUtils(page) + await page.setViewportSize({ width: 1200, height: 500 }) + await page.goto('/') + await u.waitForAuthSkipAppStart() + + await page.getByText('xLineTo(9 - 5, %)').click() + await page.waitForTimeout(100) + await page.getByRole('button', { name: 'Edit Sketch' }).click() + await page.waitForTimeout(500) + + await expect(page.getByTestId('segment-overlay')).toHaveCount(13) + + const clickUnconstrained = _clickUnconstrained(page) + const clickConstrained = _clickConstrained(page) + + const tangentialArcTo = await u.getBoundingBox( + `[data-overlay-index="12"]` + ) + console.log('tangentialArcTo') + await clickConstrained({ + hoverPos: { x: tangentialArcTo.x - 10, y: tangentialArcTo.y + 20 }, + constraintType: 'xAbsolute', + expectBeforeUnconstrained: 'tangentialArcTo([3.14 + 13, -3.14], %)', + expectAfterUnconstrained: 'tangentialArcTo([16.14, -3.14], %)', + expectFinal: 'tangentialArcTo([xAbs001, -3.14], %)', + ang: -45, + steps: 6, + }) + console.log('tangentialArcTo2') + await clickUnconstrained({ + hoverPos: { x: tangentialArcTo.x - 10, y: tangentialArcTo.y + 20 }, + constraintType: 'yAbsolute', + expectBeforeUnconstrained: 'tangentialArcTo([xAbs001, -3.14], %)', + expectAfterUnconstrained: 'tangentialArcTo([xAbs001, yAbs001], %)', + expectFinal: 'tangentialArcTo([xAbs001, -3.14], %)', + ang: -135, + steps: 10, + }) + }) + }) + test.describe('Testing deleting a segment', () => { + const _deleteSegmentSequence = + (page: Page) => + async ({ + hoverPos, + codeToBeDeleted, + stdLibFnName, + ang = 45, + steps = 6, + }: { + hoverPos: { x: number; y: number } + codeToBeDeleted: string + stdLibFnName: string + ang?: number + steps?: number + }) => { + await expect(page.getByText('Added variable')).not.toBeVisible() + const [x, y] = [ + Math.cos((ang * Math.PI) / 180) * 45, + Math.sin((ang * Math.PI) / 180) * 45, + ] + + await page.mouse.move(hoverPos.x + x, hoverPos.y + y) + await page.mouse.move(hoverPos.x, hoverPos.y, { steps }) + await expect(page.locator('.cm-content')).toContainText(codeToBeDeleted) + + await page.locator(`[data-stdlib-fn-name="${stdLibFnName}"]`).click() + await page.getByText('Delete Segment').click() + + await expect(page.locator('.cm-content')).not.toContainText( + codeToBeDeleted + ) + } + test('all segment types', async ({ page }) => { + await page.addInitScript(async () => { + localStorage.setItem( + 'persistCode', + `const part001 = startSketchOn('XZ') + |> startProfileAt([0, 0], %) + |> line([0.5, -14 + 0], %) + |> angledLine({ angle: 3 + 0, length: 32 + 0 }, %) + |> lineTo([33, 11.5 + 0], %) + |> xLineTo(9 - 5, %) + |> yLineTo(-10.77, %, 'a') + |> xLine(26.04, %) + |> yLine(21.14 + 0, %) + |> angledLineOfXLength({ angle: 181 + 0, length: 23.14 }, %) + |> angledLineOfYLength({ angle: -91, length: 19 + 0 }, %) + |> angledLineToX({ angle: 3 + 0, to: 26 }, %) + |> angledLineToY({ angle: 89, to: 9.14 + 0 }, %) + |> angledLineThatIntersects({ + angle: 4.14, + intersectTag: 'a', + offset: 9 + }, %) + |> tangentialArcTo([3.14 + 13, 1.14], %) + ` + ) + localStorage.setItem('playwright', 'true') + }) + const u = await getUtils(page) + await page.setViewportSize({ width: 1200, height: 500 }) + await page.goto('/') + await u.waitForAuthSkipAppStart() + + await page.getByText('xLineTo(9 - 5, %)').click() + await page.waitForTimeout(100) + await page.getByRole('button', { name: 'Edit Sketch' }).click() + await page.waitForTimeout(500) + + await expect(page.getByTestId('segment-overlay')).toHaveCount(13) + const deleteSegmentSequence = _deleteSegmentSequence(page) + + let segmentToDelete + + const getOverlayByIndex = (index: number) => + u.getBoundingBox(`[data-overlay-index="${index}"]`) + segmentToDelete = await getOverlayByIndex(12) + await deleteSegmentSequence({ + hoverPos: { x: segmentToDelete.x - 10, y: segmentToDelete.y + 20 }, + codeToBeDeleted: 'tangentialArcTo([3.14 + 13, 1.14], %)', + stdLibFnName: 'tangentialArcTo', + ang: -45, + steps: 6, + }) + + segmentToDelete = await getOverlayByIndex(0) + await deleteSegmentSequence({ + hoverPos: { x: segmentToDelete.x, y: segmentToDelete.y - 20 }, + codeToBeDeleted: 'line([0.5, -14 + 0], %)', + stdLibFnName: 'line', + ang: -45, + }) + + segmentToDelete = await getOverlayByIndex(0) + await deleteSegmentSequence({ + hoverPos: { x: segmentToDelete.x - 20, y: segmentToDelete.y }, + codeToBeDeleted: 'angledLine({ angle: 3 + 0, length: 32 + 0 }, %)', + stdLibFnName: 'angledLine', + ang: 135, + }) + + await page.waitForTimeout(200) + + segmentToDelete = await getOverlayByIndex(9) + await deleteSegmentSequence({ + hoverPos: { x: segmentToDelete.x + 10, y: segmentToDelete.y }, + codeToBeDeleted: `angledLineThatIntersects({ + angle: 4.14, + intersectTag: 'a', + offset: 9 + }, %)`, + stdLibFnName: 'angledLineThatIntersects', + ang: -45, + steps: 7, + }) + + segmentToDelete = await getOverlayByIndex(8) + await deleteSegmentSequence({ + hoverPos: { x: segmentToDelete.x + 10, y: segmentToDelete.y }, + codeToBeDeleted: 'angledLineToY({ angle: 89, to: 9.14 + 0 }, %)', + stdLibFnName: 'angledLineToY', + }) + + segmentToDelete = await getOverlayByIndex(7) + await deleteSegmentSequence({ + hoverPos: { x: segmentToDelete.x - 10, y: segmentToDelete.y }, + codeToBeDeleted: 'angledLineToX({ angle: 3 + 0, to: 26 }, %)', + stdLibFnName: 'angledLineToX', + }) + + segmentToDelete = await getOverlayByIndex(6) + await deleteSegmentSequence({ + hoverPos: { x: segmentToDelete.x, y: segmentToDelete.y - 10 }, + codeToBeDeleted: + 'angledLineOfYLength({ angle: -91, length: 19 + 0 }, %)', + stdLibFnName: 'angledLineOfYLength', + }) + + segmentToDelete = await getOverlayByIndex(5) + await deleteSegmentSequence({ + hoverPos: { x: segmentToDelete.x + 10, y: segmentToDelete.y }, + codeToBeDeleted: + 'angledLineOfXLength({ angle: 181 + 0, length: 23.14 }, %)', + stdLibFnName: 'angledLineOfXLength', + }) + + segmentToDelete = await getOverlayByIndex(4) + await deleteSegmentSequence({ + hoverPos: { x: segmentToDelete.x, y: segmentToDelete.y + 10 }, + codeToBeDeleted: 'yLine(21.14 + 0, %)', + stdLibFnName: 'yLine', + }) + + segmentToDelete = await getOverlayByIndex(3) + await deleteSegmentSequence({ + hoverPos: { x: segmentToDelete.x - 10, y: segmentToDelete.y }, + codeToBeDeleted: 'xLine(26.04, %)', + stdLibFnName: 'xLine', + }) + + segmentToDelete = await getOverlayByIndex(2) + await deleteSegmentSequence({ + hoverPos: { x: segmentToDelete.x, y: segmentToDelete.y - 10 }, + codeToBeDeleted: "yLineTo(-10.77, %, 'a')", + stdLibFnName: 'yLineTo', + }) + + segmentToDelete = await getOverlayByIndex(1) + await deleteSegmentSequence({ + hoverPos: { x: segmentToDelete.x + 10, y: segmentToDelete.y }, + codeToBeDeleted: 'xLineTo(9 - 5, %)', + stdLibFnName: 'xLineTo', + }) + + for (let i = 0; i < 15; i++) { + await page.mouse.wheel(0, 100) + await page.waitForTimeout(25) + } + + await page.waitForTimeout(200) + + segmentToDelete = await getOverlayByIndex(0) + const hoverPos = { x: segmentToDelete.x - 10, y: segmentToDelete.y + 10 } + await expect(page.getByText('Added variable')).not.toBeVisible() + const [x, y] = [ + Math.cos((45 * Math.PI) / 180) * 45, + Math.sin((45 * Math.PI) / 180) * 45, + ] + + await page.mouse.move(hoverPos.x + x, hoverPos.y + y) + await page.mouse.move(hoverPos.x, hoverPos.y, { steps: 5 }) + const codeToBeDeleted = 'lineTo([33, 11.5 + 0], %)' + await expect(page.locator('.cm-content')).toContainText(codeToBeDeleted) + + await page.getByTestId('overlay-menu').click() + await page.getByText('Delete Segment').click() + + await expect(page.locator('.cm-content')).not.toContainText( + codeToBeDeleted + ) + }) + }) + test.describe('Testing delete with dependent segments', () => { + const cases = [ + "line([22, 2], %, 'seg01')", + "angledLine([5, 23.03], %, 'seg01')", + "xLine(23, %, 'seg01')", + "yLine(-8, %, 'seg01')", + "xLineTo(30, %, 'seg01')", + "yLineTo(-4, %, 'seg01')", + "angledLineOfXLength([3, 30], %, 'seg01')", + "angledLineOfXLength({ angle: 3, length: 30 }, %, 'seg01')", + "angledLineOfYLength([3, 1.5], %, 'seg01')", + "angledLineOfYLength({ angle: 3, length: 1.5 }, %, 'seg01')", + "angledLineToX([3, 30], %, 'seg01')", + "angledLineToX({ angle: 3, to: 30 }, %, 'seg01')", + "angledLineToY([3, 7], %, 'seg01')", + "angledLineToY({ angle: 3, to: 7 }, %, 'seg01')", + ] + for (const doesHaveTagOutsideSketch of [true, false]) { + for (const lineOfInterest of cases) { + const isObj = lineOfInterest.includes('{ angle: 3,') + test(`${lineOfInterest.split('(')[0]}${isObj ? '-[obj-input]' : ''}${ + doesHaveTagOutsideSketch ? '-[tagOutsideSketch]' : '' + }`, async ({ page }) => { + await page.addInitScript( + async ({ lineToBeDeleted, extraLine }) => { + localStorage.setItem( + 'persistCode', + `const part001 = startSketchOn('XZ') + |> startProfileAt([5, 6], %) + |> ${lineToBeDeleted} + |> line([-10, -15], %) + |> angledLine([-176, segLen('seg01', %)], %) +${extraLine ? "const myVar = segLen('seg01', part001)" : ''}` + ) + }, + { + lineToBeDeleted: lineOfInterest, + extraLine: doesHaveTagOutsideSketch, + } + ) + const u = await getUtils(page) + await page.setViewportSize({ width: 1200, height: 500 }) + await page.goto('/') + await u.waitForAuthSkipAppStart() + await page.waitForTimeout(300) + + await page.getByText(lineOfInterest).click() + await page.waitForTimeout(100) + await page.getByRole('button', { name: 'Edit Sketch' }).click() + await page.waitForTimeout(500) + + await expect(page.getByTestId('segment-overlay')).toHaveCount(3) + const segmentToDelete = await u.getBoundingBox( + `[data-overlay-index="0"]` + ) + + const isYLine = lineOfInterest.toLowerCase().includes('yline') + const hoverPos = { + x: segmentToDelete.x + (isYLine ? 0 : -20), + y: segmentToDelete.y + (isYLine ? -20 : 0), + } + await expect(page.getByText('Added variable')).not.toBeVisible() + const ang = isYLine ? 45 : -45 + const [x, y] = [ + Math.cos((ang * Math.PI) / 180) * 45, + Math.sin((ang * Math.PI) / 180) * 45, + ] + + await page.mouse.move(hoverPos.x + x, hoverPos.y + y) + await page.mouse.move(hoverPos.x, hoverPos.y, { steps: 5 }) + + await expect(page.locator('.cm-content')).toContainText( + lineOfInterest + ) + + await page.getByTestId('overlay-menu').click() + await page.getByText('Delete Segment').click() + + await page.getByText('Cancel').click() + + await page.mouse.move(hoverPos.x + x, hoverPos.y + y) + await page.mouse.move(hoverPos.x, hoverPos.y, { steps: 5 }) + + await expect(page.locator('.cm-content')).toContainText( + lineOfInterest + ) + + await page.getByTestId('overlay-menu').click() + await page.getByText('Delete Segment').click() + + await page.getByText('Continue and unconstrain').last().click() + + if (doesHaveTagOutsideSketch) { + // eslint-disable-next-line jest/no-conditional-expect + await expect( + page.getByText( + 'Segment tag used outside of current Sketch. Could not delete.' + ) + ).toBeTruthy() + // eslint-disable-next-line jest/no-conditional-expect + await expect(page.locator('.cm-content')).toContainText( + lineOfInterest + ) + } else { + // eslint-disable-next-line jest/no-conditional-expect + await expect(page.locator('.cm-content')).not.toContainText( + lineOfInterest + ) + // eslint-disable-next-line jest/no-conditional-expect + await expect(page.locator('.cm-content')).not.toContainText('seg01') + } + }) + } + } + }) +}) test('First escape in tool pops you out of tool, second exits sketch mode', async ({ page, }) => { 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 1a9fc8f0f..21bc230f6 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-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 e2c6387d6..9e3f23951 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/test-utils.ts b/e2e/playwright/test-utils.ts index ecd0aa277..26336057a 100644 --- a/e2e/playwright/test-utils.ts +++ b/e2e/playwright/test-utils.ts @@ -130,6 +130,11 @@ export async function getUtils(page: Page) { }, waitForCmdReceive: (commandType: string) => waitForCmdReceive(page, commandType), + getBoundingBox: async (locator: string) => + page + .locator(locator) + .boundingBox() + .then((box) => ({ x: box?.x || 0, y: box?.y || 0 })), doAndWaitForCmd: async ( fn: () => Promise, commandType: string, diff --git a/src/Toolbar.tsx b/src/Toolbar.tsx index d13758d9b..3cf05d95c 100644 --- a/src/Toolbar.tsx +++ b/src/Toolbar.tsx @@ -15,10 +15,12 @@ import { ActionButtonDropdown } from 'components/ActionButtonDropdown' import { useHotkeys } from 'react-hotkeys-hook' import Tooltip from 'components/Tooltip' -export const Toolbar = () => { - const { commandBarSend } = useCommandsContext() +export function Toolbar({ + className = '', + ...props +}: React.HTMLAttributes) { const { state, send, context } = useModelingContext() - const toolbarButtonsRef = useRef(null) + 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 = @@ -34,6 +36,9 @@ export const Toolbar = () => { context.selectionRanges ) }, [engineCommandManager.artifactMap, context.selectionRanges]) + + const toolbarButtonsRef = useRef(null) + const { overallState } = useNetworkStatus() const { isExecuting } = useKclContext() const { isStreamReady } = useStore((s) => ({ @@ -100,12 +105,45 @@ export const Toolbar = () => { 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, + })), - function ToolbarButtons({ - className = '', - ...props - }: React.HTMLAttributes) { - return ( + [JSON.stringify(nextEvents), state] + ) + return ( +
    { className={'m-0 py-1 rounded-l-sm flex gap-2 items-center ' + className} style={{ scrollbarWidth: 'thin' }} > - {state.nextEvents.includes('Enter sketch') && ( + {nextEvents.includes('Enter sketch') && (
  • {
  • )} - {state.nextEvents.includes('Enter sketch') && pathId && ( + {nextEvents.includes('Enter sketch') && pathId && (
  • {
  • )} - {state.nextEvents.includes('Cancel') && !state.matches('idle') && ( + {nextEvents.includes('Cancel') && !state.matches('idle') && (
  • { )} {state.matches('Sketch.SketchIdle') && - state.nextEvents.filter( + nextEvents.filter( (eventName) => eventName.includes('Make segment') || eventName.includes('Constrain') ).length > 0 && ( - eventName.includes('Make segment') || - eventName.includes('Constrain') - ) - .sort((a, b) => { - const aisEnabled = state.nextEvents - .filter((event) => state.can(event as any)) - .includes(a) - const bIsEnabled = state.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: - !state.nextEvents - .filter((event) => state.can(event as any)) - .includes(eventName) || disableAllButtons, - }))} + splitMenuItems={splitMenuItems} className={buttonClassName} Element="button" iconStart={{ @@ -369,12 +377,6 @@ export const Toolbar = () => {
  • )}
- ) - } - - return ( - - ) } diff --git a/src/clientSideScene/ClientSideSceneComp.tsx b/src/clientSideScene/ClientSideSceneComp.tsx index d7962a7f7..1d38dc0f8 100644 --- a/src/clientSideScene/ClientSideSceneComp.tsx +++ b/src/clientSideScene/ClientSideSceneComp.tsx @@ -1,4 +1,4 @@ -import { useRef, useEffect, useState } from 'react' +import { useRef, useEffect, useState, useMemo, Fragment } from 'react' import { useModelingContext } from 'hooks/useModelingContext' import { cameraMouseDragGuards } from 'lib/cameraControls' @@ -6,12 +6,44 @@ import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext' import { ARROWHEAD, DEBUG_SHOW_BOTH_SCENES } from './sceneInfra' import { ReactCameraProperties } from './CameraControls' import { throttle } from 'lib/utils' -import { sceneInfra } from 'lib/singletons' +import { + sceneInfra, + kclManager, + codeManager, + editorManager, + sceneEntitiesManager, + engineCommandManager, +} from 'lib/singletons' import { EXTRA_SEGMENT_HANDLE, PROFILE_START, getParentGroup, } from './sceneEntities' +import { SegmentOverlay, SketchDetails } from 'machines/modelingMachine' +import { findUsesOfTagInPipe, getNodeFromPath } from 'lang/queryAst' +import { + CallExpression, + PathToNode, + Program, + SourceRange, + Value, + parse, + recast, +} from 'lang/wasm' +import { CustomIcon, CustomIconName } from 'components/CustomIcon' +import { ConstrainInfo } from 'lang/std/stdTypes' +import { getConstraintInfo } from 'lang/std/sketch' +import { Dialog, Popover, Transition } from '@headlessui/react' +import { LineInputsType } from 'lang/std/sketchcombos' +import toast from 'react-hot-toast' +import { InstanceProps, create } from 'react-modal-promise' +import { executeAst } from 'useStore' +import { + deleteSegmentFromPipeExpression, + makeRemoveSingleConstraintInput, + removeSingleConstraintInfo, +} from 'lang/modifyAst' +import { ActionButton } from 'components/ActionButton' function useShouldHideScene(): { hideClient: boolean; hideServer: boolean } { const [isCamMoving, setIsCamMoving] = useState(false) @@ -100,17 +132,531 @@ export const ClientSideScene = ({ } return ( -
+ <> +
+ + + ) +} + +const Overlays = () => { + const { context } = useModelingContext() + if (context.mouseState.type === 'isDragging') return null + return ( +
+ {Object.entries(context.segmentOverlays) + .filter((a) => a[1].visible) + .map(([pathToNodeString, overlay], index) => { + return ( + + ) + })} +
+ ) +} + +const Overlay = ({ + overlay, + overlayIndex, + pathToNodeString, +}: { + overlay: SegmentOverlay + overlayIndex: number + pathToNodeString: string +}) => { + const { context, send, state } = useModelingContext() + let xAlignment = overlay.angle < 0 ? '0%' : '-100%' + let yAlignment = overlay.angle < -90 || overlay.angle >= 90 ? '0%' : '-100%' + + const callExpression = getNodeFromPath( + kclManager.ast, + overlay.pathToNode, + 'CallExpression' + ).node + const constraints = getConstraintInfo( + callExpression, + codeManager.code, + overlay.pathToNode + ) + + const offset = 20 // px + // We could put a boolean in settings that + const offsetAngle = 90 + + const xOffset = + Math.cos(((overlay.angle + offsetAngle) * Math.PI) / 180) * offset + const yOffset = + Math.sin(((overlay.angle + offsetAngle) * Math.PI) / 180) * offset + + const shouldShow = + overlay.visible && + typeof context?.segmentHoverMap?.[pathToNodeString] === 'number' && + !( + state.matches('Sketch.Line tool') || + state.matches('Sketch.Tangential arc to') || + state.matches('Sketch.Rectangle tool') + ) + + return ( +
+
+ {shouldShow && ( +
+ send({ + type: 'Set mouse state', + data: { + type: 'isHovering', + on: overlay.group, + }, + }) + } + onMouseLeave={() => + send({ + type: 'Set mouse state', + data: { type: 'idle' }, + }) + } + > + {constraints && + constraints.map((constraintInfo, i) => ( + window.innerHeight / 2 + ? 'top' + : 'bottom' + } + /> + ))} + window.innerHeight / 2 + ? 'top' + : 'bottom' + } + pathToNode={overlay.pathToNode} + stdLibFnName={constraints[0]?.stdLibFnName} + /> +
+ )} +
+ ) +} + +type ConfirmModalProps = InstanceProps & { text: string } + +export const ConfirmModal = ({ + isOpen, + onResolve, + onReject, + text, +}: ConfirmModalProps) => { + return ( + + onResolve(false)} + > + +
+ + +
+
+ + +
{text}
+
+ onResolve(true)} + > + Continue and unconstrain + + onReject(false)} + > + Cancel + +
+
+
+
+
+
+
+ ) +} + +export const confirmModal = create( + ConfirmModal +) + +export async function deleteSegment({ + pathToNode, + sketchDetails, +}: { + pathToNode: PathToNode + sketchDetails: SketchDetails | null +}) { + let modifiedAst: Program = kclManager.ast + const dependentRanges = findUsesOfTagInPipe(modifiedAst, pathToNode) + + const shouldContinueSegDelete = dependentRanges.length + ? await confirmModal({ + text: `At least ${dependentRanges.length} segment rely on the segment you're deleting.\nDo you want to continue and unconstrain these segments?`, + isOpen: true, + }) + : true + + if (!shouldContinueSegDelete) return + modifiedAst = deleteSegmentFromPipeExpression( + dependentRanges, + modifiedAst, + kclManager.programMemory, + codeManager.code, + pathToNode + ) + + const newCode = recast(modifiedAst) + modifiedAst = parse(newCode) + const testExecute = await executeAst({ + ast: modifiedAst, + useFakeExecutor: true, + engineCommandManager: engineCommandManager, + }) + if (testExecute.errors.length) { + toast.error('Segment tag used outside of current Sketch. Could not delete.') + return + } + + if (!sketchDetails) return + sceneEntitiesManager.updateAstAndRejigSketch( + sketchDetails.sketchPathToNode, + modifiedAst, + sketchDetails.zAxis, + sketchDetails.yAxis, + sketchDetails.origin + ) +} + +const SegmentMenu = ({ + verticalPosition, + pathToNode, + stdLibFnName, +}: { + verticalPosition: 'top' | 'bottom' + pathToNode: PathToNode + stdLibFnName: string +}) => { + const { send } = useModelingContext() + const dependentSourceRanges = findUsesOfTagInPipe(kclManager.ast, pathToNode) + return ( + + {({ open }) => ( + <> + + + + + {/* */} + + + + )} + + ) +} + +const ConstraintSymbol = ({ + constrainInfo: { type: _type, isConstrained, value, pathToNode, argPosition }, + verticalPosition, +}: { + constrainInfo: ConstrainInfo + verticalPosition: 'top' | 'bottom' +}) => { + const { context, send } = useModelingContext() + const varNameMap: { + [key in ConstrainInfo['type']]: { + varName: string + displayName: string + iconName: CustomIconName + implicitConstraintDesc?: string + } + } = { + xRelative: { + varName: 'xRel', + displayName: 'X Relative', + iconName: 'xRelative', + }, + xAbsolute: { + varName: 'xAbs', + displayName: 'X Absolute', + iconName: 'xAbsolute', + }, + yRelative: { + varName: 'yRel', + displayName: 'Y Relative', + iconName: 'yRelative', + }, + yAbsolute: { + varName: 'yAbs', + displayName: 'Y Absolute', + iconName: 'yAbsolute', + }, + angle: { + varName: 'angle', + displayName: 'Angle', + iconName: 'angle', + }, + length: { + varName: 'len', + displayName: 'Length', + iconName: 'dimension', + }, + intersectionOffset: { + varName: 'perpDist', + displayName: 'Intersection Offset', + iconName: 'intersection-offset', + }, + + // implicit constraints + vertical: { + varName: '', + displayName: '', + iconName: 'vertical', + implicitConstraintDesc: 'vertically', + }, + horizontal: { + varName: '', + displayName: '', + iconName: 'horizontal', + implicitConstraintDesc: 'horizontally', + }, + tangentialWithPrevious: { + varName: '', + displayName: '', + iconName: 'tangent', + implicitConstraintDesc: 'tangential to previous segment', + }, + + // we don't render this one + intersectionTag: { + varName: '', + displayName: '', + iconName: 'dimension', + }, + } + const varName = + _type in varNameMap ? varNameMap[_type as LineInputsType].varName : 'var' + const name: CustomIconName = varNameMap[_type as LineInputsType].iconName + const displayName = varNameMap[_type as LineInputsType]?.displayName + const implicitDesc = + varNameMap[_type as LineInputsType]?.implicitConstraintDesc + + const node = useMemo( + () => + getNodeFromPath(parse(recast(kclManager.ast)), pathToNode).node, + [kclManager.ast, pathToNode] + ) + const range: SourceRange = node ? [node.start, node.end] : [0, 0] + + if (_type === 'intersectionTag') return null + + return ( +
+ + + +
+
+ {implicitDesc ? ( +
+
+                {value}
+              
{' '} + is implicitly constrained {implicitDesc} +
+ ) : ( + <> +
+ + + {isConstrained ? 'Constrained' : 'Unconstrained'} + + + {displayName} + + +
+
+ Set to +
+                  {value}
+                
+
+
+ {isConstrained + ? 'Click to unconstrain with raw number' + : 'Click to constrain with variable'} +
+ + )} +
+
+
) } diff --git a/src/clientSideScene/sceneEntities.ts b/src/clientSideScene/sceneEntities.ts index 64e1d60f1..30efa7e3f 100644 --- a/src/clientSideScene/sceneEntities.ts +++ b/src/clientSideScene/sceneEntities.ts @@ -75,7 +75,7 @@ import { changeSketchArguments, updateStartProfileAtArgs, } from 'lang/std/sketch' -import { roundOff, throttle } from 'lib/utils' +import { normaliseAngle, roundOff, throttle } from 'lib/utils' import { createArrayExpression, createCallExpressionStdLib, @@ -92,7 +92,7 @@ import { getTangentPointFromPreviousArc } from 'lib/utils2d' import { createGridHelper, orthoScale, perspScale } from './helpers' import { Models } from '@kittycad/lib' import { uuidv4 } from 'lib/utils' -import { SketchDetails } from 'machines/modelingMachine' +import { SegmentOverlayPayload, SketchDetails } from 'machines/modelingMachine' import { EngineCommandManager } from 'lang/std/engineConnection' import { getRectangleCallExpressions, @@ -139,8 +139,8 @@ export class SceneEntities { } onCamChange = () => { const orthoFactor = orthoScale(sceneInfra.camControls.camera) - - Object.values(this.activeSegments).forEach((segment) => { + const callbacks: (() => SegmentOverlayPayload | null)[] = [] + Object.values(this.activeSegments).forEach((segment, index) => { const factor = (sceneInfra.camControls.camera instanceof OrthographicCamera ? orthoFactor @@ -151,12 +151,14 @@ export class SceneEntities { segment.userData.to && segment.userData.type === STRAIGHT_SEGMENT ) { - this.updateStraightSegment({ - from: segment.userData.from, - to: segment.userData.to, - group: segment, - scale: factor, - }) + callbacks.push( + this.updateStraightSegment({ + from: segment.userData.from, + to: segment.userData.to, + group: segment, + scale: factor, + }) + ) } if ( @@ -165,13 +167,15 @@ export class SceneEntities { segment.userData.prevSegment && segment.userData.type === TANGENTIAL_ARC_TO_SEGMENT ) { - this.updateTangentialArcToSegment({ - prevSegment: segment.userData.prevSegment, - from: segment.userData.from, - to: segment.userData.to, - group: segment, - scale: factor, - }) + callbacks.push( + this.updateTangentialArcToSegment({ + prevSegment: segment.userData.prevSegment, + from: segment.userData.from, + to: segment.userData.to, + group: segment, + scale: factor, + }) + ) } if (segment.name === PROFILE_START) { segment.scale.set(factor, factor, factor) @@ -187,6 +191,7 @@ export class SceneEntities { const y = this.axisGroup.getObjectByName(Y_AXIS) y?.scale.set(factor / sceneInfra._baseUnitMultiplier, 1, 1) } + sceneInfra.overlayCallbacks(callbacks) } createIntersectionPlane() { @@ -366,7 +371,7 @@ export class SceneEntities { }) group.add(_profileStart) this.activeSegments[JSON.stringify(segPathToNode)] = _profileStart - + const callbacks: (() => SegmentOverlayPayload | null)[] = [] sketchGroup.value.forEach((segment, index) => { let segPathToNode = getNodePathFromSourceRange( maybeModdedAst, @@ -411,6 +416,15 @@ export class SceneEntities { texture: sceneInfra.extraSegmentTexture, theme: sceneInfra._theme, }) + callbacks.push( + this.updateTangentialArcToSegment({ + prevSegment: sketchGroup.value[index - 1], + from: segment.from, + to: segment.to, + group: seg, + scale: factor, + }) + ) } else { seg = straightSegment({ from: segment.from, @@ -423,6 +437,14 @@ export class SceneEntities { texture: sceneInfra.extraSegmentTexture, theme: sceneInfra._theme, }) + callbacks.push( + this.updateStraightSegment({ + from: segment.from, + to: segment.to, + group: seg, + scale: factor, + }) + ) } seg.layers.set(SKETCH_LAYER) seg.traverse((child) => { @@ -447,6 +469,7 @@ export class SceneEntities { this.intersectionPlane.position.set(...position) this.scene.add(group) sceneInfra.camControls.enableRotate = false + sceneInfra.overlayCallbacks(callbacks) return { truncatedAst, @@ -1019,7 +1042,8 @@ export class SceneEntities { orthoFactor, sketchGroup ) - sgPaths.forEach((group, index) => + + const callBacks = sgPaths.map((group, index) => this.updateSegment( group, index, @@ -1029,6 +1053,7 @@ export class SceneEntities { sketchGroup ) ) + sceneInfra.overlayCallbacks(callBacks) })() } @@ -1049,7 +1074,7 @@ export class SceneEntities { modifiedAst: Program, orthoFactor: number, sketchGroup: SketchGroup - ) => { + ): (() => SegmentOverlayPayload | null) => { const segPathToNode = getNodePathFromSourceRange( modifiedAst, segment.__geoMeta.sourceRange @@ -1070,7 +1095,7 @@ export class SceneEntities { : perspScale(sceneInfra.camControls.camera, group)) / sceneInfra._baseUnitMultiplier if (type === TANGENTIAL_ARC_TO_SEGMENT) { - this.updateTangentialArcToSegment({ + return this.updateTangentialArcToSegment({ prevSegment: sgPaths[index - 1], from: segment.from, to: segment.to, @@ -1078,7 +1103,7 @@ export class SceneEntities { scale: factor, }) } else if (type === STRAIGHT_SEGMENT) { - this.updateStraightSegment({ + return this.updateStraightSegment({ from: segment.from, to: segment.to, group, @@ -1088,6 +1113,7 @@ export class SceneEntities { group.position.set(segment.from[0], segment.from[1], 0) group.scale.set(factor, factor, factor) } + return () => null } updateTangentialArcToSegment({ @@ -1102,7 +1128,7 @@ export class SceneEntities { to: [number, number] group: Group scale?: number - }) { + }): () => SegmentOverlayPayload | null { group.userData.from = from group.userData.to = to group.userData.prevSegment = prevSegment @@ -1190,6 +1216,18 @@ export class SceneEntities { scale, }) } + const angle = normaliseAngle( + (arcInfo.endAngle * 180) / Math.PI + (arcInfo.ccw ? 90 : -90) + ) + return () => + sceneInfra.updateOverlayDetails({ + arrowGroup, + group, + isHandlesVisible, + from, + to, + angle, + }) } throttledUpdateDashedArcGeo = throttle( ( @@ -1210,7 +1248,7 @@ export class SceneEntities { to: [number, number] group: Group scale?: number - }) { + }): () => SegmentOverlayPayload | null { group.userData.from = from group.userData.to = to const shape = new Shape() @@ -1287,6 +1325,14 @@ export class SceneEntities { scale ) } + return () => + sceneInfra.updateOverlayDetails({ + arrowGroup, + group, + isHandlesVisible, + from, + to, + }) } async animateAfterSketch() { // if (isReducedMotion()) { @@ -1558,6 +1604,14 @@ export class SceneEntities { }, } } + resetOverlays() { + sceneInfra.modelingSend({ + type: 'Set Segment Overlays', + data: { + type: 'clear', + }, + }) + } } export type DefaultPlaneStr = 'XY' | 'XZ' | 'YZ' | '-XY' | '-XZ' | '-YZ' diff --git a/src/clientSideScene/sceneInfra.ts b/src/clientSideScene/sceneInfra.ts index 2a0fdff78..0020a3edf 100644 --- a/src/clientSideScene/sceneInfra.ts +++ b/src/clientSideScene/sceneInfra.ts @@ -21,14 +21,15 @@ import { TextureLoader, Texture, } from 'three' -import { compareVec2Epsilon2 } from 'lang/std/sketch' +import { Coords2d, compareVec2Epsilon2 } from 'lang/std/sketch' import { useModelingContext } from 'hooks/useModelingContext' import * as TWEEN from '@tweenjs/tween.js' import { Axis } from 'lib/selections' import { type BaseUnit } from 'lib/settings/settingsTypes' import { CameraControls } from './CameraControls' import { EngineCommandManager } from 'lang/std/engineConnection' -import { MouseState } from 'machines/modelingMachine' +import { MouseState, SegmentOverlayPayload } from 'machines/modelingMachine' +import { getAngle, throttle } from 'lib/utils' import { Themes } from 'lib/theme' type SendType = ReturnType['send'] @@ -154,8 +155,88 @@ export class SceneInfra { } modelingSend: SendType = (() => {}) as any + throttledModelingSend: any = (() => {}) as any setSend(send: SendType) { this.modelingSend = send + this.throttledModelingSend = throttle(send, 100) + } + overlayTimeout = 0 + callbacks: (() => SegmentOverlayPayload | null)[] = [] + _overlayCallbacks(callbacks: (() => SegmentOverlayPayload | null)[]) { + const segmentOverlayPayload: SegmentOverlayPayload = { + type: 'set-many', + overlays: {}, + } + callbacks.forEach((cb) => { + const overlay = cb() + if (overlay?.type === 'set-one') { + segmentOverlayPayload.overlays[overlay.pathToNodeString] = overlay.seg + } + }) + this.modelingSend({ + type: 'Set Segment Overlays', + data: segmentOverlayPayload, + }) + } + overlayCallbacks( + callbacks: (() => SegmentOverlayPayload | null)[], + instant = false + ) { + if (instant) { + this._overlayCallbacks(callbacks) + return + } + this.callbacks = callbacks + if (this.overlayTimeout) clearTimeout(this.overlayTimeout) + this.overlayTimeout = setTimeout(() => { + this._overlayCallbacks(this.callbacks) + }, 100) as unknown as number + } + + overlayThrottleMap: { [pathToNodeString: string]: number } = {} + updateOverlayDetails({ + arrowGroup, + group, + isHandlesVisible, + from, + to, + angle, + }: { + arrowGroup: Group + group: Group + isHandlesVisible: boolean + from: Coords2d + to: Coords2d + angle?: number + }): SegmentOverlayPayload | null { + if (group.userData.pathToNode && arrowGroup) { + const vector = new Vector3(0, 0, 0) + + // Get the position of the object3D in world space + // console.log('arrowGroup', arrowGroup) + arrowGroup.getWorldPosition(vector) + + // Project that position to screen space + vector.project(this.camControls.camera) + + const _angle = typeof angle === 'number' ? angle : getAngle(from, to) + + const x = (vector.x * 0.5 + 0.5) * window.innerWidth + const y = (-vector.y * 0.5 + 0.5) * window.innerHeight + const pathToNodeString = JSON.stringify(group.userData.pathToNode) + return { + type: 'set-one', + pathToNodeString, + seg: { + windowCoords: [x, y], + angle: _angle, + group, + pathToNode: group.userData.pathToNode, + visible: isHandlesVisible, + }, + } + } + return null } hoveredObject: null | any = null diff --git a/src/components/AvailableVarsHelpers.tsx b/src/components/AvailableVarsHelpers.tsx index a2559928c..fa081fc06 100644 --- a/src/components/AvailableVarsHelpers.tsx +++ b/src/components/AvailableVarsHelpers.tsx @@ -214,10 +214,7 @@ export const CreateNewVariable = ({ }) => { return ( <> -