Snap to origin and axis behavior for profile starts and segments (#4344)
* Visualize draft point when near axes (only works on XY rn due to quaternion rotation issue) * Slightly better quaternion rotation * Actually snap new profiles to the X and Y axis * Add snapping behavior while dragging * Fix flickering on non-XY planes * Add some fixture additions to support click-and-drag tests * Add new test to verify snapping behavior * Make the editor test fixture auto-open and close as needed * All feedback except absolute lines * Use `lineTo` for lines that have snapped * Get other existing tests passing after switching to `lineTo` when snapping * A snapshot a day keeps the bugs away! 📷🐛 (OS: ubuntu-latest) * Re-run CI --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
This commit is contained in:
@ -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
|
||||
|
@ -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<string>
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
@ -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()
|
||||
}
|
||||
|
@ -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<void | boolean>
|
||||
type MoveHandler = (moveParams?: mouseParams) => Promise<void | boolean>
|
||||
type DragToHandler = (dragParams: mouseDragToParams) => Promise<void | boolean>
|
||||
type DragFromHandler = (
|
||||
dragParams: mouseDragFromParams
|
||||
) => Promise<void | boolean>
|
||||
|
||||
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.
|
||||
*
|
||||
|
@ -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')
|
||||
|
@ -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
|
||||
)
|
||||
})
|
||||
})
|
||||
|
@ -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)
|
||||
|
@ -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], %)
|
||||
|
||||
`)
|
||||
|
||||
|
@ -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) {
|
||||
|
@ -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)
|
||||
|
@ -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(
|
||||
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<Node<CallExpression>>(
|
||||
|
@ -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<typeof useModelingContext>['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<Object3DEventMap>
|
||||
dragSelected?: Object3D<Object3DEventMap>
|
||||
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<Object3D<Object3DEventMap>>
|
||||
} | 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({
|
||||
|
@ -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
|
||||
|
@ -684,6 +684,14 @@ const transformMap: TransformMap = {
|
||||
tag
|
||||
),
|
||||
},
|
||||
xAbs: {
|
||||
tooltip: 'lineTo',
|
||||
createNode: setAbsDistanceCreateNode('x'),
|
||||
},
|
||||
yAbs: {
|
||||
tooltip: 'lineTo',
|
||||
createNode: setAbsDistanceCreateNode('y'),
|
||||
},
|
||||
},
|
||||
xAbsolute: {
|
||||
equalLength: {
|
||||
|
Reference in New Issue
Block a user