#5339 Add tangent snapping to straight segment tool (#5995)

* first draft of making segment snap to previous arc's last tangent

* ability to force/disable line snap, threshold in screen space

* mouseEvent refactor tsc errors fixed

* cleanups, extract getTanPreviousPoint function

* add snap line support when previous segment is ARC

* small cleanups

* remove unused planeNodePath param from onDragSegment

* renaming

* Enable snapping when placing the segment point in onClick

* refactor getSnappedDragPoint to include axis intersection

* handle snapping to both axis and tangent direction

* snap refinements

* small cleanups

* lint

* A snapshot a day keeps the bugs away! 📷🐛

* A snapshot a day keeps the bugs away! 📷🐛

* A snapshot a day keeps the bugs away! 📷🐛

* A snapshot a day keeps the bugs away! 📷🐛

* A snapshot a day keeps the bugs away! 📷🐛

* A snapshot a day keeps the bugs away! 📷🐛

* A snapshot a day keeps the bugs away! 📷🐛

* A snapshot a day keeps the bugs away! 📷🐛

* A snapshot a day keeps the bugs away! 📷🐛

* generate tag for previous arc when snapping current straight segment

* using previous arc's tag in snapped angledLine

* angledLine uses object instead of array now

* use more general snap object instead

* snap tangent line visualized when snapping occurs

* remove unused scale param from createLine

* prettier

* fix bug where segment body is not drawn

* fix generated kcl error introduced in merge from main - modifiedAst needs to be passed to addNewSketchLn

* add support for snapping to negative tangent direction

* fix findTangentDirection for THREE_POINT_ARC_SEGMENT

* fix tsc error by introducing overrideExpr

* fix missing ccw for 3 point arc, fix tan_previous_point calculation for 3 point arcs

* resolve clippy until confirmation for circle radius

* fix runtime error when drawing a 3 point arc

* add unit tests to closestPointoOnRay

* unrelated react warning fixed

* add playwright test for tangent snapping

* better fix for tan_previous_point

* fix lint

* add simulation test for tangent_to_3_point_arc

* Fix simulation test output

* Add missing simulation test output files

* fix tangent snapping bug: use current group instead of last group in activeSegments

* make testcombos.test happy

* cleanup merge

* fix merge mistake, tsc error

* update tangent_to_3_point_arc simulation test

* fix angledLine related breaking tests

* minimum distance added before snapping to tangent

* circle is always ccw regardless of the order of points for tangential info calculation

* fix snapping when different unit is used other than mm

* update test: Straight line snapping to previous tangent

* update rust snapshot test

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Frank Noirot <frank@zoo.dev>
Co-authored-by: Jonathan Tran <jonnytran@gmail.com>
This commit is contained in:
Andrew Varga
2025-04-14 23:51:14 +02:00
committed by GitHub
parent add1b21503
commit d0e9b111af
26 changed files with 1556 additions and 129 deletions

View File

@ -205,6 +205,11 @@ export class ToolbarFixture {
).toBeVisible()
await this.page.getByTestId('dropdown-three-point-arc').click()
}
selectLine = async () => {
await this.page
.getByRole('button', { name: 'line Line', exact: true })
.click()
}
async closePane(paneId: SidebarType) {
return closePane(this.page, paneId + SIDEBAR_BUTTON_SUFFIX)

View File

@ -3034,4 +3034,78 @@ test.describe('Redirecting to home page and back to the original file should cle
await homePage.openProject('testDefault')
await expect(page.getByText('323.49')).not.toBeVisible()
})
test('Straight line snapping to previous tangent', async ({
page,
homePage,
toolbar,
scene,
cmdBar,
context,
editor,
}) => {
await context.addInitScript(() => {
localStorage.setItem('persistCode', `@settings(defaultLengthUnit = mm)`)
})
const viewportSize = { width: 1200, height: 900 }
await page.setBodyDimensions(viewportSize)
await homePage.goToModelingScene()
// wait until scene is ready to be interacted with
await scene.connectionEstablished()
await scene.settled(cmdBar)
await page.getByRole('button', { name: 'Start Sketch' }).click()
// select an axis plane
await page.mouse.click(700, 200)
// Needed as we don't yet have a way to get a signal from the engine that the camera has animated to the sketch plane
await page.waitForTimeout(3000)
const center = { x: viewportSize.width / 2, y: viewportSize.height / 2 }
const { click00r } = getMovementUtils({ center, page })
// Draw line
await click00r(0, 0)
await click00r(200, -200)
// Draw arc
await toolbar.tangentialArcBtn.click()
await click00r(0, 0)
await click00r(100, 100)
// Switch back to line
await toolbar.selectLine()
await click00r(0, 0)
await click00r(-100, 100)
// Draw a 3 point arc
await toolbar.selectThreePointArc()
await click00r(0, 0)
await click00r(0, 100)
await click00r(100, 0)
// draw a line to opposite tangnet direction of previous arc
await toolbar.selectLine()
await click00r(0, 0)
await click00r(-200, 200)
await editor.expectEditor.toContain(
`@settings(defaultLengthUnit = mm)
sketch001 = startSketchOn(XZ)
profile001 = startProfileAt([0, 0], sketch001)
|> line(end = [191.39, 191.39])
|> tangentialArc(endAbsolute = [287.08, 95.69], tag = $seg01)
|> angledLine(angle = tangentToEnd(seg01), length = 135.34)
|> arcTo({
interior = [191.39, -95.69],
end = [287.08, -95.69]
}, %, $seg02)
|> angledLine(angle = tangentToEnd(seg02) + turns::HALF_TURN, length = 270.67)
`.replaceAll('\n', '')
)
})
})

View File

@ -644,7 +644,7 @@ impl GetTangentialInfoFromPathsResult {
pub(crate) fn tan_previous_point(&self, last_arc_end: [f64; 2]) -> [f64; 2] {
match self {
GetTangentialInfoFromPathsResult::PreviousPoint(p) => *p,
GetTangentialInfoFromPathsResult::Arc { center, ccw, .. } => {
GetTangentialInfoFromPathsResult::Arc { center, ccw } => {
crate::std::utils::get_tangent_point_from_previous_arc(*center, *ccw, last_arc_end)
}
// The circle always starts at 0 degrees, so a suitable tangent
@ -1231,12 +1231,9 @@ impl Path {
},
Path::ArcThreePoint { p1, p2, p3, .. } => {
let circle_center = crate::std::utils::calculate_circle_from_3_points([*p1, *p2, *p3]);
let radius = linear_distance(&[circle_center.center[0], circle_center.center[1]], p1);
let center_point = [circle_center.center[0], circle_center.center[1]];
GetTangentialInfoFromPathsResult::Circle {
center: center_point,
ccw: true,
radius,
GetTangentialInfoFromPathsResult::Arc {
center: circle_center.center,
ccw: crate::std::utils::is_points_ccw(&[*p1, *p2, *p3]) > 0,
}
}
Path::Circle {
@ -1252,6 +1249,7 @@ impl Path {
let center_point = [circle_center.center[0], circle_center.center[1]];
GetTangentialInfoFromPathsResult::Circle {
center: center_point,
// Note: a circle is always ccw regardless of the order of points
ccw: true,
radius,
}

View File

@ -2482,6 +2482,7 @@ mod intersect_cubes {
super::execute(TEST_NAME, true).await
}
}
mod pattern_into_union {
const TEST_NAME: &str = "pattern_into_union";
@ -2524,3 +2525,24 @@ mod subtract_doesnt_need_brackets {
super::execute(TEST_NAME, true).await
}
}
mod tangent_to_3_point_arc {
const TEST_NAME: &str = "tangent_to_3_point_arc";
/// Test parsing KCL.
#[test]
fn parse() {
super::parse(TEST_NAME)
}
/// Test that parsing and unparsing KCL produces the original KCL input.
#[tokio::test(flavor = "multi_thread")]
async fn unparse() {
super::unparse(TEST_NAME).await
}
/// Test that KCL is executed correctly.
#[tokio::test(flavor = "multi_thread")]
async fn kcl_test_execute() {
super::execute(TEST_NAME, true).await
}
}

View File

@ -0,0 +1,156 @@
---
source: kcl-lib/src/simulation_tests.rs
description: Artifact commands tangent_to_3_point_arc.kcl
---
[
{
"cmdId": "[uuid]",
"range": [],
"command": {
"type": "edge_lines_visible",
"hidden": false
}
},
{
"cmdId": "[uuid]",
"range": [],
"command": {
"type": "object_visible",
"object_id": "[uuid]",
"hidden": true
}
},
{
"cmdId": "[uuid]",
"range": [],
"command": {
"type": "object_visible",
"object_id": "[uuid]",
"hidden": true
}
},
{
"cmdId": "[uuid]",
"range": [],
"command": {
"type": "make_plane",
"origin": {
"x": 0.0,
"y": 0.0,
"z": 0.0
},
"x_axis": {
"x": 1.0,
"y": 0.0,
"z": 0.0
},
"y_axis": {
"x": 0.0,
"y": 0.0,
"z": 1.0
},
"size": 60.0,
"clobber": false,
"hide": true
}
},
{
"cmdId": "[uuid]",
"range": [],
"command": {
"type": "enable_sketch_mode",
"entity_id": "[uuid]",
"ortho": false,
"animated": false,
"adjust_camera": false,
"planar_normal": {
"x": 0.0,
"y": -1.0,
"z": 0.0
}
}
},
{
"cmdId": "[uuid]",
"range": [],
"command": {
"type": "start_path"
}
},
{
"cmdId": "[uuid]",
"range": [],
"command": {
"type": "move_path_pen",
"path": "[uuid]",
"to": {
"x": 100.0,
"y": 0.0,
"z": 0.0
}
}
},
{
"cmdId": "[uuid]",
"range": [],
"command": {
"type": "sketch_mode_disable"
}
},
{
"cmdId": "[uuid]",
"range": [],
"command": {
"type": "extend_path",
"path": "[uuid]",
"segment": {
"type": "line",
"end": {
"x": 0.0,
"y": 120.0,
"z": 0.0
},
"relative": true
}
}
},
{
"cmdId": "[uuid]",
"range": [],
"command": {
"type": "extend_path",
"path": "[uuid]",
"segment": {
"type": "arc_to",
"interior": {
"x": 300.0,
"y": 100.0,
"z": 0.0
},
"end": {
"x": 200.0,
"y": -100.0,
"z": 0.0
},
"relative": false
}
}
},
{
"cmdId": "[uuid]",
"range": [],
"command": {
"type": "extend_path",
"path": "[uuid]",
"segment": {
"type": "line",
"end": {
"x": -99.8038,
"y": -6.2608,
"z": 0.0
},
"relative": true
}
}
}
]

View File

@ -0,0 +1,6 @@
---
source: kcl-lib/src/simulation_tests.rs
description: Artifact graph flowchart tangent_to_3_point_arc.kcl
extension: md
snapshot_kind: binary
---

View File

@ -0,0 +1,14 @@
```mermaid
flowchart LR
subgraph path2 [Path]
2["Path<br>[43, 82, 0]"]
3["Segment<br>[88, 112, 0]"]
4["Segment<br>[118, 209, 0]"]
5["Segment<br>[215, 292, 0]"]
end
1["Plane<br>[12, 29, 0]"]
1 --- 2
2 --- 3
2 --- 4
2 --- 5
```

View File

@ -0,0 +1,490 @@
---
source: kcl-lib/src/simulation_tests.rs
description: Result of parsing tangent_to_3_point_arc.kcl
---
{
"Ok": {
"body": [
{
"commentStart": 0,
"declaration": {
"commentStart": 0,
"end": 0,
"id": {
"commentStart": 0,
"end": 0,
"name": "sketch001",
"start": 0,
"type": "Identifier"
},
"init": {
"arguments": [
{
"abs_path": false,
"commentStart": 0,
"end": 0,
"name": {
"commentStart": 0,
"end": 0,
"name": "XZ",
"start": 0,
"type": "Identifier"
},
"path": [],
"start": 0,
"type": "Name",
"type": "Name"
}
],
"callee": {
"abs_path": false,
"commentStart": 0,
"end": 0,
"name": {
"commentStart": 0,
"end": 0,
"name": "startSketchOn",
"start": 0,
"type": "Identifier"
},
"path": [],
"start": 0,
"type": "Name"
},
"commentStart": 0,
"end": 0,
"start": 0,
"type": "CallExpression",
"type": "CallExpression"
},
"start": 0,
"type": "VariableDeclarator"
},
"end": 0,
"kind": "const",
"start": 0,
"type": "VariableDeclaration",
"type": "VariableDeclaration"
},
{
"commentStart": 0,
"declaration": {
"commentStart": 0,
"end": 0,
"id": {
"commentStart": 0,
"end": 0,
"name": "profile001",
"start": 0,
"type": "Identifier"
},
"init": {
"body": [
{
"arguments": [
{
"commentStart": 0,
"elements": [
{
"commentStart": 0,
"end": 0,
"raw": "100.0",
"start": 0,
"type": "Literal",
"type": "Literal",
"value": {
"value": 100.0,
"suffix": "None"
}
},
{
"commentStart": 0,
"end": 0,
"raw": "0.0",
"start": 0,
"type": "Literal",
"type": "Literal",
"value": {
"value": 0.0,
"suffix": "None"
}
}
],
"end": 0,
"start": 0,
"type": "ArrayExpression",
"type": "ArrayExpression"
},
{
"abs_path": false,
"commentStart": 0,
"end": 0,
"name": {
"commentStart": 0,
"end": 0,
"name": "sketch001",
"start": 0,
"type": "Identifier"
},
"path": [],
"start": 0,
"type": "Name",
"type": "Name"
}
],
"callee": {
"abs_path": false,
"commentStart": 0,
"end": 0,
"name": {
"commentStart": 0,
"end": 0,
"name": "startProfileAt",
"start": 0,
"type": "Identifier"
},
"path": [],
"start": 0,
"type": "Name"
},
"commentStart": 0,
"end": 0,
"start": 0,
"type": "CallExpression",
"type": "CallExpression"
},
{
"arguments": [
{
"type": "LabeledArg",
"label": {
"commentStart": 0,
"end": 0,
"name": "end",
"start": 0,
"type": "Identifier"
},
"arg": {
"commentStart": 0,
"elements": [
{
"commentStart": 0,
"end": 0,
"raw": "0.0",
"start": 0,
"type": "Literal",
"type": "Literal",
"value": {
"value": 0.0,
"suffix": "None"
}
},
{
"commentStart": 0,
"end": 0,
"raw": "120.0",
"start": 0,
"type": "Literal",
"type": "Literal",
"value": {
"value": 120.0,
"suffix": "None"
}
}
],
"end": 0,
"start": 0,
"type": "ArrayExpression",
"type": "ArrayExpression"
}
}
],
"callee": {
"abs_path": false,
"commentStart": 0,
"end": 0,
"name": {
"commentStart": 0,
"end": 0,
"name": "line",
"start": 0,
"type": "Identifier"
},
"path": [],
"start": 0,
"type": "Name"
},
"commentStart": 0,
"end": 0,
"start": 0,
"type": "CallExpressionKw",
"type": "CallExpressionKw",
"unlabeled": null
},
{
"arguments": [
{
"commentStart": 0,
"end": 0,
"properties": [
{
"commentStart": 0,
"end": 0,
"key": {
"commentStart": 0,
"end": 0,
"name": "interior",
"start": 0,
"type": "Identifier"
},
"start": 0,
"type": "ObjectProperty",
"value": {
"commentStart": 0,
"elements": [
{
"commentStart": 0,
"end": 0,
"raw": "300.0",
"start": 0,
"type": "Literal",
"type": "Literal",
"value": {
"value": 300.0,
"suffix": "None"
}
},
{
"commentStart": 0,
"end": 0,
"raw": "100.0",
"start": 0,
"type": "Literal",
"type": "Literal",
"value": {
"value": 100.0,
"suffix": "None"
}
}
],
"end": 0,
"start": 0,
"type": "ArrayExpression",
"type": "ArrayExpression"
}
},
{
"commentStart": 0,
"end": 0,
"key": {
"commentStart": 0,
"end": 0,
"name": "end",
"start": 0,
"type": "Identifier"
},
"start": 0,
"type": "ObjectProperty",
"value": {
"commentStart": 0,
"elements": [
{
"commentStart": 0,
"end": 0,
"raw": "200.00",
"start": 0,
"type": "Literal",
"type": "Literal",
"value": {
"value": 200.0,
"suffix": "None"
}
},
{
"argument": {
"commentStart": 0,
"end": 0,
"raw": "100.00",
"start": 0,
"type": "Literal",
"type": "Literal",
"value": {
"value": 100.0,
"suffix": "None"
}
},
"commentStart": 0,
"end": 0,
"operator": "-",
"start": 0,
"type": "UnaryExpression",
"type": "UnaryExpression"
}
],
"end": 0,
"start": 0,
"type": "ArrayExpression",
"type": "ArrayExpression"
}
}
],
"start": 0,
"type": "ObjectExpression",
"type": "ObjectExpression"
},
{
"commentStart": 0,
"end": 0,
"start": 0,
"type": "PipeSubstitution",
"type": "PipeSubstitution"
},
{
"commentStart": 0,
"end": 0,
"start": 0,
"type": "TagDeclarator",
"type": "TagDeclarator",
"value": "seg01"
}
],
"callee": {
"abs_path": false,
"commentStart": 0,
"end": 0,
"name": {
"commentStart": 0,
"end": 0,
"name": "arcTo",
"start": 0,
"type": "Identifier"
},
"path": [],
"start": 0,
"type": "Name"
},
"commentStart": 0,
"end": 0,
"start": 0,
"type": "CallExpression",
"type": "CallExpression"
},
{
"arguments": [
{
"type": "LabeledArg",
"label": {
"commentStart": 0,
"end": 0,
"name": "angle",
"start": 0,
"type": "Identifier"
},
"arg": {
"arguments": [
{
"abs_path": false,
"commentStart": 0,
"end": 0,
"name": {
"commentStart": 0,
"end": 0,
"name": "seg01",
"start": 0,
"type": "Identifier"
},
"path": [],
"start": 0,
"type": "Name",
"type": "Name"
}
],
"callee": {
"abs_path": false,
"commentStart": 0,
"end": 0,
"name": {
"commentStart": 0,
"end": 0,
"name": "tangentToEnd",
"start": 0,
"type": "Identifier"
},
"path": [],
"start": 0,
"type": "Name"
},
"commentStart": 0,
"end": 0,
"start": 0,
"type": "CallExpression",
"type": "CallExpression"
}
},
{
"type": "LabeledArg",
"label": {
"commentStart": 0,
"end": 0,
"name": "length",
"start": 0,
"type": "Identifier"
},
"arg": {
"commentStart": 0,
"end": 0,
"raw": "100.00",
"start": 0,
"type": "Literal",
"type": "Literal",
"value": {
"value": 100.0,
"suffix": "None"
}
}
}
],
"callee": {
"abs_path": false,
"commentStart": 0,
"end": 0,
"name": {
"commentStart": 0,
"end": 0,
"name": "angledLine",
"start": 0,
"type": "Identifier"
},
"path": [],
"start": 0,
"type": "Name"
},
"commentStart": 0,
"end": 0,
"start": 0,
"type": "CallExpressionKw",
"type": "CallExpressionKw",
"unlabeled": null
}
],
"commentStart": 0,
"end": 0,
"start": 0,
"type": "PipeExpression",
"type": "PipeExpression"
},
"start": 0,
"type": "VariableDeclarator"
},
"end": 0,
"kind": "const",
"start": 0,
"type": "VariableDeclaration",
"type": "VariableDeclaration"
}
],
"commentStart": 0,
"end": 0,
"start": 0
}
}

View File

@ -0,0 +1,11 @@
sketch001 = startSketchOn(XZ)
profile001 = startProfileAt([100.0, 0.0], sketch001)
|> line(end = [0.0, 120.0])
|> arcTo({
interior = [300.0, 100.0],
end = [200.00, -100.00]
}, %, $seg01)
|> angledLine(
angle = tangentToEnd(seg01),
length = 100.00
)

View File

@ -0,0 +1,21 @@
---
source: kcl-lib/src/simulation_tests.rs
description: Operations executed tangent_to_3_point_arc.kcl
---
[
{
"labeledArgs": {
"planeOrSolid": {
"value": {
"type": "Plane",
"artifact_id": "[uuid]"
},
"sourceRange": []
}
},
"name": "startSketchOn",
"sourceRange": [],
"type": "StdLibCall",
"unlabeledArg": null
}
]

View File

@ -0,0 +1,208 @@
---
source: kcl-lib/src/simulation_tests.rs
description: Variables in memory after executing tangent_to_3_point_arc.kcl
---
{
"profile001": {
"type": "Sketch",
"value": {
"type": "Sketch",
"id": "[uuid]",
"paths": [
{
"__geoMeta": {
"id": "[uuid]",
"sourceRange": []
},
"from": [
100.0,
0.0
],
"tag": null,
"to": [
100.0,
120.0
],
"type": "ToPoint",
"units": {
"type": "Mm"
}
},
{
"__geoMeta": {
"id": "[uuid]",
"sourceRange": []
},
"from": [
100.0,
120.0
],
"p1": [
100.0,
120.0
],
"p2": [
300.0,
100.0
],
"p3": [
200.0,
-100.0
],
"tag": {
"commentStart": 202,
"end": 208,
"start": 202,
"type": "TagDeclarator",
"value": "seg01"
},
"to": [
200.0,
-100.0
],
"type": "ArcThreePoint",
"units": {
"type": "Mm"
}
},
{
"__geoMeta": {
"id": "[uuid]",
"sourceRange": []
},
"from": [
200.0,
-100.0
],
"tag": null,
"to": [
100.1962,
-106.2608
],
"type": "ToPoint",
"units": {
"type": "Mm"
}
}
],
"on": {
"type": "plane",
"id": "[uuid]",
"artifactId": "[uuid]",
"value": "XZ",
"origin": {
"x": 0.0,
"y": 0.0,
"z": 0.0,
"units": {
"type": "Mm"
}
},
"xAxis": {
"x": 1.0,
"y": 0.0,
"z": 0.0,
"units": {
"type": "Mm"
}
},
"yAxis": {
"x": 0.0,
"y": 0.0,
"z": 1.0,
"units": {
"type": "Mm"
}
},
"zAxis": {
"x": 0.0,
"y": -1.0,
"z": 0.0,
"units": {
"type": "Mm"
}
},
"units": {
"type": "Mm"
}
},
"start": {
"from": [
100.0,
0.0
],
"to": [
100.0,
0.0
],
"units": {
"type": "Mm"
},
"tag": null,
"__geoMeta": {
"id": "[uuid]",
"sourceRange": []
}
},
"tags": {
"seg01": {
"type": "TagIdentifier",
"value": "seg01"
}
},
"artifactId": "[uuid]",
"originalId": "[uuid]",
"units": {
"type": "Mm"
}
}
},
"seg01": {
"type": "TagIdentifier",
"type": "TagIdentifier",
"value": "seg01"
},
"sketch001": {
"type": "Plane",
"value": {
"id": "[uuid]",
"artifactId": "[uuid]",
"value": "XZ",
"origin": {
"x": 0.0,
"y": 0.0,
"z": 0.0,
"units": {
"type": "Mm"
}
},
"xAxis": {
"x": 1.0,
"y": 0.0,
"z": 0.0,
"units": {
"type": "Mm"
}
},
"yAxis": {
"x": 0.0,
"y": 0.0,
"z": 1.0,
"units": {
"type": "Mm"
}
},
"zAxis": {
"x": 0.0,
"y": -1.0,
"z": 0.0,
"units": {
"type": "Mm"
}
},
"units": {
"type": "Mm"
}
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

View File

@ -0,0 +1,12 @@
---
source: kcl-lib/src/simulation_tests.rs
description: Result of unparsing tangent_to_3_point_arc.kcl
---
sketch001 = startSketchOn(XZ)
profile001 = startProfileAt([100.0, 0.0], sketch001)
|> line(end = [0.0, 120.0])
|> arcTo({
interior = [300.0, 100.0],
end = [200.0, -100.0]
}, %, $seg01)
|> angledLine(angle = tangentToEnd(seg01), length = 100.0)

View File

@ -9,6 +9,7 @@ export const ARC_SEGMENT_DASH = 'arc-segment-dash'
export const STRAIGHT_SEGMENT = 'straight-segment'
export const STRAIGHT_SEGMENT_BODY = 'straight-segment-body'
export const STRAIGHT_SEGMENT_DASH = 'straight-segment-body-dashed'
export const STRAIGHT_SEGMENT_SNAP_LINE = 'straight-segment-snap-line'
export const CIRCLE_SEGMENT = 'circle-segment'
export const CIRCLE_SEGMENT_BODY = 'circle-segment-body'
export const CIRCLE_SEGMENT_DASH = 'circle-segment-body-dashed'
@ -62,6 +63,12 @@ export const SEGMENT_BODIES_PLUS_PROFILE_START = [
PROFILE_START,
]
export const ARC_SEGMENT_TYPES = [
TANGENTIAL_ARC_TO_SEGMENT,
THREE_POINT_ARC_SEGMENT,
ARC_SEGMENT,
]
// Helper functions
export function getParentGroup(
object: any,

View File

@ -5,6 +5,7 @@ import type {
Object3DEventMap,
Quaternion,
} from 'three'
import {
BoxGeometry,
DoubleSide,
@ -36,7 +37,8 @@ import type { Sketch } from '@rust/kcl-lib/bindings/Sketch'
import type { SourceRange } from '@rust/kcl-lib/bindings/SourceRange'
import type { VariableDeclaration } from '@rust/kcl-lib/bindings/VariableDeclaration'
import type { VariableDeclarator } from '@rust/kcl-lib/bindings/VariableDeclarator'
import { uuidv4 } from '@src/lib/utils'
import type { SafeArray } from '@src/lib/utils'
import { getAngle, getLength, uuidv4 } from '@src/lib/utils'
import {
createGridHelper,
@ -48,6 +50,7 @@ import {
import {
ARC_ANGLE_END,
ARC_SEGMENT,
ARC_SEGMENT_TYPES,
CIRCLE_CENTER_HANDLE,
CIRCLE_SEGMENT,
CIRCLE_THREE_POINT_HANDLE1,
@ -56,6 +59,7 @@ import {
CIRCLE_THREE_POINT_SEGMENT,
DRAFT_DASHED_LINE,
EXTRA_SEGMENT_HANDLE,
getParentGroup,
PROFILE_START,
SEGMENT_BODIES,
SEGMENT_BODIES_PLUS_PROFILE_START,
@ -66,31 +70,32 @@ import {
THREE_POINT_ARC_HANDLE2,
THREE_POINT_ARC_HANDLE3,
THREE_POINT_ARC_SEGMENT,
getParentGroup,
} from '@src/clientSideScene/sceneConstants'
import type {
OnClickCallbackArgs,
OnMouseEnterLeaveArgs,
SceneInfra,
} from '@src/clientSideScene/sceneInfra'
import {
ANGLE_SNAP_THRESHOLD_DEGREES,
ARROWHEAD,
AXIS_GROUP,
DRAFT_POINT,
DRAFT_POINT_GROUP,
getSceneScale,
INTERSECTION_PLANE_LAYER,
RAYCASTABLE_PLANE,
SKETCH_GROUP_SEGMENTS,
SKETCH_LAYER,
X_AXIS,
Y_AXIS,
getSceneScale,
} from '@src/clientSideScene/sceneUtils'
import type { SegmentUtils } from '@src/clientSideScene/segments'
import {
createProfileStartHandle,
dashedStraight,
getTanPreviousPoint,
segmentUtils,
} from '@src/clientSideScene/segments'
import type EditorManager from '@src/editor/manager'
@ -118,6 +123,7 @@ import {
insertNewStartProfileAt,
updateSketchNodePathsWithInsertIndex,
} from '@src/lang/modifyAst'
import { mutateAstWithTagForSketchSegment } from '@src/lang/modifyAst/addEdgeTreatment'
import { getNodeFromPath } from '@src/lang/queryAst'
import { getNodePathFromSourceRange } from '@src/lang/queryAstNodePathUtils'
import {
@ -138,6 +144,7 @@ import { topLevelRange } from '@src/lang/util'
import type { PathToNode, VariableMap } from '@src/lang/wasm'
import {
defaultSourceRange,
getTangentialArcToInfo,
parse,
recast,
resultIsOk,
@ -157,12 +164,14 @@ import type { Themes } from '@src/lib/theme'
import { getThemeColorForThreeJs } from '@src/lib/theme'
import { err, reportRejection, trap } from '@src/lib/trap'
import { isArray, isOverlap, roundOff } from '@src/lib/utils'
import { closestPointOnRay, deg2Rad } from '@src/lib/utils2d'
import type {
SegmentOverlayPayload,
SketchDetails,
SketchDetailsUpdate,
SketchTool,
} from '@src/machines/modelingMachine'
import { calculateIntersectionOfTwoLines } from 'sketch-helpers'
type DraftSegment = 'line' | 'tangentialArc'
@ -183,6 +192,7 @@ export class SceneEntities {
axisGroup: Group | null = null
draftPointGroups: Group[] = []
currentSketchQuaternion: Quaternion | null = null
constructor(
engineCommandManager: EngineCommandManager,
sceneInfra: SceneInfra,
@ -344,6 +354,7 @@ export class SceneEntities {
sceneInfra.scene.add(intersectionPlane)
return intersectionPlane
}
createSketchAxis(
sketchPathToNode: PathToNode,
forward: [number, number, number],
@ -423,9 +434,11 @@ export class SceneEntities {
sketchPosition && this.axisGroup.position.set(...sketchPosition)
this.sceneInfra.scene.add(this.axisGroup)
}
getDraftPoint() {
return this.sceneInfra.scene.getObjectByName(DRAFT_POINT)
}
createDraftPoint({
point,
origin,
@ -857,6 +870,7 @@ export class SceneEntities {
variableDeclarationName,
}
}
updateAstAndRejigSketch = async (
sketchEntryNodePath: PathToNode,
sketchNodePaths: PathToNode[],
@ -1016,22 +1030,25 @@ 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] || sketch.start
let {
snappedPoint,
snappedToTangent,
intersectsXAxis,
intersectsYAxis,
negativeTangentDirection,
} = this.getSnappedDragPoint(
intersection2d,
args.intersects,
args.mouseEvent,
Object.values(this.activeSegments).at(-1)
)
const lastSegment = sketch.paths.slice(-1)[0] || sketch.start
const snappedPoint = {
x: intersectsYAxis ? 0 : intersection2d.x,
y: intersectsXAxis ? 0 : intersection2d.y,
}
// Get the angle between the previous segment (or sketch start)'s end and this one's
const angle = Math.atan2(
snappedPoint.y - lastSegment.to[1],
snappedPoint.x - lastSegment.to[0]
snappedPoint[1] - lastSegment.to[1],
snappedPoint[0] - lastSegment.to[0]
)
const isHorizontal =
@ -1043,6 +1060,12 @@ export class SceneEntities {
ANGLE_SNAP_THRESHOLD_DEGREES
let resolvedFunctionName: ToolTip = 'line'
const snaps = {
previousArcTag: '',
negativeTangentDirection,
xAxis: !!intersectsXAxis,
yAxis: !!intersectsYAxis,
}
// This might need to become its own function if we want more
// case-based logic for different segment types
@ -1051,27 +1074,46 @@ export class SceneEntities {
segmentName === 'tangentialArc'
) {
resolvedFunctionName = 'tangentialArc'
} else if (snappedToTangent) {
// Generate tag for previous arc segment and use it for the angle of angledLine:
// |> tangentialArcTo([5, -10], %, $arc001)
// |> angledLine({ angle = tangentToEnd(arc001), length = 12 }, %)
const previousSegmentPathToNode = getNodePathFromSourceRange(
modifiedAst,
sourceRangeFromRust(lastSegment.__geoMeta.sourceRange)
)
const taggedAstResult = mutateAstWithTagForSketchSegment(
modifiedAst,
previousSegmentPathToNode
)
if (trap(taggedAstResult)) return Promise.reject(taggedAstResult)
modifiedAst = taggedAstResult.modifiedAst
snaps.previousArcTag = taggedAstResult.tag
resolvedFunctionName = 'angledLine'
} else if (isHorizontal) {
// If the angle between is 0 or 180 degrees (+/- the snapping angle), make the line an xLine
resolvedFunctionName = 'xLine'
} else if (isVertical) {
// If the angle between is 90 or 270 degrees (+/- the snapping angle), make the line a yLine
resolvedFunctionName = 'yLine'
} else if (snappedPoint.x === 0 || snappedPoint.y === 0) {
} else if (snappedPoint[0] === 0 || snappedPoint[1] === 0) {
// We consider a point placed on axes or origin to be absolute
resolvedFunctionName = 'lineTo'
}
const tmp = addNewSketchLn({
node: this.kclManager.ast,
node: modifiedAst,
variables: this.kclManager.variables,
input: {
type: 'straight-segment',
from: [lastSegment.to[0], lastSegment.to[1]],
to: [snappedPoint.x, snappedPoint.y],
to: [snappedPoint[0], snappedPoint[1]],
},
fnName: resolvedFunctionName,
pathToNode: sketchEntryNodePath,
snaps,
})
if (trap(tmp)) return Promise.reject(tmp)
modifiedAst = tmp.modifiedAst
@ -1118,11 +1160,11 @@ export class SceneEntities {
intersects: args.intersects,
sketchNodePaths,
sketchEntryNodePath,
planeNodePath,
draftInfo: {
truncatedAst,
variableDeclarationName,
},
mouseEvent: args.mouseEvent,
})
},
})
@ -1263,10 +1305,11 @@ export class SceneEntities {
const { intersectionPoint } = args
if (!intersectionPoint?.twoD) return
const { snappedPoint, isSnapped } = this.getSnappedDragPoint({
intersection2d: intersectionPoint.twoD,
intersects: args.intersects,
})
const { snappedPoint, isSnapped } = this.getSnappedDragPoint(
intersectionPoint.twoD,
args.intersects,
args.mouseEvent
)
if (isSnapped) {
this.positionDraftPoint({
snappedPoint: new Vector2(...snappedPoint),
@ -2046,10 +2089,11 @@ export class SceneEntities {
if (trap(_node)) return
const sketchInit = _node.node.declaration.init
const maybeSnapToAxis = this.getSnappedDragPoint({
intersection2d: args.intersectionPoint.twoD,
intersects: args.intersects,
}).snappedPoint
const maybeSnapToAxis = this.getSnappedDragPoint(
args.intersectionPoint.twoD,
args.intersects,
args.mouseEvent
).snappedPoint
const maybeSnapToProfileStart = doNotSnapAsThreePointArcIsTheOnlySegment
? new Vector2(...maybeSnapToAxis)
@ -2148,10 +2192,11 @@ export class SceneEntities {
type: 'circle-three-point-segment',
p1,
p2,
p3: this.getSnappedDragPoint({
intersection2d: args.intersectionPoint.twoD,
intersects: args.intersects,
}).snappedPoint,
p3: this.getSnappedDragPoint(
args.intersectionPoint.twoD,
args.intersects,
args.mouseEvent
).snappedPoint,
}
)
if (err(moddedResult)) return
@ -2524,10 +2569,10 @@ export class SceneEntities {
this.onDragSegment({
sketchNodePaths,
sketchEntryNodePath: pathToNodeForNewSegment,
planeNodePath,
object: selected,
intersection2d: intersectionPoint.twoD,
intersects,
mouseEvent: mouseEvent,
})
}
return
@ -2536,10 +2581,10 @@ export class SceneEntities {
this.onDragSegment({
object: selected,
intersection2d: intersectionPoint.twoD,
planeNodePath,
intersects,
sketchNodePaths,
sketchEntryNodePath,
mouseEvent: mouseEvent,
})
},
onMove: () => {},
@ -2578,13 +2623,19 @@ export class SceneEntities {
this.kclManager.lastSuccessfulVariables,
draftSegment
)
getSnappedDragPoint({
intersects,
intersection2d,
}: {
intersects: Intersection<Object3D<Object3DEventMap>>[]
intersection2d: Vector2
}): { snappedPoint: [number, number]; isSnapped: boolean } {
getSnappedDragPoint(
pos: Vector2,
intersects: Intersection<Object3D<Object3DEventMap>>[],
mouseEvent: MouseEvent,
// During draft segment mouse move:
// - the three.js object currently being dragged: the new draft segment or existing segment (may not be the last in activeSegments)
// When placing the draft segment::
// - the last segment in activeSegments
currentObject?: Object3D | Group
) {
let snappedPoint: Coords2d = [pos.x, pos.y]
const intersectsYAxis = intersects.find(
(sceneObject) => sceneObject.object.name === Y_AXIS
)
@ -2592,16 +2643,116 @@ export class SceneEntities {
(sceneObject) => sceneObject.object.name === X_AXIS
)
const snappedPoint = new Vector2(
intersectsYAxis ? 0 : intersection2d.x,
intersectsXAxis ? 0 : intersection2d.y
// Snap to previous segment's tangent direction when drawing a straight segment
let snappedToTangent = false
let negativeTangentDirection = false
const disableTangentSnapping = mouseEvent.ctrlKey || mouseEvent.altKey
const forceDirectionSnapping = mouseEvent.shiftKey
if (!disableTangentSnapping) {
const segments: SafeArray<Group> = Object.values(this.activeSegments) // Using the order in the object feels wrong
const currentIndex =
currentObject instanceof Group ? segments.indexOf(currentObject) : -1
const current = segments[currentIndex]
if (
current?.userData.type === STRAIGHT_SEGMENT &&
// This draft check is not strictly necessary currently, but we only want
// to snap when drawing a new segment, this makes that more robust.
current?.userData.draft
) {
const prev = segments[currentIndex - 1]
if (prev && ARC_SEGMENT_TYPES.includes(prev.userData.type)) {
const snapDirection = findTangentDirection(prev)
if (snapDirection) {
const SNAP_TOLERANCE_PIXELS = 12 * window.devicePixelRatio
const SNAP_MIN_DISTANCE_PIXELS = 5 * window.devicePixelRatio
const orthoFactor = orthoScale(this.sceneInfra.camControls.camera)
// See if snapDirection intersects with any of the axes
if (intersectsXAxis || intersectsYAxis) {
let intersectionPoint: Coords2d | undefined
if (intersectsXAxis && intersectsYAxis) {
// Current mouse position intersects with both axes (origin) -> that has precedence over tangent so we snap to the origin.
intersectionPoint = [0, 0]
} else {
// Intersects only one axis
const axisLine: [Coords2d, Coords2d] = intersectsXAxis
? [
[0, 0],
[1, 0],
]
: [
[0, 0],
[0, 1],
]
// See if that axis line intersects with the tangent direction
// Note: this includes both positive and negative tangent directions as it just checks 2 lines.
intersectionPoint = calculateIntersectionOfTwoLines({
line1: axisLine,
line2Angle: getAngle([0, 0], snapDirection),
line2Point: current.userData.from,
})
}
// If yes, see if that intersection point is within tolerance and if yes snap to it.
if (
intersectionPoint &&
getLength(intersectionPoint, snappedPoint) / orthoFactor <
SNAP_TOLERANCE_PIXELS
) {
snappedPoint = intersectionPoint
snappedToTangent = true
}
}
if (!snappedToTangent) {
// Otherwise, try to snap to the tangent direction, in both positive and negative directions
const { closestPoint, t } = closestPointOnRay(
prev.userData.to,
snapDirection,
snappedPoint,
true
)
if (
forceDirectionSnapping ||
(this.sceneInfra.screenSpaceDistance(
closestPoint,
snappedPoint
) < SNAP_TOLERANCE_PIXELS &&
// We only want to snap to the tangent direction if the mouse has moved enough to avoid quick jumps
// at the beginning of the drag
this.sceneInfra.screenSpaceDistance(
current.userData.from,
current.userData.to
) > SNAP_MIN_DISTANCE_PIXELS)
) {
snappedPoint = closestPoint
snappedToTangent = true
negativeTangentDirection = t < 0
}
}
}
}
}
}
// Snap to the main axes if there was no snapping to tangent direction
if (!snappedToTangent) {
snappedPoint = [
intersectsYAxis ? 0 : snappedPoint[0],
intersectsXAxis ? 0 : snappedPoint[1],
]
}
return {
snappedPoint: [snappedPoint.x, snappedPoint.y],
isSnapped: !!(intersectsYAxis || intersectsXAxis),
isSnapped: !!(intersectsYAxis || intersectsXAxis || snappedToTangent),
snappedToTangent,
negativeTangentDirection,
snappedPoint,
intersectsXAxis,
intersectsYAxis,
}
}
positionDraftPoint({
origin,
yAxis,
@ -2634,6 +2785,7 @@ export class SceneEntities {
draftPoint.position.set(snappedPoint.x, snappedPoint.y, 0)
}
}
maybeSnapProfileStartIntersect2d({
sketchEntryNodePath,
intersects,
@ -2654,25 +2806,26 @@ export class SceneEntities {
: _intersection2d
return intersection2d
}
onDragSegment({
object,
intersection2d: _intersection2d,
sketchEntryNodePath,
sketchNodePaths,
planeNodePath,
draftInfo,
intersects,
mouseEvent,
}: {
object: Object3D<Object3DEventMap>
intersection2d: Vector2
sketchEntryNodePath: PathToNode
sketchNodePaths: PathToNode[]
planeNodePath: PathToNode
intersects: Intersection<Object3D<Object3DEventMap>>[]
draftInfo?: {
truncatedAst: Node<Program>
variableDeclarationName: string
}
mouseEvent: MouseEvent
}) {
const intersection2d = this.maybeSnapProfileStartIntersect2d({
sketchEntryNodePath,
@ -2705,10 +2858,12 @@ export class SceneEntities {
group.userData?.from?.[0],
group.userData?.from?.[1],
]
const dragTo = this.getSnappedDragPoint({
intersects,
const { snappedPoint: dragTo, snappedToTangent } = this.getSnappedDragPoint(
intersection2d,
}).snappedPoint
intersects,
mouseEvent,
object
)
let modifiedAst = draftInfo
? draftInfo.truncatedAst
: { ...this.kclManager.ast }
@ -2938,7 +3093,8 @@ export class SceneEntities {
varDecIndex,
modifiedAst,
orthoFactor,
sketch
sketch,
snappedToTangent
)
callBacks.push(
@ -2949,7 +3105,8 @@ export class SceneEntities {
varDecIndex,
modifiedAst,
orthoFactor,
sketch
sketch,
snappedToTangent
)
)
)
@ -2967,6 +3124,7 @@ export class SceneEntities {
* @param modifiedAst
* @param orthoFactor
* @param sketch
* @param snappedToTangent if currently drawn draft segment is snapping to previous arc tangent
*/
updateSegment = (
segment: Path | Sketch['start'],
@ -2974,7 +3132,8 @@ export class SceneEntities {
varDecIndex: number,
modifiedAst: Program,
orthoFactor: number,
sketch: Sketch
sketch: Sketch,
snappedToTangent: boolean = false
): (() => SegmentOverlayPayload | null) => {
const segPathToNode = getNodePathFromSourceRange(
modifiedAst,
@ -2998,6 +3157,7 @@ export class SceneEntities {
type: 'straight-segment',
from: segment.from,
to: segment.to,
snap: snappedToTangent,
}
let update: SegmentUtils['update'] | null = null
if (type === TANGENTIAL_ARC_TO_SEGMENT) {
@ -3095,9 +3255,11 @@ export class SceneEntities {
})
})
}
removeSketchGrid() {
if (this.axisGroup) this.sceneInfra.scene.remove(this.axisGroup)
}
tearDownSketch({ removeAxis = true }: { removeAxis?: boolean }) {
// Remove all draft groups
this.draftPointGroups.forEach((draftPointGroup) => {
@ -3125,6 +3287,7 @@ export class SceneEntities {
this.sceneInfra.camControls.enableRotate = true
this.activeSegments = {}
}
mouseEnterLeaveCallbacks() {
return {
onMouseEnter: ({ selected, dragSelected }: OnMouseEnterLeaveArgs) => {
@ -3333,6 +3496,7 @@ export class SceneEntities {
},
}
}
resetOverlays() {
this.sceneInfra.modelingSend({
type: 'Set Segment Overlays',
@ -3713,6 +3877,7 @@ function getSketchesInfo({
}
return sketchesInfo
}
/**
* Given a SourceRange [x,y,boolean] create a Selections object which contains
* graphSelections with the artifact and codeRef.
@ -3747,3 +3912,36 @@ function isGroupStartProfileForCurrentProfile(sketchEntryNodePath: PathToNode) {
return isProfileStartOfCurrentExpr
}
}
// Returns the 2D tangent direction vector at the end of the segmentGroup if it's an arc.
function findTangentDirection(segmentGroup: Group) {
let tangentDirection: Coords2d | undefined
if (segmentGroup.userData.type === TANGENTIAL_ARC_TO_SEGMENT) {
const prevSegment = segmentGroup.userData.prevSegment
const arcInfo = getTangentialArcToInfo({
arcStartPoint: segmentGroup.userData.from,
arcEndPoint: segmentGroup.userData.to,
tanPreviousPoint: getTanPreviousPoint(prevSegment),
obtuse: true,
})
const tangentAngle =
arcInfo.endAngle + (Math.PI / 2) * (arcInfo.ccw ? 1 : -1)
tangentDirection = [Math.cos(tangentAngle), Math.sin(tangentAngle)]
} else if (
segmentGroup.userData.type === ARC_SEGMENT ||
segmentGroup.userData.type === THREE_POINT_ARC_SEGMENT
) {
const tangentAngle =
deg2Rad(
getAngle(segmentGroup.userData.center, segmentGroup.userData.to)
) +
(Math.PI / 2) * (segmentGroup.userData.ccw ? 1 : -1)
tangentDirection = [Math.cos(tangentAngle), Math.sin(tangentAngle)]
} else {
console.warn(
'Unsupported segment type for tangent direction calculation: ',
segmentGroup.userData.type
)
}
return tangentDirection
}

View File

@ -2,7 +2,6 @@ import * as TWEEN from '@tweenjs/tween.js'
import type {
Group,
Intersection,
Mesh,
MeshBasicMaterial,
Object3D,
Object3DEventMap,
@ -13,6 +12,7 @@ import {
Color,
GridHelper,
LineBasicMaterial,
Mesh,
OrthographicCamera,
Raycaster,
Scene,
@ -42,7 +42,7 @@ import { compareVec2Epsilon2 } from '@src/lang/std/sketch'
import type { Axis, NonCodeSelection } from '@src/lib/selections'
import { type BaseUnit } from '@src/lib/settings/settingsTypes'
import { Themes } from '@src/lib/theme'
import { getAngle, throttle } from '@src/lib/utils'
import { getAngle, getLength, throttle } from '@src/lib/utils'
import type {
MouseState,
SegmentOverlayPayload,
@ -68,6 +68,7 @@ interface OnDragCallbackArgs extends OnMouseEnterLeaveArgs {
}
intersects: Intersection<Object3D<Object3DEventMap>>[]
}
export interface OnClickCallbackArgs {
mouseEvent: MouseEvent
intersectionPoint?: {
@ -93,6 +94,7 @@ interface OnMoveCallbackArgs {
// Anything that added the the scene for the user to interact with is probably in SceneEntities.ts
type Voidish = void | Promise<void>
export class SceneInfra {
static instance: SceneInfra
readonly scene: Scene
@ -130,6 +132,7 @@ export class SceneInfra {
this.onMouseLeave = callbacks.onMouseLeave || this.onMouseLeave
this.selected = null // following selections between callbacks being set is too tricky
}
set baseUnit(unit: BaseUnit) {
this._baseUnitMultiplier = baseUnitTomm(unit)
this.scene.scale.set(
@ -138,9 +141,11 @@ export class SceneInfra {
this._baseUnitMultiplier
)
}
set theme(theme: Themes) {
this._theme = theme
}
resetMouseListeners = () => {
this.setCallbacks({
onDragStart: () => {},
@ -155,12 +160,15 @@ 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: 'add-many',
@ -179,6 +187,7 @@ export class SceneInfra {
data: segmentOverlayPayload,
})
}
overlayCallbacks(
callbacks: (() => SegmentOverlayPayload | null)[],
instant = false
@ -195,6 +204,7 @@ export class SceneInfra {
}
overlayThrottleMap: { [pathToNodeString: string]: number } = {}
updateOverlayDetails({
handle,
group,
@ -349,6 +359,7 @@ 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 =
@ -358,6 +369,7 @@ export class SceneInfra {
this._baseUnitMultiplier
return factor
}
getPlaneIntersectPoint = (): {
twoD?: Vector2
threeD?: Vector3
@ -556,6 +568,7 @@ export class SceneInfra {
(a, b) => a.distance - b.distance
)
}
updateMouseState(mouseState: MouseState) {
if (this.lastMouseState.type === mouseState.type) return
this.lastMouseState = mouseState
@ -665,6 +678,13 @@ export class SceneInfra {
}
})
}
screenSpaceDistance(a: Coords2d, b: Coords2d): number {
const dummy = new Mesh()
dummy.position.set(0, 0, 0)
const scale = this.getClientSceneScaleFactor(dummy)
return getLength(a, b) / scale
}
}
function baseUnitTomm(baseUnit: BaseUnit) {

View File

@ -54,6 +54,7 @@ import {
STRAIGHT_SEGMENT,
STRAIGHT_SEGMENT_BODY,
STRAIGHT_SEGMENT_DASH,
STRAIGHT_SEGMENT_SNAP_LINE,
TANGENTIAL_ARC_TO_SEGMENT,
TANGENTIAL_ARC_TO_SEGMENT_BODY,
TANGENTIAL_ARC_TO__SEGMENT_DASH,
@ -216,7 +217,17 @@ class StraightSegment implements SegmentUtils {
segmentGroup.add(lengthIndicatorGroup)
}
if (isDraftSegment) {
const snapLine = createLine({
from: [0, 0],
to: [0, 0],
color: 0xcccccc,
})
snapLine.name = STRAIGHT_SEGMENT_SNAP_LINE
segmentGroup.add(snapLine)
}
segmentGroup.add(mesh, extraSegmentGroup)
let updateOverlaysCallback = this.update({
prevSegment,
input,
@ -265,20 +276,39 @@ class StraightSegment implements SegmentUtils {
isHandlesVisible = !shouldHideHover
}
if (arrowGroup) {
arrowGroup.position.set(to[0], to[1], 0)
const dir = new Vector3()
.subVectors(
new Vector3(to[0], to[1], 0),
new Vector3(from[0], from[1], 0)
)
.normalize()
if (arrowGroup) {
arrowGroup.position.set(to[0], to[1], 0)
arrowGroup.quaternion.setFromUnitVectors(new Vector3(0, 1, 0), dir)
arrowGroup.scale.set(scale, scale, scale)
arrowGroup.visible = isHandlesVisible
}
const snapLine = group.getObjectByName(STRAIGHT_SEGMENT_SNAP_LINE) as Line
if (snapLine) {
snapLine.visible = !!input.snap
if (snapLine.visible) {
const snapLineFrom = to
const snapLineTo = new Vector3(to[0], to[1], 0).addScaledVector(
dir,
// Draw a large enough line that reaches the screen edge
// Cleaner way would be to draw in screen space
9999999 * scale
)
updateLine(snapLine, {
from: snapLineFrom,
to: [snapLineTo.x, snapLineTo.y],
scale,
})
}
}
const extraSegmentGroup = group.getObjectByName(EXTRA_SEGMENT_HANDLE)
if (extraSegmentGroup) {
const offsetFromBase = new Vector2(to[0] - from[0], to[1] - from[1])
@ -431,33 +461,10 @@ class TangentialArcToSegment implements SegmentUtils {
const arrowGroup = group.getObjectByName(ARROWHEAD) as Group
const extraSegmentGroup = group.getObjectByName(EXTRA_SEGMENT_HANDLE)
let previousPoint = prevSegment.from
if (prevSegment?.type === 'TangentialArcTo') {
previousPoint = getTangentPointFromPreviousArc(
prevSegment.center,
prevSegment.ccw,
prevSegment.to
)
} else if (prevSegment?.type === 'ArcThreePoint') {
const arcDetails = calculate_circle_from_3_points(
prevSegment.p1[0],
prevSegment.p1[1],
prevSegment.p2[0],
prevSegment.p2[1],
prevSegment.p3[0],
prevSegment.p3[1]
)
previousPoint = getTangentPointFromPreviousArc(
[arcDetails.center_x, arcDetails.center_y],
!isClockwise([prevSegment.p1, prevSegment.p2, prevSegment.p3]),
prevSegment.p3
)
}
const arcInfo = getTangentialArcToInfo({
arcStartPoint: from,
arcEndPoint: to,
tanPreviousPoint: previousPoint,
tanPreviousPoint: getTanPreviousPoint(prevSegment),
obtuse: true,
})
@ -539,6 +546,32 @@ class TangentialArcToSegment implements SegmentUtils {
}
}
export function getTanPreviousPoint(prevSegment: Sketch['paths'][number]) {
let previousPoint = prevSegment.from
if (prevSegment.type === 'TangentialArcTo') {
previousPoint = getTangentPointFromPreviousArc(
prevSegment.center,
prevSegment.ccw,
prevSegment.to
)
} else if (prevSegment.type === 'ArcThreePoint') {
const arcDetails = calculate_circle_from_3_points(
prevSegment.p1[0],
prevSegment.p1[1],
prevSegment.p2[0],
prevSegment.p2[1],
prevSegment.p3[0],
prevSegment.p3[1]
)
previousPoint = getTangentPointFromPreviousArc(
[arcDetails.center_x, arcDetails.center_y],
!isClockwise([prevSegment.p1, prevSegment.p2, prevSegment.p3]),
prevSegment.p3
)
}
return previousPoint
}
class CircleSegment implements SegmentUtils {
init: SegmentUtils['init'] = ({
prevSegment,
@ -1018,21 +1051,18 @@ class ArcSegment implements SegmentUtils {
const centerToFromLine = createLine({
from: center,
to: from,
scale,
color: grey, // Light gray color for the line
})
centerToFromLine.name = ARC_CENTER_TO_FROM
const centerToToLine = createLine({
from: center,
to,
scale,
color: grey, // Light gray color for the line
})
centerToToLine.name = ARC_CENTER_TO_TO
const angleReferenceLine = createLine({
from: [center[0] + (ANGLE_INDICATOR_RADIUS - 2) * scale, center[1]],
to: [center[0] + (ANGLE_INDICATOR_RADIUS + 2) * scale, center[1]],
scale,
color: grey, // Light gray color for the line
})
angleReferenceLine.name = ARC_ANGLE_REFERENCE_LINE
@ -1398,7 +1428,7 @@ class ThreePointArcSegment implements SegmentUtils {
p3,
radius,
center,
ccw: false,
ccw: !isClockwise([p1, p2, p3]),
prevSegment,
pathToNode,
isSelected,
@ -1992,12 +2022,10 @@ export function dashedStraight(
function createLine({
from,
to,
scale,
color,
}: {
from: [number, number]
to: [number, number]
scale: number
color: number
}): Line {
// Implementation for creating a line

View File

@ -312,7 +312,7 @@ export function getPathToExtrudeForSegmentSelection(
export function mutateAstWithTagForSketchSegment(
astClone: Node<Program>,
pathToSegmentNode: PathToNode
): { modifiedAst: Program; tag: string } | Error {
): { modifiedAst: Node<Program>; tag: string } | Error {
const segmentNode = getNodeFromPath<CallExpression | CallExpressionKw>(
astClone,
pathToSegmentNode,

View File

@ -20,10 +20,12 @@ import {
} from '@src/lang/constants'
import {
createArrayExpression,
createBinaryExpression,
createCallExpression,
createCallExpressionStdLibKw,
createLabeledArg,
createLiteral,
createLocalName,
createObjectExpression,
createPipeExpression,
createPipeSubstitution,
@ -60,6 +62,7 @@ import type {
SingleValueInput,
SketchLineHelper,
SketchLineHelperKw,
addCall,
} from '@src/lang/std/stdTypes'
import {
findKwArg,
@ -2324,8 +2327,9 @@ export const circleThreePoint: SketchLineHelperKw = {
return finalConstraints
},
}
export const angledLine: SketchLineHelperKw = {
add: ({ node, pathToNode, segmentInput, replaceExistingCallback }) => {
add: ({ node, pathToNode, segmentInput, replaceExistingCallback, snaps }) => {
if (segmentInput.type !== 'straight-segment') return STRAIGHT_SEGMENT_ERR
const { from, to } = segmentInput
const _node = { ...node }
@ -2334,11 +2338,27 @@ export const angledLine: SketchLineHelperKw = {
if (err(_node1)) return _node1
const { node: pipe } = _node1
const newAngleVal = createLiteral(roundOff(getAngle(from, to), 0))
// When snapping to previous arc's tangent direction, create this expression:
// angledLine({ angle = tangentToEnd(arc001), length = 12 }, %)
// Or if snapping to the negative direction:
// angledLine({ angle = tangentToEnd(arc001) + turns::HALF_TURN, length = 12 }, %)
const newAngleVal = snaps?.previousArcTag
? snaps.negativeTangentDirection
? createBinaryExpression([
createCallExpression('tangentToEnd', [
createLocalName(snaps?.previousArcTag),
]),
'+',
createLocalName('turns::HALF_TURN'),
])
: createCallExpression('tangentToEnd', [
createLocalName(snaps?.previousArcTag),
])
: createLiteral(roundOff(getAngle(from, to), 0))
const newLengthVal = createLiteral(roundOff(getLength(from, to), 2))
const newLine = createCallExpressionStdLibKw('angledLine', null, [
createLabeledArg('angle', newAngleVal),
createLabeledArg('length', newLengthVal),
createLabeledArg(ARG_ANGLE, newAngleVal),
createLabeledArg(ARG_LENGTH, newLengthVal),
])
if (replaceExistingCallback) {
@ -2348,7 +2368,12 @@ export const angledLine: SketchLineHelperKw = {
type: 'labeledArg',
key: 'angle',
argType: 'angle',
expr: newAngleVal,
// We cannot pass newAngleVal to expr because it is a Node<Literal>.
// We couldn't change that type to be Node<Expr> because there is a lot of code assuming it to be Node<Literal>.
// So we added a new optional overrideExpr which can be Node<Expr> and this is used if present in sketchcombos/createNode().
expr:
newAngleVal.type === 'Literal' ? newAngleVal : createLiteral(''),
overrideExpr: newAngleVal,
},
{
type: 'labeledArg',
@ -3309,6 +3334,7 @@ interface CreateLineFnCallArgs {
fnName: ToolTip
pathToNode: PathToNode
spliceBetween?: boolean
snaps?: addCall['snaps']
}
export function addNewSketchLn({
@ -3318,6 +3344,7 @@ export function addNewSketchLn({
pathToNode,
input: segmentInput,
spliceBetween = false,
snaps,
}: CreateLineFnCallArgs):
| {
modifiedAst: Node<Program>
@ -3347,6 +3374,7 @@ export function addNewSketchLn({
pathToNode,
segmentInput,
spliceBetween,
snaps,
})
}

View File

@ -1538,20 +1538,22 @@ export function removeSingleConstraint({
}
if (inputToReplace.type === 'arrayItem') {
const values = inputs.map((arg) => {
const argExpr = arg.overrideExpr ?? arg.expr
if (
!(
(arg.type === 'arrayItem' || arg.type === 'arrayOrObjItem') &&
arg.index === inputToReplace.index
)
)
return arg.expr
const literal = rawArgs.find(
return argExpr
const rawArg = rawArgs.find(
(rawValue) =>
(rawValue.type === 'arrayItem' ||
rawValue.type === 'arrayOrObjItem') &&
rawValue.index === inputToReplace.index
)?.expr
return (arg.index === inputToReplace.index && literal) || arg.expr
)
const literal = rawArg?.overrideExpr ?? rawArg?.expr
return (arg.index === inputToReplace.index && literal) || argExpr
})
if (callExp.node.type === 'CallExpression') {
return createStdlibCallExpression(
@ -1589,6 +1591,7 @@ export function removeSingleConstraint({
const objInput: Parameters<typeof createObjectExpression>[0] = {}
const kwArgInput: ReturnType<typeof createLabeledArg>[] = []
inputs.forEach((currentArg) => {
const currentArgExpr = currentArg.overrideExpr ?? currentArg.expr
if (
// should be one of these, return early to make TS happy.
currentArg.type !== 'objectProperty' &&
@ -1619,8 +1622,11 @@ export function removeSingleConstraint({
if (!arrayInput[currentArg.key]) {
arrayInput[currentArg.key] = []
}
arrayInput[inputToReplace.key][inputToReplace.index] =
const rawLiteralArrayInObjectExpr =
rawLiteralArrayInObject.overrideExpr ??
rawLiteralArrayInObject.expr
arrayInput[inputToReplace.key][inputToReplace.index] =
rawLiteralArrayInObjectExpr
let existingKwgForKey = kwArgInput.find(
(kwArg) => kwArg.label.name === currentArg.key
)
@ -1633,7 +1639,7 @@ export function removeSingleConstraint({
}
if (existingKwgForKey.arg.type === 'ArrayExpression') {
existingKwgForKey.arg.elements[inputToReplace.index] =
rawLiteralArrayInObject.expr
rawLiteralArrayInObjectExpr
}
} else if (
inputToReplace.type === 'objectProperty' &&
@ -1642,10 +1648,11 @@ export function removeSingleConstraint({
rawLiteralObjProp?.key === inputToReplace.key &&
currentArg.key === inputToReplace.key
) {
objInput[inputToReplace.key] = rawLiteralObjProp.expr
objInput[inputToReplace.key] =
rawLiteralObjProp.overrideExpr ?? rawLiteralObjProp.expr
} else if (currentArg.type === 'arrayInObject') {
if (!arrayInput[currentArg.key]) arrayInput[currentArg.key] = []
arrayInput[currentArg.key][currentArg.index] = currentArg.expr
arrayInput[currentArg.key][currentArg.index] = currentArgExpr
let existingKwgForKey = kwArgInput.find(
(kwArg) => kwArg.label.name === currentArg.key
)
@ -1657,10 +1664,10 @@ export function removeSingleConstraint({
kwArgInput.push(existingKwgForKey)
}
if (existingKwgForKey.arg.type === 'ArrayExpression') {
existingKwgForKey.arg.elements[currentArg.index] = currentArg.expr
existingKwgForKey.arg.elements[currentArg.index] = currentArgExpr
}
} else if (currentArg.type === 'objectProperty') {
objInput[currentArg.key] = currentArg.expr
objInput[currentArg.key] = currentArgExpr
}
})
const createObjParam: Parameters<typeof createObjectExpression>[0] = {}
@ -1694,7 +1701,7 @@ export function removeSingleConstraint({
return createCallWrapper(
callExp.node.callee.name.name as any,
rawArgs[0].expr,
rawArgs[0].overrideExpr ?? rawArgs[0].expr,
tag
)
},

View File

@ -44,6 +44,7 @@ interface StraightSegmentInput {
type: 'straight-segment'
from: [number, number]
to: [number, number]
snap?: boolean
}
/** Inputs for arcs, excluding tangentialArc for reasons explain in the
@ -92,6 +93,12 @@ export interface addCall extends ModifyAstBase {
) => CreatedSketchExprResult | Error
referencedSegment?: Path
spliceBetween?: boolean
snaps?: {
previousArcTag?: string
negativeTangentDirection: boolean
xAxis?: boolean
yAxis?: boolean
}
}
interface updateArgs extends ModifyAstBase {
@ -121,18 +128,21 @@ export interface SingleValueInput<T> {
type: 'singleValue'
argType: LineInputsType
expr: T
overrideExpr?: Node<Expr>
}
export interface ArrayItemInput<T> {
type: 'arrayItem'
index: 0 | 1
argType: LineInputsType
expr: T
overrideExpr?: Node<Expr>
}
export interface ObjectPropertyInput<T> {
type: 'objectProperty'
key: InputArgKeys
argType: LineInputsType
expr: T
overrideExpr?: Node<Expr>
}
interface ArrayOrObjItemInput<T> {
@ -141,6 +151,7 @@ interface ArrayOrObjItemInput<T> {
index: 0 | 1
argType: LineInputsType
expr: T
overrideExpr?: Node<Expr>
}
interface ArrayInObject<T> {
@ -149,6 +160,7 @@ interface ArrayInObject<T> {
argType: LineInputsType
index: 0 | 1
expr: T
overrideExpr?: Node<Expr>
}
interface LabeledArg<T> {
@ -156,6 +168,7 @@ interface LabeledArg<T> {
key: InputArgKeys
argType: LineInputsType
expr: T
overrideExpr?: Node<Expr>
}
type _InputArg<T> =

View File

@ -23,6 +23,10 @@ export function isArray(val: any): val is unknown[] {
return Array.isArray(val)
}
export type SafeArray<T> = Omit<Array<T>, number> & {
[index: number]: T | undefined
}
/**
* An alternative to `Object.keys()` that returns an array of keys with types.
*

View File

@ -1,6 +1,7 @@
import type { Coords2d } from '@src/lang/std/sketch'
import { isPointsCCW } from '@src/lang/wasm'
import { initPromise } from '@src/lang/wasmUtils'
import { closestPointOnRay } from '@src/lib/utils2d'
beforeAll(async () => {
await initPromise
@ -20,3 +21,72 @@ describe('test isPointsCW', () => {
expect(CW).toBe(-1)
})
})
describe('test closestPointOnRay', () => {
test('point lies on ray', () => {
const rayOrigin: Coords2d = [0, 0]
const rayDirection: Coords2d = [1, 0]
const pointToCheck: Coords2d = [7, 0]
const result = closestPointOnRay(rayOrigin, rayDirection, pointToCheck)
expect(result.closestPoint).toEqual([7, 0])
expect(result.t).toBe(7)
})
test('point is above ray', () => {
const rayOrigin: Coords2d = [1, 0]
const rayDirection: Coords2d = [1, 0]
const pointToCheck: Coords2d = [7, 7]
const result = closestPointOnRay(rayOrigin, rayDirection, pointToCheck)
expect(result.closestPoint).toEqual([7, 0])
expect(result.t).toBe(6)
})
test('point lies behind ray origin and allowNegative=false', () => {
const rayOrigin: Coords2d = [0, 0]
const rayDirection: Coords2d = [1, 0]
const pointToCheck: Coords2d = [-7, 7]
const result = closestPointOnRay(rayOrigin, rayDirection, pointToCheck)
expect(result.closestPoint).toEqual([0, 0])
expect(result.t).toBe(0)
})
test('point lies behind ray origin and allowNegative=true', () => {
const rayOrigin: Coords2d = [0, 0]
const rayDirection: Coords2d = [1, 0]
const pointToCheck: Coords2d = [-7, 7]
const result = closestPointOnRay(
rayOrigin,
rayDirection,
pointToCheck,
true
)
expect(result.closestPoint).toEqual([-7, 0])
expect(result.t).toBe(-7)
})
test('diagonal ray and point', () => {
const rayOrigin: Coords2d = [0, 0]
const rayDirection: Coords2d = [1, 1]
const pointToCheck: Coords2d = [3, 4]
const result = closestPointOnRay(rayOrigin, rayDirection, pointToCheck)
expect(result.closestPoint[0]).toBeCloseTo(3.5)
expect(result.closestPoint[1]).toBeCloseTo(3.5)
expect(result.t).toBeCloseTo(4.95, 1)
})
test('non-normalized direction vector', () => {
const rayOrigin: Coords2d = [0, 0]
const rayDirection: Coords2d = [2, 2]
const pointToCheck: Coords2d = [3, 4]
const result = closestPointOnRay(rayOrigin, rayDirection, pointToCheck)
expect(result.closestPoint[0]).toBeCloseTo(3.5)
expect(result.closestPoint[1]).toBeCloseTo(3.5)
expect(result.t).toBeCloseTo(4.95, 1)
})
})

View File

@ -17,3 +17,38 @@ export function getTangentPointFromPreviousArc(
Math.sin(deg2Rad(tangentialAngle)) * 10 + lastArcEnd[1],
]
}
export function closestPointOnRay(
rayOrigin: Coords2d,
rayDirection: Coords2d,
pointToCheck: Coords2d,
allowNegative = false
) {
const dirMagnitude = Math.sqrt(
rayDirection[0] * rayDirection[0] + rayDirection[1] * rayDirection[1]
)
const normalizedDir: Coords2d = [
rayDirection[0] / dirMagnitude,
rayDirection[1] / dirMagnitude,
]
const originToPoint: Coords2d = [
pointToCheck[0] - rayOrigin[0],
pointToCheck[1] - rayOrigin[1],
]
let t =
originToPoint[0] * normalizedDir[0] + originToPoint[1] * normalizedDir[1]
if (!allowNegative) {
t = Math.max(0, t)
}
return {
closestPoint: [
rayOrigin[0] + normalizedDir[0] * t,
rayOrigin[1] + normalizedDir[1] * t,
] as Coords2d,
t,
}
}

View File

@ -866,10 +866,11 @@ export const modelingMachine = setup({
if (twoD) {
sceneInfra.modelingSend({
type: 'click in scene',
data: sceneEntitiesManager.getSnappedDragPoint({
intersection2d: twoD,
intersects: args.intersects,
}).snappedPoint,
data: sceneEntitiesManager.getSnappedDragPoint(
twoD,
args.intersects,
args.mouseEvent
).snappedPoint,
})
} else {
console.error('No intersection point found')
@ -1238,10 +1239,11 @@ export const modelingMachine = setup({
if (!intersectionPoint?.twoD) return
if (!context.sketchDetails) return
const { snappedPoint, isSnapped } =
sceneEntitiesManager.getSnappedDragPoint({
intersection2d: intersectionPoint.twoD,
intersects: args.intersects,
})
sceneEntitiesManager.getSnappedDragPoint(
intersectionPoint.twoD,
args.intersects,
args.mouseEvent
)
if (isSnapped) {
sceneEntitiesManager.positionDraftPoint({
snappedPoint: new Vector2(...snappedPoint),

View File

@ -82,16 +82,14 @@ const SignIn = () => {
style={
isDesktop()
? ({
'-webkit-app-region': 'drag',
WebkitAppRegion: 'drag',
} as CSSProperties)
: {}
}
>
<div
style={
isDesktop()
? ({ '-webkit-app-region': 'no-drag' } as CSSProperties)
: {}
isDesktop() ? ({ WebkitAppRegion: 'no-drag' } as CSSProperties) : {}
}
className="body-bg py-5 px-12 rounded-lg grid place-items-center overflow-y-auto"
>