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:
Frank Noirot
2024-10-31 07:04:38 -07:00
committed by GitHub
parent a8b816a3e2
commit 26e995dc3f
14 changed files with 453 additions and 71 deletions

View File

@ -86,7 +86,7 @@ async function doBasicSketch(page: Page, openPanes: string[]) {
|> startProfileAt(${commonPoints.startAt}, %) |> startProfileAt(${commonPoints.startAt}, %)
|> line([${commonPoints.num1}, 0], %) |> line([${commonPoints.num1}, 0], %)
|> line([0, ${commonPoints.num1 + 0.01}], %) |> line([0, ${commonPoints.num1 + 0.01}], %)
|> line([-${commonPoints.num2}, 0], %)`) |> lineTo([0, ${commonPoints.num3}], %)`)
} }
// deselect line tool // deselect line tool

View File

@ -1,6 +1,11 @@
import type { Page, Locator } from '@playwright/test' import type { Page, Locator } from '@playwright/test'
import { expect } from '@playwright/test' import { expect } from '@playwright/test'
import { sansWhitespace } from '../test-utils' import {
closePane,
checkIfPaneIsOpen,
openPane,
sansWhitespace,
} from '../test-utils'
interface EditorState { interface EditorState {
activeLines: Array<string> activeLines: Array<string>
@ -11,6 +16,7 @@ interface EditorState {
export class EditorFixture { export class EditorFixture {
public page: Page public page: Page
private paneButtonTestId = 'code-pane-button'
private diagnosticsTooltip!: Locator private diagnosticsTooltip!: Locator
private diagnosticsGutterIcon!: Locator private diagnosticsGutterIcon!: Locator
private codeContent!: Locator private codeContent!: Locator
@ -31,19 +37,32 @@ export class EditorFixture {
private _expectEditorToContain = private _expectEditorToContain =
(not = false) => (not = false) =>
( async (
code: string, code: string,
{ {
shouldNormalise = false, shouldNormalise = false,
timeout = 5_000, timeout = 5_000,
}: { shouldNormalise?: boolean; timeout?: number } = {} }: { shouldNormalise?: boolean; timeout?: number } = {}
) => { ) => {
const wasPaneOpen = await this.checkIfPaneIsOpen()
if (!wasPaneOpen) {
await this.openPane()
}
const resetPane = async () => {
if (!wasPaneOpen) {
await this.closePane()
}
}
if (!shouldNormalise) { if (!shouldNormalise) {
const expectStart = expect(this.codeContent) const expectStart = expect(this.codeContent)
if (not) { 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 normalisedCode = code.replaceAll(/\s+/g, '').trim()
const expectStart = expect.poll( const expectStart = expect.poll(
@ -56,9 +75,13 @@ export class EditorFixture {
} }
) )
if (not) { 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 = { expectEditor = {
toContain: this._expectEditorToContain(), toContain: this._expectEditorToContain(),
@ -115,4 +138,13 @@ export class EditorFixture {
code = code.replace(findCode, replaceCode) code = code.replace(findCode, replaceCode)
await this.codeContent.fill(code) 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)
}
} }

View File

@ -20,6 +20,7 @@ export class AuthenticatedApp {
public readonly page: Page public readonly page: Page
public readonly context: BrowserContext public readonly context: BrowserContext
public readonly testInfo: TestInfo public readonly testInfo: TestInfo
public readonly viewPortSize = { width: 1000, height: 500 }
constructor(context: BrowserContext, page: Page, testInfo: TestInfo) { constructor(context: BrowserContext, page: Page, testInfo: TestInfo) {
this.page = page this.page = page
@ -36,7 +37,7 @@ export class AuthenticatedApp {
;(window as any).playwrightSkipFilePicker = true ;(window as any).playwrightSkipFilePicker = true
}, code) }, code)
await this.page.setViewportSize({ width: 1000, height: 500 }) await this.page.setViewportSize(this.viewPortSize)
await u.waitForAuthSkipAppStart() await u.waitForAuthSkipAppStart()
} }

View File

@ -10,7 +10,13 @@ import {
} from '../test-utils' } from '../test-utils'
type mouseParams = { type mouseParams = {
pixelDiff: number pixelDiff?: number
}
type mouseDragToParams = mouseParams & {
fromPoint: { x: number; y: number }
}
type mouseDragFromParams = mouseParams & {
toPoint: { x: number; y: number }
} }
type SceneSerialised = { 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 { export class SceneFixture {
public page: Page public page: Page
@ -55,7 +68,7 @@ export class SceneFixture {
x: number, x: number,
y: number, y: number,
{ steps }: { steps: number } = { steps: 20 } { steps }: { steps: number } = { steps: 20 }
) => ): [ClickHandler, MoveHandler] =>
[ [
(clickParams?: mouseParams) => { (clickParams?: mouseParams) => {
if (clickParams?.pixelDiff) { if (clickParams?.pixelDiff) {
@ -78,6 +91,47 @@ export class SceneFixture {
return this.page.mouse.move(x, y, { steps }) return this.page.mouse.move(x, y, { steps })
}, },
] as const ] 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. /** Likely no where, there's a chance it will click something in the scene, depending what you have in the scene.
* *

View File

@ -7,6 +7,7 @@ export class ToolbarFixture {
extrudeButton!: Locator extrudeButton!: Locator
startSketchBtn!: Locator startSketchBtn!: Locator
lineBtn!: Locator
rectangleBtn!: Locator rectangleBtn!: Locator
exitSketchBtn!: Locator exitSketchBtn!: Locator
editSketchBtn!: Locator editSketchBtn!: Locator
@ -24,6 +25,7 @@ export class ToolbarFixture {
this.page = page this.page = page
this.extrudeButton = page.getByTestId('extrude') this.extrudeButton = page.getByTestId('extrude')
this.startSketchBtn = page.getByTestId('sketch') this.startSketchBtn = page.getByTestId('sketch')
this.lineBtn = page.getByTestId('line')
this.rectangleBtn = page.getByTestId('corner-rectangle') this.rectangleBtn = page.getByTestId('corner-rectangle')
this.exitSketchBtn = page.getByTestId('sketch-exit') this.exitSketchBtn = page.getByTestId('sketch-exit')
this.editSketchBtn = page.getByText('Edit Sketch') this.editSketchBtn = page.getByText('Edit Sketch')

View File

@ -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
)
})
})

View File

@ -637,7 +637,6 @@ test.describe('Sketch tests', () => {
|> revolve({ axis: "X" }, %)`) |> revolve({ axis: "X" }, %)`)
}) })
test('Can add multiple sketches', async ({ page }) => { test('Can add multiple sketches', async ({ page }) => {
test.skip(process.platform === 'darwin', 'Can add multiple sketches')
const u = await getUtils(page) const u = await getUtils(page)
const viewportSize = { width: 1200, height: 500 } const viewportSize = { width: 1200, height: 500 }
await page.setViewportSize(viewportSize) await page.setViewportSize(viewportSize)
@ -675,15 +674,16 @@ test.describe('Sketch tests', () => {
await click00r(50, 0) await click00r(50, 0)
await page.waitForTimeout(100) await page.waitForTimeout(100)
codeStr += ` |> line(${toSU([50, 0])}, %)` codeStr += ` |> lineTo(${toSU([50, 0])}, %)`
await expect(u.codeLocator).toHaveText(codeStr) await expect(u.codeLocator).toHaveText(codeStr)
await click00r(0, 50) await click00r(0, 50)
codeStr += ` |> line(${toSU([0, 50])}, %)` codeStr += ` |> line(${toSU([0, 50])}, %)`
await expect(u.codeLocator).toHaveText(codeStr) await expect(u.codeLocator).toHaveText(codeStr)
await click00r(-50, 0) let clickCoords = await click00r(-50, 0)
codeStr += ` |> line(${toSU([-50, 0])}, %)` expect(clickCoords).not.toBeUndefined()
codeStr += ` |> lineTo(${toSU(clickCoords!)}, %)`
await expect(u.codeLocator).toHaveText(codeStr) await expect(u.codeLocator).toHaveText(codeStr)
// exit the sketch, reset relative clicker // exit the sketch, reset relative clicker
@ -709,8 +709,10 @@ test.describe('Sketch tests', () => {
codeStr += ` |> startProfileAt([2.03, 0], %)` codeStr += ` |> startProfileAt([2.03, 0], %)`
await expect(u.codeLocator).toHaveText(codeStr) 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) await click00r(30, 0)
codeStr += ` |> line([2.04, 0], %)` codeStr += ` |> lineTo([4.07, 0], %)`
await expect(u.codeLocator).toHaveText(codeStr) await expect(u.codeLocator).toHaveText(codeStr)
await click00r(0, 30) await click00r(0, 30)

View File

@ -219,7 +219,7 @@ test.describe('Test network and connection issues', () => {
|> startProfileAt([12.34, -12.34], %) |> startProfileAt([12.34, -12.34], %)
|> line([12.34, 0], %) |> line([12.34, 0], %)
|> line([-12.34, 12.34], %) |> line([-12.34, 12.34], %)
|> line([-12.34, 0], %) |> lineTo([0, -12.34], %)
`) `)

View File

@ -45,7 +45,9 @@ export const commonPoints = {
startAt: '[7.19, -9.7]', startAt: '[7.19, -9.7]',
num1: 7.25, num1: 7.25,
num2: 14.44, 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 /** A semi-reliable color to check the default XZ plane on
* in dark mode in the default camera position * in dark mode in the default camera position
@ -118,15 +120,32 @@ async function waitForDefaultPlanesToBeVisible(page: Page) {
) )
} }
async function openPane(page: Page, testId: string) { export async function checkIfPaneIsOpen(page: Page, testId: string) {
const locator = page.getByTestId(testId) const paneButtonLocator = page.getByTestId(testId)
await expect(locator).toBeVisible() await expect(paneButtonLocator).toBeVisible()
const isOpen = (await locator?.getAttribute('aria-pressed')) === 'true' 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) { if (!isOpen) {
await locator.click() await paneButtonLocator.click()
await expect(locator).toHaveAttribute('aria-pressed', 'true')
} }
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) { async function openKclCodePanel(page: Page) {

View File

@ -96,7 +96,7 @@ test.describe('Testing selections', () => {
|> startProfileAt(${commonPoints.startAt}, %) |> startProfileAt(${commonPoints.startAt}, %)
|> line([${commonPoints.num1}, 0], %) |> line([${commonPoints.num1}, 0], %)
|> line([0, ${commonPoints.num1 + 0.01}], %) |> line([0, ${commonPoints.num1 + 0.01}], %)
|> line([-${commonPoints.num2}, 0], %)`) |> lineTo([0, ${commonPoints.num3}], %)`)
// deselect line tool // deselect line tool
await page.getByRole('button', { name: 'line Line', exact: true }).click() await page.getByRole('button', { name: 'line Line', exact: true }).click()
@ -157,7 +157,9 @@ test.describe('Testing selections', () => {
await emptySpaceClick() await emptySpaceClick()
// check the same selection again by putting cursor in code first then selecting axis // 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 page.keyboard.down('Shift')
await constrainButton.click() await constrainButton.click()
await expect(absYButton).toBeDisabled() await expect(absYButton).toBeDisabled()
@ -180,7 +182,9 @@ test.describe('Testing selections', () => {
process.platform === 'linux' ? 'Control' : 'Meta' process.platform === 'linux' ? 'Control' : 'Meta'
) )
await page.waitForTimeout(100) 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 expect(page.locator('.cm-cursor')).toHaveCount(2)
await page.waitForTimeout(500) await page.waitForTimeout(500)

View File

@ -19,6 +19,8 @@ import {
import { import {
ARROWHEAD, ARROWHEAD,
AXIS_GROUP, AXIS_GROUP,
DRAFT_POINT,
DRAFT_POINT_GROUP,
getSceneScale, getSceneScale,
INTERSECTION_PLANE_LAYER, INTERSECTION_PLANE_LAYER,
OnClickCallbackArgs, OnClickCallbackArgs,
@ -53,7 +55,7 @@ import {
editorManager, editorManager,
} from 'lib/singletons' } from 'lib/singletons'
import { getNodeFromPath, getNodePathFromSourceRange } from 'lang/queryAst' import { getNodeFromPath, getNodePathFromSourceRange } from 'lang/queryAst'
import { executeAst } from 'lang/langHelpers' import { executeAst, ToolTip } from 'lang/langHelpers'
import { import {
createProfileStartHandle, createProfileStartHandle,
SegmentUtils, SegmentUtils,
@ -314,6 +316,27 @@ export class SceneEntities {
const intersectionPlane = this.scene.getObjectByName(RAYCASTABLE_PLANE) const intersectionPlane = this.scene.getObjectByName(RAYCASTABLE_PLANE)
if (intersectionPlane) this.scene.remove(intersectionPlane) 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({ setupNoPointsListener({
sketchDetails, sketchDetails,
@ -322,22 +345,78 @@ export class SceneEntities {
sketchDetails: SketchDetails sketchDetails: SketchDetails
afterClick: (args: OnClickCallbackArgs) => void 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() 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( const quaternion = quaternionFromUpNForward(
new Vector3(...sketchDetails.yAxis), new Vector3(...sketchDetails.yAxis),
new Vector3(...sketchDetails.zAxis) new Vector3(...sketchDetails.zAxis)
) )
// Position the click raycast plane // Position the click raycast plane
if (this.intersectionPlane) { this.intersectionPlane!.setRotationFromQuaternion(quaternion)
this.intersectionPlane.setRotationFromQuaternion(quaternion) this.intersectionPlane!.position.copy(
this.intersectionPlane.position.copy( new Vector3(...(sketchDetails?.origin || [0, 0, 0]))
new Vector3(...(sketchDetails?.origin || [0, 0, 0])) )
)
}
sceneInfra.setCallbacks({ 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) => { onClick: async (args) => {
this.removeDraftPoint()
if (!args) return if (!args) return
// If there is a valid camera interaction that matches, do that instead // If there is a valid camera interaction that matches, do that instead
const interaction = sceneInfra.camControls.getInteractionType( const interaction = sceneInfra.camControls.getInteractionType(
@ -347,10 +426,25 @@ export class SceneEntities {
if (args.mouseEvent.which !== 1) return if (args.mouseEvent.which !== 1) return
const { intersectionPoint } = args const { intersectionPoint } = args
if (!intersectionPoint?.twoD || !sketchDetails?.sketchPathToNode) return 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( const addStartProfileAtRes = addStartProfileAt(
kclManager.ast, kclManager.ast,
sketchDetails.sketchPathToNode, sketchDetails.sketchPathToNode,
[intersectionPoint.twoD.x, intersectionPoint.twoD.y] [snappedClickPoint.x, snappedClickPoint.y]
) )
if (trap(addStartProfileAtRes)) return if (trap(addStartProfileAtRes)) return
@ -358,6 +452,7 @@ export class SceneEntities {
await kclManager.updateAst(modifiedAst, false) await kclManager.updateAst(modifiedAst, false)
this.removeIntersectionPlane() this.removeIntersectionPlane()
this.scene.remove(draftPointGroup)
// Now perform the caller-specified action // Now perform the caller-specified action
afterClick(args) afterClick(args)
@ -430,12 +525,7 @@ export class SceneEntities {
const dummy = new Mesh() const dummy = new Mesh()
// TODO: When we actually have sketch positions and rotations we can use them here. // TODO: When we actually have sketch positions and rotations we can use them here.
dummy.position.set(0, 0, 0) dummy.position.set(0, 0, 0)
const orthoFactor = orthoScale(sceneInfra.camControls.camera) const scale = sceneInfra.getClientSceneScaleFactor(dummy)
const factor =
(sceneInfra.camControls.camera instanceof OrthographicCamera
? orthoFactor
: perspScale(sceneInfra.camControls.camera, dummy)) /
sceneInfra._baseUnitMultiplier
const segPathToNode = getNodePathFromSourceRange( const segPathToNode = getNodePathFromSourceRange(
maybeModdedAst, maybeModdedAst,
@ -446,8 +536,9 @@ export class SceneEntities {
from: sketch.start.from, from: sketch.start.from,
id: sketch.start.__geoMeta.id, id: sketch.start.__geoMeta.id,
pathToNode: segPathToNode, pathToNode: segPathToNode,
scale: factor, scale,
theme: sceneInfra._theme, theme: sceneInfra._theme,
isDraft: false,
}) })
_profileStart.layers.set(SKETCH_LAYER) _profileStart.layers.set(SKETCH_LAYER)
_profileStart.traverse((child) => { _profileStart.traverse((child) => {
@ -523,7 +614,7 @@ export class SceneEntities {
id: segment.__geoMeta.id, id: segment.__geoMeta.id,
pathToNode: segPathToNode, pathToNode: segPathToNode,
isDraftSegment, isDraftSegment,
scale: factor, scale,
texture: sceneInfra.extraSegmentTexture, texture: sceneInfra.extraSegmentTexture,
theme: sceneInfra._theme, theme: sceneInfra._theme,
isSelected, isSelected,
@ -660,12 +751,14 @@ export class SceneEntities {
const { intersectionPoint } = args const { intersectionPoint } = args
let intersection2d = intersectionPoint?.twoD let intersection2d = intersectionPoint?.twoD
const profileStart = args.intersects const intersectsProfileStart = args.intersects
.map(({ object }) => getParentGroup(object, [PROFILE_START])) .map(({ object }) => getParentGroup(object, [PROFILE_START]))
.find((a) => a?.name === PROFILE_START) .find((a) => a?.name === PROFILE_START)
let modifiedAst let modifiedAst
if (profileStart) {
// Snapping logic for the profile start handle
if (intersectsProfileStart) {
const lastSegment = sketch.paths.slice(-1)[0] const lastSegment = sketch.paths.slice(-1)[0]
modifiedAst = addCallExpressionsToPipe({ modifiedAst = addCallExpressionsToPipe({
node: kclManager.ast, node: kclManager.ast,
@ -698,19 +791,39 @@ export class SceneEntities {
}) })
if (trap(modifiedAst)) return Promise.reject(modifiedAst) if (trap(modifiedAst)) return Promise.reject(modifiedAst)
} else if (intersection2d) { } 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 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({ const tmp = addNewSketchLn({
node: kclManager.ast, node: kclManager.ast,
programMemory: kclManager.programMemory, programMemory: kclManager.programMemory,
input: { input: {
type: 'straight-segment', type: 'straight-segment',
from: [lastSegment.to[0], lastSegment.to[1]], from: [lastSegment.to[0], lastSegment.to[1]],
to: [intersection2d.x, intersection2d.y], to: [snappedPoint.x, snappedPoint.y],
}, },
fnName: fnName: resolvedFunctionName,
lastSegment.type === 'TangentialArcTo'
? 'tangentialArcTo'
: 'line',
pathToNode: sketchPathToNode, pathToNode: sketchPathToNode,
}) })
if (trap(tmp)) return Promise.reject(tmp) if (trap(tmp)) return Promise.reject(tmp)
@ -722,7 +835,7 @@ export class SceneEntities {
} }
await kclManager.executeAstMock(modifiedAst) await kclManager.executeAstMock(modifiedAst)
if (profileStart) { if (intersectsProfileStart) {
sceneInfra.modelingSend({ type: 'CancelSketch' }) sceneInfra.modelingSend({ type: 'CancelSketch' })
} else { } else {
await this.setUpDraftSegment( await this.setUpDraftSegment(
@ -1229,15 +1342,30 @@ export class SceneEntities {
variableDeclarationName: string variableDeclarationName: string
} }
}) { }) {
const profileStart = const intersectsProfileStart =
draftInfo && draftInfo &&
intersects intersects
.map(({ object }) => getParentGroup(object, [PROFILE_START])) .map(({ object }) => getParentGroup(object, [PROFILE_START]))
.find((a) => a?.name === PROFILE_START) .find((a) => a?.name === PROFILE_START)
const intersection2d = profileStart const intersection2d = intersectsProfileStart
? new Vector2(profileStart.position.x, profileStart.position.y) ? new Vector2(
intersectsProfileStart.position.x,
intersectsProfileStart.position.y
)
: _intersection2d : _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 group = getParentGroup(object, SEGMENT_BODIES_PLUS_PROFILE_START)
const subGroup = getParentGroup(object, [ARROWHEAD, CIRCLE_CENTER_HANDLE]) const subGroup = getParentGroup(object, [ARROWHEAD, CIRCLE_CENTER_HANDLE])
if (!group) return if (!group) return
@ -1257,7 +1385,7 @@ export class SceneEntities {
group.userData.from[0], group.userData.from[0],
group.userData.from[1], 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 } let modifiedAst = draftInfo ? draftInfo.truncatedAst : { ...kclManager.ast }
const _node = getNodeFromPath<Node<CallExpression>>( const _node = getNodeFromPath<Node<CallExpression>>(

View File

@ -30,6 +30,7 @@ import { MouseState, SegmentOverlayPayload } from 'machines/modelingMachine'
import { getAngle, throttle } from 'lib/utils' import { getAngle, throttle } from 'lib/utils'
import { Themes } from 'lib/theme' import { Themes } from 'lib/theme'
import { CSS2DRenderer } from 'three/examples/jsm/renderers/CSS2DRenderer' import { CSS2DRenderer } from 'three/examples/jsm/renderers/CSS2DRenderer'
import { orthoScale, perspScale } from './helpers'
type SendType = ReturnType<typeof useModelingContext>['send'] type SendType = ReturnType<typeof useModelingContext>['send']
@ -49,6 +50,10 @@ export const RAYCASTABLE_PLANE = 'raycastable-plane'
export const X_AXIS = 'xAxis' export const X_AXIS = 'xAxis'
export const Y_AXIS = 'yAxis' 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 AXIS_GROUP = 'axisGroup'
export const SKETCH_GROUP_SEGMENTS = 'sketch-group-segments' export const SKETCH_GROUP_SEGMENTS = 'sketch-group-segments'
export const ARROWHEAD = 'arrowhead' export const ARROWHEAD = 'arrowhead'
@ -60,6 +65,11 @@ export interface OnMouseEnterLeaveArgs {
selected: Object3D<Object3DEventMap> selected: Object3D<Object3DEventMap>
dragSelected?: Object3D<Object3DEventMap> dragSelected?: Object3D<Object3DEventMap>
mouseEvent: MouseEvent mouseEvent: MouseEvent
/** The intersection of the mouse with the THREEjs raycast plane */
intersectionPoint?: {
twoD?: Vector2
threeD?: Vector3
}
} }
interface OnDragCallbackArgs extends OnMouseEnterLeaveArgs { interface OnDragCallbackArgs extends OnMouseEnterLeaveArgs {
@ -348,29 +358,42 @@ export class SceneInfra {
window.removeEventListener('resize', this.onWindowResize) window.removeEventListener('resize', this.onWindowResize)
// Dispose of any other resources like geometries, materials, textures // 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 = (): { getPlaneIntersectPoint = (): {
twoD?: Vector2 twoD?: Vector2
threeD?: Vector3 threeD?: Vector3
intersection: Intersection<Object3D<Object3DEventMap>> intersection: Intersection<Object3D<Object3DEventMap>>
} | null => { } | null => {
// Get the orientations from the camera and mouse position
this.planeRaycaster.setFromCamera( this.planeRaycaster.setFromCamera(
this.currentMouseVector, this.currentMouseVector,
this.camControls.camera this.camControls.camera
) )
// Get the intersection of the ray with the default planes
const planeIntersects = this.planeRaycaster.intersectObjects( const planeIntersects = this.planeRaycaster.intersectObjects(
this.scene.children, this.scene.children,
true 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 (intersect) => intersect.object.name === RAYCASTABLE_PLANE
) )
if (!planeIntersects.length) return null if (!raycastablePlaneIntersection)
if (!recastablePlaneIntersect) return { intersection: planeIntersects[0] } return { intersection: planeIntersects[0] }
const planePosition = planeIntersects[0].object.position const planePosition = raycastablePlaneIntersection.object.position
const inversePlaneQuaternion = planeIntersects[0].object.quaternion const inversePlaneQuaternion =
.clone() raycastablePlaneIntersection.object.quaternion.clone().invert()
.invert() const intersectPoint = raycastablePlaneIntersection.point
const intersectPoint = planeIntersects[0].point
let transformedPoint = intersectPoint.clone() let transformedPoint = intersectPoint.clone()
if (transformedPoint) { if (transformedPoint) {
transformedPoint.applyQuaternion(inversePlaneQuaternion) transformedPoint.applyQuaternion(inversePlaneQuaternion)
@ -447,18 +470,26 @@ export class SceneInfra {
if (intersects[0]) { if (intersects[0]) {
const firstIntersectObject = intersects[0].object const firstIntersectObject = intersects[0].object
const planeIntersectPoint = this.getPlaneIntersectPoint()
const intersectionPoint = {
twoD: planeIntersectPoint?.twoD,
threeD: planeIntersectPoint?.threeD,
}
if (this.hoveredObject !== firstIntersectObject) { if (this.hoveredObject !== firstIntersectObject) {
const hoveredObj = this.hoveredObject const hoveredObj = this.hoveredObject
this.hoveredObject = null this.hoveredObject = null
await this.onMouseLeave({ await this.onMouseLeave({
selected: hoveredObj, selected: hoveredObj,
mouseEvent: mouseEvent, mouseEvent: mouseEvent,
intersectionPoint,
}) })
this.hoveredObject = firstIntersectObject this.hoveredObject = firstIntersectObject
await this.onMouseEnter({ await this.onMouseEnter({
selected: this.hoveredObject, selected: this.hoveredObject,
dragSelected: this.selected?.object, dragSelected: this.selected?.object,
mouseEvent: mouseEvent, mouseEvent: mouseEvent,
intersectionPoint,
}) })
if (!this.selected) if (!this.selected)
this.updateMouseState({ this.updateMouseState({

View File

@ -45,6 +45,7 @@ import {
import { getTangentPointFromPreviousArc } from 'lib/utils2d' import { getTangentPointFromPreviousArc } from 'lib/utils2d'
import { import {
ARROWHEAD, ARROWHEAD,
DRAFT_POINT,
SceneInfra, SceneInfra,
SEGMENT_LENGTH_LABEL, SEGMENT_LENGTH_LABEL,
SEGMENT_LENGTH_LABEL_OFFSET_PX, SEGMENT_LENGTH_LABEL_OFFSET_PX,
@ -686,19 +687,20 @@ class CircleSegment implements SegmentUtils {
export function createProfileStartHandle({ export function createProfileStartHandle({
from, from,
id, isDraft = false,
pathToNode,
scale = 1, scale = 1,
theme, theme,
isSelected, isSelected,
...rest
}: { }: {
from: Coords2d from: Coords2d
id: string
pathToNode: PathToNode
scale?: number scale?: number
theme: Themes theme: Themes
isSelected?: boolean isSelected?: boolean
}) { } & (
| { isDraft: true }
| { isDraft: false; id: string; pathToNode: PathToNode }
)) {
const group = new Group() const group = new Group()
const geometry = new BoxGeometry(12, 12, 12) // in pixels scaled later const geometry = new BoxGeometry(12, 12, 12) // in pixels scaled later
@ -711,13 +713,12 @@ export function createProfileStartHandle({
group.userData = { group.userData = {
type: PROFILE_START, type: PROFILE_START,
id,
from, from,
pathToNode,
isSelected, isSelected,
baseColor, baseColor,
...rest,
} }
group.name = PROFILE_START group.name = isDraft ? DRAFT_POINT : PROFILE_START
group.position.set(from[0], from[1], 0) group.position.set(from[0], from[1], 0)
group.scale.set(scale, scale, scale) group.scale.set(scale, scale, scale)
return group return group

View File

@ -684,6 +684,14 @@ const transformMap: TransformMap = {
tag tag
), ),
}, },
xAbs: {
tooltip: 'lineTo',
createNode: setAbsDistanceCreateNode('x'),
},
yAbs: {
tooltip: 'lineTo',
createNode: setAbsDistanceCreateNode('y'),
},
}, },
xAbsolute: { xAbsolute: {
equalLength: { equalLength: {