* 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:
		| @ -1445,6 +1445,48 @@ solid001 = subtract([extrude001], tools = [extrude002]) | |||||||
|     await u.closeDebugPanel() |     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 ({ |   test('Can delete a single segment line with keyboard', async ({ | ||||||
|     page, |     page, | ||||||
|     scene, |     scene, | ||||||
|  | |||||||
| @ -165,7 +165,12 @@ import type { Themes } from '@src/lib/theme' | |||||||
| import { getThemeColorForThreeJs } from '@src/lib/theme' | import { getThemeColorForThreeJs } from '@src/lib/theme' | ||||||
| import { err, reportRejection, trap } from '@src/lib/trap' | import { err, reportRejection, trap } from '@src/lib/trap' | ||||||
| import { isArray, isOverlap, roundOff } from '@src/lib/utils' | 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 { | import type { | ||||||
|   SegmentOverlayPayload, |   SegmentOverlayPayload, | ||||||
|   SketchDetails, |   SketchDetails, | ||||||
| @ -798,7 +803,7 @@ export class SceneEntities { | |||||||
|         const callExpName = _node1.node?.callee?.name.name |         const callExpName = _node1.node?.callee?.name.name | ||||||
|  |  | ||||||
|         const initSegment = |         const initSegment = | ||||||
|           segment.type === 'TangentialArcTo' |           segment.type === 'TangentialArcTo' || segment.type === 'TangentialArc' | ||||||
|             ? segmentUtils.tangentialArc.init |             ? segmentUtils.tangentialArc.init | ||||||
|             : segment.type === 'Circle' |             : segment.type === 'Circle' | ||||||
|               ? segmentUtils.circle.init |               ? segmentUtils.circle.init | ||||||
| @ -3023,11 +3028,20 @@ export class SceneEntities { | |||||||
|         return input |         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 { |       return { | ||||||
|         type: 'straight-segment', |         type: 'straight-segment', | ||||||
|         from, |         from, | ||||||
|         to: dragTo, |         to: dragTo, | ||||||
|  |         previousEndTangent: previousSegment | ||||||
|  |           ? findTangentDirection(previousSegment) | ||||||
|  |           : undefined, | ||||||
|       } |       } | ||||||
|     } |     } | ||||||
|  |  | ||||||
| @ -3953,6 +3967,11 @@ function findTangentDirection(segmentGroup: Group) { | |||||||
|       ) + |       ) + | ||||||
|       (Math.PI / 2) * (segmentGroup.userData.ccw ? 1 : -1) |       (Math.PI / 2) * (segmentGroup.userData.ccw ? 1 : -1) | ||||||
|     tangentDirection = [Math.cos(tangentAngle), Math.sin(tangentAngle)] |     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 { |   } else { | ||||||
|     console.warn( |     console.warn( | ||||||
|       'Unsupported segment type for tangent direction calculation: ', |       'Unsupported segment type for tangent direction calculation: ', | ||||||
|  | |||||||
| @ -550,7 +550,10 @@ class TangentialArcToSegment implements SegmentUtils { | |||||||
|  |  | ||||||
| export function getTanPreviousPoint(prevSegment: Sketch['paths'][number]) { | export function getTanPreviousPoint(prevSegment: Sketch['paths'][number]) { | ||||||
|   let previousPoint = prevSegment.from |   let previousPoint = prevSegment.from | ||||||
|   if (prevSegment.type === 'TangentialArcTo') { |   if ( | ||||||
|  |     prevSegment.type === 'TangentialArcTo' || | ||||||
|  |     prevSegment.type === 'TangentialArc' | ||||||
|  |   ) { | ||||||
|     previousPoint = getTangentPointFromPreviousArc( |     previousPoint = getTangentPointFromPreviousArc( | ||||||
|       prevSegment.center, |       prevSegment.center, | ||||||
|       prevSegment.ccw, |       prevSegment.ccw, | ||||||
|  | |||||||
| @ -1,4 +1,7 @@ | |||||||
| import { perpendicularDistance } from 'sketch-helpers' | import { | ||||||
|  |   calculateIntersectionOfTwoLines, | ||||||
|  |   perpendicularDistance, | ||||||
|  | } from 'sketch-helpers' | ||||||
|  |  | ||||||
| import type { Node } from '@rust/kcl-lib/bindings/Node' | import type { Node } from '@rust/kcl-lib/bindings/Node' | ||||||
|  |  | ||||||
| @ -28,6 +31,7 @@ import { | |||||||
|   createCallExpressionStdLibKw, |   createCallExpressionStdLibKw, | ||||||
|   createLabeledArg, |   createLabeledArg, | ||||||
|   createLiteral, |   createLiteral, | ||||||
|  |   createLiteralMaybeSuffix, | ||||||
|   createLocalName, |   createLocalName, | ||||||
|   createPipeExpression, |   createPipeExpression, | ||||||
|   createTagDeclarator, |   createTagDeclarator, | ||||||
| @ -83,8 +87,15 @@ import type { | |||||||
| } from '@src/lang/wasm' | } from '@src/lang/wasm' | ||||||
| import { sketchFromKclValue } from '@src/lang/wasm' | import { sketchFromKclValue } from '@src/lang/wasm' | ||||||
| import { err } from '@src/lib/trap' | 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 type { EdgeCutInfo } from '@src/machines/modelingMachine' | ||||||
|  | import { cross2d, distance2d, isValidNumber, subVec } from '@src/lib/utils2d' | ||||||
|  |  | ||||||
| const STRAIGHT_SEGMENT_ERR = () => | const STRAIGHT_SEGMENT_ERR = () => | ||||||
|   new Error('Invalid input, expected "straight-segment"') |   new Error('Invalid input, expected "straight-segment"') | ||||||
| @ -3976,8 +3987,15 @@ export function getArgForEnd(lineCall: CallExpressionKw): | |||||||
|     case 'line': { |     case 'line': { | ||||||
|       const arg = findKwArgAny(DETERMINING_ARGS, lineCall) |       const arg = findKwArgAny(DETERMINING_ARGS, lineCall) | ||||||
|       if (arg === undefined) { |       if (arg === undefined) { | ||||||
|  |         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 new Error("no end of the line was found in fn '" + name + "'") | ||||||
|         } |         } | ||||||
|  |       } | ||||||
|       return getValuesForXYFns(arg) |       return getValuesForXYFns(arg) | ||||||
|     } |     } | ||||||
|     case 'angledLineThatIntersects': |     case 'angledLineThatIntersects': | ||||||
| @ -4145,27 +4163,101 @@ const tangentialArcHelpers = { | |||||||
|       ) |       ) | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     const argLabel = isAbsolute ? ARG_END_ABSOLUTE : ARG_END |     // All function arguments, except the tag | ||||||
|     const functionName = isAbsolute ? 'tangentialArcTo' : 'tangentialArc' |     const functionArguments = callExpression.arguments | ||||||
|  |       .map((arg) => arg.label?.name) | ||||||
|  |       .filter((n) => n && n !== ARG_TAG) | ||||||
|  |  | ||||||
|     for (const arg of callExpression.arguments) { |     if (areArraysEqual(functionArguments, [ARG_ANGLE, ARG_RADIUS])) { | ||||||
|       if (arg.label?.name !== argLabel && arg.label?.name !== ARG_TAG) { |       // Using length and radius -> convert "from", "to" to the matching length and radius | ||||||
|         console.debug( |       const previousEndTangent = input.previousEndTangent | ||||||
|           `Trying to edit unsupported ${functionName} keyword arguments; skipping` |       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)) | ||||||
|           ) |           ) | ||||||
|         return { |           const angleValue = createLiteralMaybeSuffix({ | ||||||
|           modifiedAst: _node, |             value: roundOff(angle, 2), | ||||||
|           pathToNode, |             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([ |         const toArrExp = createArrayExpression([ | ||||||
|           createLiteral(roundOff(isAbsolute ? to[0] : to[0] - from[0], 2)), |           createLiteral(roundOff(isAbsolute ? to[0] : to[0] - from[0], 2)), | ||||||
|           createLiteral(roundOff(isAbsolute ? to[1] : to[1] - from[1], 2)), |           createLiteral(roundOff(isAbsolute ? to[1] : to[1] - from[1], 2)), | ||||||
|         ]) |         ]) | ||||||
|  |  | ||||||
|         mutateKwArg(argLabel, callExpression, toArrExp) |         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 { |     return { | ||||||
|       modifiedAst: _node, |       modifiedAst: _node, | ||||||
|       pathToNode, |       pathToNode, | ||||||
|  | |||||||
| @ -23,6 +23,7 @@ import type { | |||||||
|   SourceRange, |   SourceRange, | ||||||
|   VariableMap, |   VariableMap, | ||||||
| } from '@src/lang/wasm' | } from '@src/lang/wasm' | ||||||
|  | import type { Coords2d } from '@src/lang/std/sketch' | ||||||
|  |  | ||||||
| export interface ModifyAstBase { | export interface ModifyAstBase { | ||||||
|   node: Node<Program> |   node: Node<Program> | ||||||
| @ -46,6 +47,7 @@ interface StraightSegmentInput { | |||||||
|   from: [number, number] |   from: [number, number] | ||||||
|   to: [number, number] |   to: [number, number] | ||||||
|   snap?: boolean |   snap?: boolean | ||||||
|  |   previousEndTangent?: Coords2d | ||||||
| } | } | ||||||
|  |  | ||||||
| /** Inputs for arcs, excluding tangentialArc for reasons explain in the | /** Inputs for arcs, excluding tangentialArc for reasons explain in the | ||||||
|  | |||||||
| @ -55,6 +55,12 @@ export function isArray(val: any): val is unknown[] { | |||||||
|   return Array.isArray(val) |   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> & { | export type SafeArray<T> = Omit<Array<T>, number> & { | ||||||
|   [index: number]: T | undefined |   [index: number]: T | undefined | ||||||
| } | } | ||||||
|  | |||||||
| @ -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( | export function closestPointOnRay( | ||||||
|   rayOrigin: Coords2d, |   rayOrigin: Coords2d, | ||||||
|   rayDirection: Coords2d, |   rayDirection: Coords2d, | ||||||
|   pointToCheck: Coords2d, |   pointToCheck: Coords2d, | ||||||
|   allowNegative = false |   allowNegative = false | ||||||
| ) { | ) { | ||||||
|   const dirMagnitude = Math.sqrt( |   const normalizedDir = normalizeVec(rayDirection) | ||||||
|     rayDirection[0] * rayDirection[0] + rayDirection[1] * rayDirection[1] |   const originToPoint = subVec(pointToCheck, rayOrigin) | ||||||
|   ) |  | ||||||
|   const normalizedDir: Coords2d = [ |  | ||||||
|     rayDirection[0] / dirMagnitude, |  | ||||||
|     rayDirection[1] / dirMagnitude, |  | ||||||
|   ] |  | ||||||
|  |  | ||||||
|   const originToPoint: Coords2d = [ |  | ||||||
|     pointToCheck[0] - rayOrigin[0], |  | ||||||
|     pointToCheck[1] - rayOrigin[1], |  | ||||||
|   ] |  | ||||||
|  |  | ||||||
|   let t = |   let t = | ||||||
|     originToPoint[0] * normalizedDir[0] + originToPoint[1] * normalizedDir[1] |     originToPoint[0] * normalizedDir[0] + originToPoint[1] * normalizedDir[1] | ||||||
|  | |||||||
		Reference in New Issue
	
	Block a user