#7255 tangentialArc: angle, radius point-and-click support (#7449)

* separate handling of tangentialArc with angle and radius args

* make previousEndTangent available in segment input for handling tangentialArc with angle/radius

* start adding support for editing tangentialArc with angle, radius

* draw tangentialArc sketch when using angle, radius

* fix getTanPreviousPoint when using tangentialArc with angle, radius

* fix case of unwanted negative angles when calculating angle for tangentialArc

* lint

* add test for tangentialArc dragging with andle, radius

* lint, fmt

* fix getArgForEnd for tangentialArc with radius, angle

* renaming vars
This commit is contained in:
Andrew Varga
2025-06-17 11:29:21 +02:00
committed by GitHub
parent acb43fc82c
commit 832bf77c92
7 changed files with 219 additions and 33 deletions

View File

@ -1445,6 +1445,48 @@ solid001 = subtract([extrude001], tools = [extrude002])
await u.closeDebugPanel()
})
test('Can edit a tangentialArc defined by angle and radius', async ({
page,
homePage,
editor,
toolbar,
scene,
cmdBar,
}) => {
const viewportSize = { width: 1500, height: 750 }
await page.setBodyDimensions(viewportSize)
await page.addInitScript(async () => {
localStorage.setItem(
'persistCode',
`@settings(defaultLengthUnit=in)
sketch001 = startSketchOn(XZ)
|> startProfile(at = [-10, -10])
|> line(end = [20.0, 10.0])
|> tangentialArc(angle = 60deg, radius=10.0)`
)
})
await homePage.goToModelingScene()
await toolbar.waitForFeatureTreeToBeBuilt()
await scene.settled(cmdBar)
await (await toolbar.getFeatureTreeOperation('Sketch', 0)).dblclick()
await page.waitForTimeout(1000)
await page.mouse.move(1200, 139)
await page.mouse.down()
await page.mouse.move(870, 250)
await page.waitForTimeout(200)
await editor.expectEditor.toContain(
`tangentialArc(angle = 234.01deg, radius = 4.08)`,
{ shouldNormalise: true }
)
})
test('Can delete a single segment line with keyboard', async ({
page,
scene,

View File

@ -165,7 +165,12 @@ 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 {
closestPointOnRay,
deg2Rad,
normalizeVec,
subVec,
} from '@src/lib/utils2d'
import type {
SegmentOverlayPayload,
SketchDetails,
@ -798,7 +803,7 @@ export class SceneEntities {
const callExpName = _node1.node?.callee?.name.name
const initSegment =
segment.type === 'TangentialArcTo'
segment.type === 'TangentialArcTo' || segment.type === 'TangentialArc'
? segmentUtils.tangentialArc.init
: segment.type === 'Circle'
? segmentUtils.circle.init
@ -3023,11 +3028,20 @@ export class SceneEntities {
return input
}
// straight segment is the default
// straight segment is the default,
// this includes "tangential-arc-to-segment"
const segments: SafeArray<Group> = Object.values(this.activeSegments) // Using the order in the object feels wrong
const currentIndex = segments.indexOf(group)
const previousSegment = segments[currentIndex - 1]
return {
type: 'straight-segment',
from,
to: dragTo,
previousEndTangent: previousSegment
? findTangentDirection(previousSegment)
: undefined,
}
}
@ -3953,6 +3967,11 @@ function findTangentDirection(segmentGroup: Group) {
) +
(Math.PI / 2) * (segmentGroup.userData.ccw ? 1 : -1)
tangentDirection = [Math.cos(tangentAngle), Math.sin(tangentAngle)]
} else if (segmentGroup.userData.type === STRAIGHT_SEGMENT) {
const to = segmentGroup.userData.to as Coords2d
const from = segmentGroup.userData.from as Coords2d
tangentDirection = subVec(to, from)
tangentDirection = normalizeVec(tangentDirection)
} else {
console.warn(
'Unsupported segment type for tangent direction calculation: ',

View File

@ -550,7 +550,10 @@ class TangentialArcToSegment implements SegmentUtils {
export function getTanPreviousPoint(prevSegment: Sketch['paths'][number]) {
let previousPoint = prevSegment.from
if (prevSegment.type === 'TangentialArcTo') {
if (
prevSegment.type === 'TangentialArcTo' ||
prevSegment.type === 'TangentialArc'
) {
previousPoint = getTangentPointFromPreviousArc(
prevSegment.center,
prevSegment.ccw,

View File

@ -1,4 +1,7 @@
import { perpendicularDistance } from 'sketch-helpers'
import {
calculateIntersectionOfTwoLines,
perpendicularDistance,
} from 'sketch-helpers'
import type { Node } from '@rust/kcl-lib/bindings/Node'
@ -28,6 +31,7 @@ import {
createCallExpressionStdLibKw,
createLabeledArg,
createLiteral,
createLiteralMaybeSuffix,
createLocalName,
createPipeExpression,
createTagDeclarator,
@ -83,8 +87,15 @@ import type {
} from '@src/lang/wasm'
import { sketchFromKclValue } from '@src/lang/wasm'
import { err } from '@src/lib/trap'
import { allLabels, getAngle, getLength, roundOff } from '@src/lib/utils'
import {
allLabels,
areArraysEqual,
getAngle,
getLength,
roundOff,
} from '@src/lib/utils'
import type { EdgeCutInfo } from '@src/machines/modelingMachine'
import { cross2d, distance2d, isValidNumber, subVec } from '@src/lib/utils2d'
const STRAIGHT_SEGMENT_ERR = () =>
new Error('Invalid input, expected "straight-segment"')
@ -3976,7 +3987,14 @@ export function getArgForEnd(lineCall: CallExpressionKw):
case 'line': {
const arg = findKwArgAny(DETERMINING_ARGS, lineCall)
if (arg === undefined) {
return new Error("no end of the line was found in fn '" + name + "'")
const angle = findKwArg(ARG_ANGLE, lineCall)
const radius = findKwArg(ARG_RADIUS, lineCall)
if (name === 'tangentialArc' && angle && radius) {
// tangentialArc may use angle and radius instead of end
return { val: [angle, radius], tag: findKwArg(ARG_TAG, lineCall) }
} else {
return new Error("no end of the line was found in fn '" + name + "'")
}
}
return getValuesForXYFns(arg)
}
@ -4145,27 +4163,101 @@ const tangentialArcHelpers = {
)
}
const argLabel = isAbsolute ? ARG_END_ABSOLUTE : ARG_END
const functionName = isAbsolute ? 'tangentialArcTo' : 'tangentialArc'
// All function arguments, except the tag
const functionArguments = callExpression.arguments
.map((arg) => arg.label?.name)
.filter((n) => n && n !== ARG_TAG)
for (const arg of callExpression.arguments) {
if (arg.label?.name !== argLabel && arg.label?.name !== ARG_TAG) {
if (areArraysEqual(functionArguments, [ARG_ANGLE, ARG_RADIUS])) {
// Using length and radius -> convert "from", "to" to the matching length and radius
const previousEndTangent = input.previousEndTangent
if (previousEndTangent) {
// Find a circle with these two lines:
// - We know "from" and "to" are on the circle, so we can use their perpendicular bisector as the first line
// - The second line goes from "from" to the tangentRotated direction
// Intersecting these two lines will give us the center of the circle.
// line 1
const midPoint: [number, number] = [
(from[0] + to[0]) / 2,
(from[1] + to[1]) / 2,
]
const dir = subVec(to, from)
const perpDir = [-dir[1], dir[0]]
const line1PointB: Coords2d = [
midPoint[0] + perpDir[0],
midPoint[1] + perpDir[1],
]
// line 2
const tangentRotated: Coords2d = [
-previousEndTangent[1],
previousEndTangent[0],
]
const center = calculateIntersectionOfTwoLines({
line1: [midPoint, line1PointB],
line2Point: from,
line2Angle: getAngle([0, 0], tangentRotated),
})
if (isValidNumber(center[0]) && isValidNumber(center[1])) {
// We have the circle center, calculate the angle by calculating the angle for "from" and "to" points
// These are in the range of [-180, 180] degrees
const angleFrom = getAngle(center, from)
const angleTo = getAngle(center, to)
let angle = angleTo - angleFrom
// Handle the cases where the angle would have an undesired sign.
// If the circle is CCW we want the angle to be always positive, otherwise negative.
// eg. CCW: angleFrom is -90 and angleTo is -175 -> would be -85, but we want it to be 275
const isCCW = cross2d(previousEndTangent, dir) > 0
if (isCCW) {
angle = (angle + 360) % 360 // Ensure angle is positive
} else {
angle = (angle - 360) % 360 // Ensure angle is negative
}
const radius = distance2d(center, from)
mutateKwArg(
ARG_RADIUS,
callExpression,
createLiteral(roundOff(radius, 2))
)
const angleValue = createLiteralMaybeSuffix({
value: roundOff(angle, 2),
suffix: 'Deg',
})
if (!err(angleValue)) {
mutateKwArg(ARG_ANGLE, callExpression, angleValue)
}
} else {
console.debug('Invalid center calculated for tangential arc')
}
} else {
console.debug('No previous end tangent found, cannot calculate radius')
}
} else {
const argLabel = isAbsolute ? ARG_END_ABSOLUTE : ARG_END
if (areArraysEqual(functionArguments, [argLabel])) {
// Using end or endAbsolute
const toArrExp = createArrayExpression([
createLiteral(roundOff(isAbsolute ? to[0] : to[0] - from[0], 2)),
createLiteral(roundOff(isAbsolute ? to[1] : to[1] - from[1], 2)),
])
mutateKwArg(argLabel, callExpression, toArrExp)
} else {
// Unsupported arguments
const functionName =
callExpression.callee.name.name ??
(isAbsolute ? 'tangentialArcTo' : 'tangentialArc')
console.debug(
`Trying to edit unsupported ${functionName} keyword arguments; skipping`
)
return {
modifiedAst: _node,
pathToNode,
}
}
}
const toArrExp = createArrayExpression([
createLiteral(roundOff(isAbsolute ? to[0] : to[0] - from[0], 2)),
createLiteral(roundOff(isAbsolute ? to[1] : to[1] - from[1], 2)),
])
mutateKwArg(argLabel, callExpression, toArrExp)
return {
modifiedAst: _node,
pathToNode,

View File

@ -23,6 +23,7 @@ import type {
SourceRange,
VariableMap,
} from '@src/lang/wasm'
import type { Coords2d } from '@src/lang/std/sketch'
export interface ModifyAstBase {
node: Node<Program>
@ -46,6 +47,7 @@ interface StraightSegmentInput {
from: [number, number]
to: [number, number]
snap?: boolean
previousEndTangent?: Coords2d
}
/** Inputs for arcs, excluding tangentialArc for reasons explain in the

View File

@ -55,6 +55,12 @@ export function isArray(val: any): val is unknown[] {
return Array.isArray(val)
}
export function areArraysEqual<T>(a: T[], b: T[]): boolean {
if (a.length !== b.length) return false
const set1 = new Set(a)
return b.every((element) => set1.has(element))
}
export type SafeArray<T> = Omit<Array<T>, number> & {
[index: number]: T | undefined
}

View File

@ -18,24 +18,46 @@ export function getTangentPointFromPreviousArc(
]
}
export function subVec(a: Coords2d, b: Coords2d): Coords2d {
return [a[0] - b[0], a[1] - b[1]]
}
export function normalizeVec(v: Coords2d): Coords2d {
const magnitude = Math.sqrt(v[0] * v[0] + v[1] * v[1])
if (magnitude === 0) {
return [0, 0]
}
return [v[0] / magnitude, v[1] / magnitude]
}
export function cross2d(a: Coords2d, b: Coords2d): number {
return a[0] * b[1] - a[1] * b[0]
}
export function distance2d(a: Coords2d, b: Coords2d): number {
const dx = a[0] - b[0]
const dy = a[1] - b[1]
return Math.sqrt(dx * dx + dy * dy)
}
export function isValidNumber(value: number): boolean {
return typeof value === 'number' && !Number.isNaN(value) && isFinite(value)
}
export function rotateVec(v: Coords2d, rad: number): Coords2d {
const cos = Math.cos(rad)
const sin = Math.sin(rad)
return [v[0] * cos - v[1] * sin, v[0] * sin + v[1] * cos]
}
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],
]
const normalizedDir = normalizeVec(rayDirection)
const originToPoint = subVec(pointToCheck, rayOrigin)
let t =
originToPoint[0] * normalizedDir[0] + originToPoint[1] * normalizedDir[1]