Add 3 point arc (#5722)

* bare minimum

* start of segment util added

* remove redundant handle

* some probably buggy handling of arc handles, can fix later

* probably bug implementation of update args, but data flow through is mostly there can fix bugs after

* fix update for arc

* fix math for center handle

* fix up length indicator

* tweak math

* stub out xState logic for arc

* more progress on adding point and click, implemented more of sketchLineHelper for arc

* small unrelated tweak

* fix up draft arc bugs

* fix arc last click

* fix draft segment animation and add comment

* add draft point snapping for arcs

* add helper stuff to arc

* clone arc point and click as base for arc-three-point

* rust change for arc three point

* can draw three point arc

* make arcTo editable

* can add new three point arc, so long as it continues existing profile

* get overlays working

* make snap to for continuing profile work for three point arcs

* add draft animation

* tangent issue fix

* action rename

* tmp test fix up

* fix silly bug

* fix couple problems causing tests to fail

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* fix up

* add delet segment test for new segments

* update docs

* draft segments should look right

* add test for dragging new segment handles

* arc tools can be chained now

* make three point arc can start a new profile (not only extend existing paths)

* add test for equiping and unequiping the tool plus drawing with it

* fix console noise

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* clean up

* update rust/docs

* put toolbar mode check into fixture

* do thing for lee

* use TEST_COLORSs

* fix colors

* don't await file write

* remove commented code

* remove unneeded template strings

* power to **2

* remove magic numbers

* more string templates

* some odd bits of clean up

* arc should be enable in dev

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores)

* add new simulation test

* fix test code from kwark migration

* issues Frank found

* fix deleting half complete ark

* fix

* small fix on dele index

* tsc post main merge

* fix up snaping to profile start

* add cross hari for three point arc

* block snapping if it's the only segment

* add tests for canceling arcTo halfway through

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
This commit is contained in:
Kurt Hutten
2025-03-18 11:14:12 +11:00
committed by GitHub
parent cb0470a31d
commit e17c6e272c
32 changed files with 20989 additions and 366 deletions

View File

@ -168,7 +168,7 @@ export function Toolbar({
return (
<menu
data-currentMode={currentMode}
data-current-mode={currentMode}
className="max-w-full whitespace-nowrap rounded-b px-2 py-1 bg-chalkboard-10 dark:bg-chalkboard-90 relative border border-chalkboard-30 dark:border-chalkboard-80 border-t-0 shadow-sm"
>
<ul

View File

@ -147,7 +147,8 @@ export const ClientSideScene = ({
state.matches({ Sketch: 'Tangential arc to' }) ||
state.matches({ Sketch: 'Rectangle tool' }) ||
state.matches({ Sketch: 'Circle tool' }) ||
state.matches({ Sketch: 'Circle three point tool' })
state.matches({ Sketch: 'Circle three point tool' }) ||
state.matches({ Sketch: 'Arc three point tool' })
) {
cursor = 'crosshair'
} else {
@ -490,14 +491,19 @@ const SegmentMenu = ({
verticalPosition === 'top' ? 'bottom-full' : 'top-full'
} z-10 w-36 flex flex-col gap-1 divide-y divide-chalkboard-20 dark:divide-chalkboard-70 align-stretch px-0 py-1 bg-chalkboard-10 dark:bg-chalkboard-100 rounded-sm shadow-lg border border-solid border-chalkboard-20/50 dark:border-chalkboard-80/50`}
>
<button
className="!border-transparent rounded-sm text-left p-1 text-nowrap"
onClick={() => {
send({ type: 'Constrain remove constraints', data: pathToNode })
}}
>
Remove constraints
</button>
{stdLibFnName !== 'arcTo' && (
<button
className="!border-transparent rounded-sm text-left p-1 text-nowrap"
onClick={() => {
send({
type: 'Constrain remove constraints',
data: pathToNode,
})
}}
>
Remove constraints
</button>
)}
<button
className="!border-transparent rounded-sm text-left p-1 text-nowrap"
title={

File diff suppressed because it is too large Load Diff

View File

@ -49,7 +49,19 @@ import {
TANGENTIAL_ARC_TO_SEGMENT,
TANGENTIAL_ARC_TO_SEGMENT_BODY,
TANGENTIAL_ARC_TO__SEGMENT_DASH,
ARC_SEGMENT,
ARC_SEGMENT_BODY,
ARC_SEGMENT_DASH,
ARC_ANGLE_END,
getParentGroup,
ARC_CENTER_TO_FROM,
ARC_CENTER_TO_TO,
ARC_ANGLE_REFERENCE_LINE,
THREE_POINT_ARC_SEGMENT,
THREE_POINT_ARC_SEGMENT_BODY,
THREE_POINT_ARC_SEGMENT_DASH,
THREE_POINT_ARC_HANDLE2,
THREE_POINT_ARC_HANDLE3,
} from './sceneEntities'
import { getTangentPointFromPreviousArc } from 'lib/utils2d'
import {
@ -61,7 +73,7 @@ import {
SEGMENT_LENGTH_LABEL_TEXT,
} from './sceneInfra'
import { Themes, getThemeColorForThreeJs } from 'lib/theme'
import { normaliseAngle, roundOff } from 'lib/utils'
import { isClockwise, normaliseAngle, roundOff } from 'lib/utils'
import {
SegmentOverlay,
SegmentOverlayPayload,
@ -74,6 +86,7 @@ import { Selections } from 'lib/selections'
import { calculate_circle_from_3_points } from '@rust/kcl-wasm-lib/pkg/kcl_wasm_lib'
import { commandBarActor } from 'machines/commandBarMachine'
const ANGLE_INDICATOR_RADIUS = 30 // in px
interface CreateSegmentArgs {
input: SegmentInputs
prevSegment: Sketch['paths'][number]
@ -412,14 +425,28 @@ class TangentialArcToSegment implements SegmentUtils {
const arrowGroup = group.getObjectByName(ARROWHEAD) as Group
const extraSegmentGroup = group.getObjectByName(EXTRA_SEGMENT_HANDLE)
const previousPoint =
prevSegment?.type === 'TangentialArcTo'
? getTangentPointFromPreviousArc(
prevSegment.center,
prevSegment.ccw,
prevSegment.to
)
: prevSegment.from
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,
@ -591,7 +618,6 @@ class CircleSegment implements SegmentUtils {
}
const { from, center, radius } = input
group.userData.from = from
// group.userData.to = to
group.userData.center = center
group.userData.radius = radius
group.userData.prevSegment = prevSegment
@ -635,8 +661,7 @@ class CircleSegment implements SegmentUtils {
}
if (radiusLengthIndicator) {
// The radius indicator is placed at the midpoint of the radius,
// at a 45 degree CCW angle from the positive X-axis
// The radius indicator is placed halfway between the center and the start angle point
const indicatorPoint = {
x: center[0] + (Math.cos(Math.PI / 4) * radius) / 2,
y: center[1] + (Math.sin(Math.PI / 4) * radius) / 2,
@ -648,6 +673,8 @@ class CircleSegment implements SegmentUtils {
const label = labelWrapperElem.children[0] as HTMLParagraphElement
label.innerText = `${roundOff(radius)}`
label.classList.add(SEGMENT_LENGTH_LABEL_TEXT)
// Calculate the angle for the label
const isPlaneBackFace = center[0] > indicatorPoint.x
label.style.setProperty(
'--degree',
@ -925,6 +952,585 @@ class CircleThreePointSegment implements SegmentUtils {
}
}
class ArcSegment implements SegmentUtils {
init: SegmentUtils['init'] = ({
prevSegment,
input,
id,
pathToNode,
isDraftSegment,
scale = 1,
theme,
isSelected,
sceneInfra,
}) => {
if (input.type !== 'arc-segment') {
return new Error('Invalid segment type')
}
const { from, to, center, radius, ccw } = input
const baseColor = getThemeColorForThreeJs(theme)
const color = isSelected ? 0x0000ff : baseColor
// Calculate start and end angles
const startAngle = Math.atan2(from[1] - center[1], from[0] - center[0])
const endAngle = Math.atan2(to[1] - center[1], to[0] - center[0])
const group = new Group()
const geometry = createArcGeometry({
center,
radius,
startAngle,
endAngle,
ccw,
isDashed: isDraftSegment,
scale,
})
const mat = new MeshBasicMaterial({ color })
const arcMesh = new Mesh(geometry, mat)
const meshType = isDraftSegment ? ARC_SEGMENT_DASH : ARC_SEGMENT_BODY
// Create handles for the arc
const endAngleHandle = createArrowhead(scale, theme, color)
endAngleHandle.name = ARC_ANGLE_END
endAngleHandle.userData.type = ARC_ANGLE_END
const circleCenterGroup = createCircleCenterHandle(scale, theme, color)
// A radius indicator that appears from the center to the perimeter
const radiusIndicatorGroup = createLengthIndicator({
from: center,
to: from,
scale,
})
const grey = 0xaaaaaa
// Create a line from the center to the 'to' point
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
// Create a curved line with an arrow to indicate the angle
const angleIndicator = createAngleIndicator({
center,
radius: ANGLE_INDICATOR_RADIUS, // Half the radius for the indicator
startAngle: 0,
endAngle,
scale,
color: grey, // Red color for the angle indicator
}) as Line
angleIndicator.name = 'angleIndicator'
// Create a new angle indicator for the end angle
const endAngleIndicator = createAngleIndicator({
center,
radius: ANGLE_INDICATOR_RADIUS, // Half the radius for the indicator
startAngle: 0,
endAngle: (endAngle * Math.PI) / 180,
scale,
color: grey, // Green color for the end angle indicator
}) as Line
endAngleIndicator.name = 'endAngleIndicator'
// Create a length indicator for the end angle
const endAngleLengthIndicator = createLengthIndicator({
from: center,
to: [
center[0] + Math.cos(endAngle) * radius,
center[1] + Math.sin(endAngle) * radius,
],
scale,
})
endAngleLengthIndicator.name = 'endAngleLengthIndicator'
arcMesh.userData.type = meshType
arcMesh.name = meshType
group.userData = {
type: ARC_SEGMENT,
draft: isDraftSegment,
id,
from,
to,
radius,
center,
ccw,
prevSegment,
pathToNode,
isSelected,
baseColor,
}
group.name = ARC_SEGMENT
group.add(
arcMesh,
endAngleHandle,
circleCenterGroup,
radiusIndicatorGroup,
centerToFromLine,
centerToToLine,
angleReferenceLine,
angleIndicator,
endAngleIndicator,
endAngleLengthIndicator
)
const updateOverlaysCallback = this.update({
prevSegment,
input,
group,
scale,
sceneInfra,
})
if (err(updateOverlaysCallback)) return updateOverlaysCallback
return {
group,
updateOverlaysCallback,
}
}
update: SegmentUtils['update'] = ({
prevSegment,
input,
group,
scale = 1,
sceneInfra,
}) => {
if (input.type !== 'arc-segment') {
return new Error('Invalid segment type')
}
const { from, to, center, radius, ccw } = input
group.userData.from = from
group.userData.to = to
group.userData.center = center
group.userData.radius = radius
group.userData.ccw = ccw
group.userData.prevSegment = prevSegment
// Calculate start and end angles
const startAngle = Math.atan2(from[1] - center[1], from[0] - center[0])
const endAngle = Math.atan2(to[1] - center[1], to[0] - center[0])
// Normalize the angle to -180 to 180 degrees
// const normalizedStartAngle = ((startAngle * 180 / Math.PI) + 180) % 360 - 180
const normalizedStartAngle = normaliseAngle((startAngle * 180) / Math.PI)
const normalizedEndAngle = (((endAngle * 180) / Math.PI + 180) % 360) - 180
const endAngleHandle = group.getObjectByName(ARC_ANGLE_END) as Group
const radiusLengthIndicator = group.getObjectByName(
SEGMENT_LENGTH_LABEL
) as Group
const circleCenterHandle = group.getObjectByName(
CIRCLE_CENTER_HANDLE
) as Group
// Calculate arc length
let arcAngle = endAngle - startAngle
if (ccw && arcAngle > 0) arcAngle = arcAngle - 2 * Math.PI
if (!ccw && arcAngle < 0) arcAngle = arcAngle + 2 * Math.PI
const arcLength = Math.abs(arcAngle) * radius
const pxLength = arcLength / scale
const shouldHideIdle = pxLength < HIDE_SEGMENT_LENGTH
const shouldHideHover = pxLength < HIDE_HOVER_SEGMENT_LENGTH
const hoveredParent =
sceneInfra.hoveredObject &&
getParentGroup(sceneInfra.hoveredObject, [ARC_SEGMENT])
let isHandlesVisible = !shouldHideIdle
if (hoveredParent && hoveredParent?.uuid === group?.uuid) {
isHandlesVisible = !shouldHideHover
}
if (endAngleHandle) {
endAngleHandle.position.set(to[0], to[1], 0)
const tangentAngle = endAngle + (Math.PI / 2) * (ccw ? 1 : -1)
endAngleHandle.quaternion.setFromUnitVectors(
new Vector3(0, 1, 0),
new Vector3(Math.cos(tangentAngle), Math.sin(tangentAngle), 0)
)
endAngleHandle.scale.set(scale, scale, scale)
endAngleHandle.visible = isHandlesVisible
}
if (radiusLengthIndicator) {
// The radius indicator is placed halfway between the center and the start angle point
const indicatorPoint = {
x: center[0] + (Math.cos(startAngle) * radius) / 2,
y: center[1] + (Math.sin(startAngle) * radius) / 2,
}
const labelWrapper = radiusLengthIndicator.getObjectByName(
SEGMENT_LENGTH_LABEL_TEXT
) as CSS2DObject
const labelWrapperElem = labelWrapper.element as HTMLDivElement
const label = labelWrapperElem.children[0] as HTMLParagraphElement
label.innerText = `R:${roundOff(radius)}${'\n'}A:${roundOff(
roundOff((startAngle * 180) / Math.PI)
)}`
label.classList.add(SEGMENT_LENGTH_LABEL_TEXT)
// Calculate the angle for the label
label.style.setProperty('--degree', `-${startAngle}rad`)
label.style.setProperty('--x', `0px`)
label.style.setProperty('--y', `0px`)
labelWrapper.position.set(indicatorPoint.x, indicatorPoint.y, 0)
radiusLengthIndicator.visible = isHandlesVisible
}
if (circleCenterHandle) {
circleCenterHandle.position.set(center[0], center[1], 0)
circleCenterHandle.scale.set(scale, scale, scale)
circleCenterHandle.visible = isHandlesVisible
}
const arcSegmentBody = group.children.find(
(child) => child.userData.type === ARC_SEGMENT_BODY
) as Mesh
if (arcSegmentBody) {
const newGeo = createArcGeometry({
radius,
center,
startAngle,
endAngle,
ccw,
scale,
})
arcSegmentBody.geometry = newGeo
}
const arcSegmentBodyDashed = group.getObjectByName(ARC_SEGMENT_DASH)
if (arcSegmentBodyDashed instanceof Mesh) {
arcSegmentBodyDashed.geometry = createArcGeometry({
center,
radius,
startAngle,
endAngle,
ccw,
isDashed: true,
scale,
})
}
const centerToFromLine = group.getObjectByName(ARC_CENTER_TO_FROM) as Line
if (centerToFromLine) {
updateLine(centerToFromLine, { from: center, to: from, scale })
centerToFromLine.visible = isHandlesVisible
}
const centerToToLine = group.getObjectByName(ARC_CENTER_TO_TO) as Line
if (centerToToLine) {
updateLine(centerToToLine, { from: center, to, scale })
centerToToLine.visible = isHandlesVisible
}
const angleReferenceLine = group.getObjectByName(
ARC_ANGLE_REFERENCE_LINE
) as Line
if (angleReferenceLine) {
updateLine(angleReferenceLine, {
from: center,
to: [center[0] + 34 * scale, center[1]],
scale,
})
angleReferenceLine.visible = isHandlesVisible
}
const angleIndicator = group.getObjectByName('angleIndicator') as Line
if (angleIndicator) {
updateAngleIndicator(angleIndicator, {
center,
radiusPx: ANGLE_INDICATOR_RADIUS - 10,
startAngle: 0,
endAngle: (normalizedStartAngle * Math.PI) / 180,
scale,
})
angleIndicator.visible = isHandlesVisible
}
const endAngleIndicator = group.getObjectByName('endAngleIndicator') as Line
if (endAngleIndicator) {
updateAngleIndicator(endAngleIndicator, {
center,
radiusPx: ANGLE_INDICATOR_RADIUS,
startAngle: 0,
endAngle: (normalizedEndAngle * Math.PI) / 180,
scale,
})
endAngleIndicator.visible = isHandlesVisible
}
const endAngleLengthIndicator = group.getObjectByName(
'endAngleLengthIndicator'
) as Group
if (endAngleLengthIndicator) {
const labelWrapper = endAngleLengthIndicator.getObjectByName(
SEGMENT_LENGTH_LABEL_TEXT
) as CSS2DObject
const labelWrapperElem = labelWrapper.element as HTMLDivElement
const label = labelWrapperElem.children[0] as HTMLParagraphElement
label.innerText = `A:${roundOff(normalizedEndAngle)}`
label.classList.add(SEGMENT_LENGTH_LABEL_TEXT)
// Position the label
const indicatorPoint = {
x: center[0] + (Math.cos(endAngle) * radius) / 2,
y: center[1] + (Math.sin(endAngle) * radius) / 2,
}
labelWrapper.position.set(indicatorPoint.x, indicatorPoint.y, 0)
endAngleLengthIndicator.visible = isHandlesVisible
}
return () =>
sceneInfra.updateOverlayDetails({
handle: endAngleHandle,
group,
isHandlesVisible,
from,
to,
angle: endAngle + (Math.PI / 2) * (ccw ? 1 : -1),
hasThreeDotMenu: true,
})
}
}
class ThreePointArcSegment implements SegmentUtils {
init: SegmentUtils['init'] = ({
input,
id,
pathToNode,
isDraftSegment,
scale = 1,
theme,
isSelected = false,
sceneInfra,
prevSegment,
}) => {
if (input.type !== 'circle-three-point-segment') {
return new Error('Invalid segment type')
}
const { p1, p2, p3 } = input
const { center_x, center_y, radius } = calculate_circle_from_3_points(
p1[0],
p1[1],
p2[0],
p2[1],
p3[0],
p3[1]
)
const center: [number, number] = [center_x, center_y]
const baseColor = getThemeColorForThreeJs(theme)
const color = isSelected ? 0x0000ff : baseColor
// Calculate start and end angles
const startAngle = Math.atan2(p1[1] - center[1], p1[0] - center[0])
const endAngle = Math.atan2(p3[1] - center[1], p3[0] - center[0])
const group = new Group()
const geometry = createArcGeometry({
center,
radius,
startAngle,
endAngle,
ccw: !isClockwise([p1, p2, p3]),
isDashed: isDraftSegment,
scale,
})
const mat = new MeshBasicMaterial({ color })
const arcMesh = new Mesh(geometry, mat)
const meshType = isDraftSegment
? THREE_POINT_ARC_SEGMENT_DASH
: THREE_POINT_ARC_SEGMENT_BODY
// Create handles for p2 and p3 using createCircleThreePointHandle
const p2Handle = createCircleThreePointHandle(
scale,
theme,
THREE_POINT_ARC_HANDLE2,
color
)
p2Handle.position.set(p2[0], p2[1], 0)
const p3Handle = createCircleThreePointHandle(
scale,
theme,
THREE_POINT_ARC_HANDLE3,
color
)
p3Handle.position.set(p3[0], p3[1], 0)
arcMesh.userData.type = meshType
arcMesh.name = meshType
group.userData = {
type: THREE_POINT_ARC_SEGMENT,
draft: isDraftSegment,
id,
from: p1,
to: p3,
p1,
p2,
p3,
radius,
center,
ccw: false,
prevSegment,
pathToNode,
isSelected,
baseColor,
}
group.name = THREE_POINT_ARC_SEGMENT
group.add(arcMesh, p2Handle, p3Handle)
const updateOverlaysCallback = this.update({
prevSegment,
input,
group,
scale,
sceneInfra,
})
if (err(updateOverlaysCallback)) return updateOverlaysCallback
return {
group,
updateOverlaysCallback,
}
}
update: SegmentUtils['update'] = ({
prevSegment,
input,
group,
scale = 1,
sceneInfra,
}) => {
if (input.type !== 'circle-three-point-segment') {
return new Error('Invalid segment type')
}
const { p1, p2, p3 } = input
const { center_x, center_y, radius } = calculate_circle_from_3_points(
p1[0],
p1[1],
p2[0],
p2[1],
p3[0],
p3[1]
)
const center: [number, number] = [center_x, center_y]
group.userData.from = p1
group.userData.to = p3
group.userData.p1 = p1
group.userData.p2 = p2
group.userData.p3 = p3
group.userData.center = center
group.userData.radius = radius
group.userData.prevSegment = prevSegment
// Calculate start and end angles
const startAngle = Math.atan2(p1[1] - center[1], p1[0] - center[0])
const endAngle = Math.atan2(p3[1] - center[1], p3[0] - center[0])
const p2Handle = group.getObjectByName(THREE_POINT_ARC_HANDLE2) as Group
const p3Handle = group.getObjectByName(THREE_POINT_ARC_HANDLE3) as Group
const arcSegmentBody = group.children.find(
(child) => child.userData.type === THREE_POINT_ARC_SEGMENT_BODY
) as Mesh
if (arcSegmentBody) {
const newGeo = createArcGeometry({
radius,
center,
startAngle,
endAngle,
ccw: !isClockwise([p1, p2, p3]),
scale,
})
arcSegmentBody.geometry = newGeo
}
const arcSegmentBodyDashed = group.getObjectByName(
THREE_POINT_ARC_SEGMENT_DASH
)
if (arcSegmentBodyDashed instanceof Mesh) {
arcSegmentBodyDashed.geometry = createArcGeometry({
center,
radius,
startAngle,
endAngle,
ccw: !isClockwise([p1, p2, p3]),
isDashed: true,
scale,
})
}
if (p2Handle) {
p2Handle.position.set(p2[0], p2[1], 0)
p2Handle.scale.set(scale, scale, scale)
p2Handle.visible = true
}
if (p3Handle) {
p3Handle.position.set(p3[0], p3[1], 0)
p3Handle.scale.set(scale, scale, scale)
p3Handle.visible = true
}
return () => {
const overlays: SegmentOverlays = {}
const overlayDetails = [p2Handle, p3Handle].map((handle, index) =>
sceneInfra.updateOverlayDetails({
handle: handle,
group,
isHandlesVisible: true,
from: p1,
to: p3,
angle: endAngle + Math.PI / 2,
hasThreeDotMenu: true,
})
)
const segmentOverlays: SegmentOverlay[] = []
overlayDetails.forEach((payload, index) => {
if (payload?.type === 'set-one') {
overlays[payload.pathToNodeString] = payload.seg
// Add filterValue: 'interior' for p2 and 'end' for p3
segmentOverlays.push({
...payload.seg[0],
filterValue: index === 0 ? 'interior' : 'end',
})
}
})
const segmentOverlayPayload: SegmentOverlayPayload = {
type: 'set-one',
pathToNodeString:
overlayDetails[0]?.type === 'set-one'
? overlayDetails[0].pathToNodeString
: '',
seg: segmentOverlays,
}
return segmentOverlayPayload
}
}
}
export function createProfileStartHandle({
from,
isDraft = false,
@ -1010,7 +1616,7 @@ function createCircleCenterHandle(
function createCircleThreePointHandle(
scale = 1,
theme: Themes,
name: `circle-three-point-handle${'1' | '2' | '3'}`,
name: string,
color?: number
): Group {
const circleCenterGroup = new Group()
@ -1249,9 +1855,11 @@ export function createArcGeometry({
)
)
const remainingArcGeometry = new ExtrudeGeometry(shape, {
steps: 50,
steps: 1,
bevelEnabled: false,
extrudePath: remainingArcPath,
extrudePath: new CatmullRomCurve3(
remainingArcPoints.map((p) => new Vector3(p.x, p.y, 0))
),
})
dashGeometries.push(remainingArcGeometry)
}
@ -1351,10 +1959,111 @@ export function dashedStraight(
geo.userData.type = 'dashed'
return geo
}
function createLine({
from,
to,
scale,
color,
}: {
from: [number, number]
to: [number, number]
scale: number
color: number
}): Line {
// Implementation for creating a line
const lineGeometry = new BufferGeometry().setFromPoints([
new Vector3(from[0], from[1], 0),
new Vector3(to[0], to[1], 0),
])
const lineMaterial = new LineBasicMaterial({ color })
return new Line(lineGeometry, lineMaterial)
}
function updateLine(
line: Line,
{
from,
to,
scale,
}: { from: [number, number]; to: [number, number]; scale: number }
) {
// Implementation for updating a line
const points = [
new Vector3(from[0], from[1], 0),
new Vector3(to[0], to[1], 0),
]
line.geometry.setFromPoints(points)
}
function createAngleIndicator({
center,
radius,
startAngle,
endAngle,
scale,
color,
}: {
center: [number, number]
radius: number
startAngle: number
endAngle: number
scale: number
color: number
}): Line {
// Implementation for creating an angle indicator
const curve = new EllipseCurve(
center[0],
center[1],
radius,
radius,
startAngle,
endAngle,
false,
0
)
const points = curve.getPoints(50)
const geometry = new BufferGeometry().setFromPoints(points)
const material = new LineBasicMaterial({ color })
return new Line(geometry, material)
}
function updateAngleIndicator(
angleIndicator: Line,
{
center,
radiusPx,
startAngle,
endAngle,
scale,
}: {
center: [number, number]
radiusPx: number
startAngle: number
endAngle: number
scale: number
}
) {
// Implementation for updating an angle indicator
const curve = new EllipseCurve(
center[0],
center[1],
radiusPx * scale,
radiusPx * scale,
startAngle,
endAngle,
endAngle < startAngle,
0
)
const points = curve.getPoints(50)
angleIndicator.geometry.setFromPoints(points)
}
export const segmentUtils = {
straight: new StraightSegment(),
tangentialArcTo: new TangentialArcToSegment(),
circle: new CircleSegment(),
circleThreePoint: new CircleThreePointSegment(),
arc: new ArcSegment(),
threePointArc: new ThreePointArcSegment(),
} as const

View File

@ -70,6 +70,7 @@ import {
import {
KclValue,
PathToNode,
PipeExpression,
Program,
VariableDeclaration,
parse,
@ -1497,6 +1498,48 @@ export const ModelingMachineProvider = ({
return result
}
),
'set-up-draft-arc-three-point': fromPromise(
async ({ input: { sketchDetails, data } }) => {
if (!sketchDetails || !data)
return reject('No sketch details or data')
sceneEntitiesManager.tearDownSketch({ removeAxis: false })
const result = await sceneEntitiesManager.setupDraftArcThreePoint(
sketchDetails.sketchEntryNodePath,
sketchDetails.sketchNodePaths,
sketchDetails.planeNodePath,
sketchDetails.zAxis,
sketchDetails.yAxis,
sketchDetails.origin,
data
)
if (err(result)) return reject(result)
// eslint-disable-next-line @typescript-eslint/no-floating-promises
codeManager.updateEditorWithAstAndWriteToFile(kclManager.ast)
return result
}
),
'set-up-draft-arc': fromPromise(
async ({ input: { sketchDetails, data } }) => {
if (!sketchDetails || !data)
return reject('No sketch details or data')
sceneEntitiesManager.tearDownSketch({ removeAxis: false })
const result = await sceneEntitiesManager.setupDraftArc(
sketchDetails.sketchEntryNodePath,
sketchDetails.sketchNodePaths,
sketchDetails.planeNodePath,
sketchDetails.zAxis,
sketchDetails.yAxis,
sketchDetails.origin,
data
)
if (err(result)) return reject(result)
await codeManager.updateEditorWithAstAndWriteToFile(kclManager.ast)
return result
}
),
'setup-client-side-sketch-segments': fromPromise(
async ({ input: { sketchDetails, selectionRanges } }) => {
if (!sketchDetails) return
@ -1568,24 +1611,53 @@ export const ModelingMachineProvider = ({
}
const indexToDelete = sketchDetails?.expressionIndexToDelete || -1
let isLastInPipeThreePointArc = false
if (indexToDelete >= 0) {
// this is the expression that was added when as sketch tool was used but not completed
// i.e first click for the center of the circle, but not the second click for the radius
// we added a circle to editor, but they bailed out early so we should remove it
moddedAst.body.splice(indexToDelete, 1)
// make sure the deleted expression is removed from the sketchNodePaths
updatedSketchNodePaths = updatedSketchNodePaths.filter(
(path) => path[1][0] !== indexToDelete
const pipe = getNodeFromPath<PipeExpression>(
moddedAst,
pathToProfile,
'PipeExpression'
)
// if the deleted expression was the entryNodePath, we should just make it the first sketchNodePath
// as a safe default
pathToProfile =
pathToProfile[1][0] !== indexToDelete
? pathToProfile
: updatedSketchNodePaths[0]
if (err(pipe)) {
isLastInPipeThreePointArc = false
} else {
const lastInPipe = pipe?.node?.body?.[pipe.node.body.length - 1]
if (
lastInPipe &&
Number(pathToProfile[1][0]) === indexToDelete &&
lastInPipe.type === 'CallExpression' &&
lastInPipe.callee.type === 'Identifier' &&
lastInPipe.callee.name === 'arcTo'
) {
isLastInPipeThreePointArc = true
pipe.node.body = pipe.node.body.slice(0, -1)
}
}
if (!isLastInPipeThreePointArc) {
moddedAst.body.splice(indexToDelete, 1)
// make sure the deleted expression is removed from the sketchNodePaths
updatedSketchNodePaths = updatedSketchNodePaths.filter(
(path) => path[1][0] !== indexToDelete
)
// if the deleted expression was the entryNodePath, we should just make it the first sketchNodePath
// as a safe default
pathToProfile =
pathToProfile[1][0] !== indexToDelete
? pathToProfile
: updatedSketchNodePaths[0]
}
}
if (doesNeedSplitting || indexToDelete >= 0) {
if (
doesNeedSplitting ||
indexToDelete >= 0 ||
isLastInPipeThreePointArc
) {
await kclManager.executeAstMock(moddedAst)
await codeManager.updateEditorWithAstAndWriteToFile(moddedAst)
}

View File

@ -28,6 +28,8 @@ export type ToolTip =
| 'tangentialArcTo'
| 'circle'
| 'circleThreePoint'
| 'arcTo'
| 'arc'
export const toolTips: Array<ToolTip> = [
'line',
@ -44,6 +46,8 @@ export const toolTips: Array<ToolTip> = [
'angledLineThatIntersects',
'tangentialArcTo',
'circleThreePoint',
'arc',
'arcTo',
]
export async function executeAst({

View File

@ -48,6 +48,7 @@ import {
RawArgs,
CreatedSketchExprResult,
SketchLineHelperKw,
InputArgKeys,
} from 'lang/std/stdTypes'
import {
@ -984,7 +985,10 @@ export const tangentialArcTo: SketchLineHelper = {
return {
modifiedAst: _node,
pathToNode: [
...pathToNode,
...pathToNode.slice(
0,
pathToNode.findIndex(([_, type]) => type === 'PipeExpression') + 1
),
['body', 'PipeExpression'],
[pipe.body.length - 1, 'CallExpression'],
],
@ -1227,6 +1231,658 @@ export const circle: SketchLineHelperKw = {
]
},
}
export const arc: SketchLineHelper = {
add: ({
node,
variables,
pathToNode,
segmentInput,
replaceExistingCallback,
spliceBetween,
}) => {
if (segmentInput.type !== 'arc-segment') return ARC_SEGMENT_ERR
const { center, radius, from, to } = segmentInput
const _node = { ...node }
const nodeMeta = getNodeFromPath<PipeExpression | CallExpression>(
_node,
pathToNode,
'PipeExpression'
)
if (err(nodeMeta)) return nodeMeta
const { node: pipe } = nodeMeta
const nodeMeta2 = getNodeFromPath<VariableDeclarator>(
_node,
pathToNode,
'VariableDeclarator'
)
if (err(nodeMeta2)) return nodeMeta2
const { node: varDec } = nodeMeta2
// Calculate start angle (from center to 'from' point)
const startAngle = Math.atan2(from[1] - center[1], from[0] - center[0])
// Calculate end angle (from center to 'to' point)
const endAngle = Math.atan2(to[1] - center[1], to[0] - center[0])
// Create literals for the angles (convert to degrees)
const startAngleDegrees = (startAngle * 180) / Math.PI
const endAngleDegrees = (endAngle * 180) / Math.PI
// Create the arc call expression
const arcObj = createObjectExpression({
radius: createLiteral(roundOff(radius)),
angleStart: createLiteral(roundOff(startAngleDegrees)),
angleEnd: createLiteral(roundOff(endAngleDegrees)),
})
const newArc = createCallExpression('arc', [
arcObj,
createPipeSubstitution(),
])
if (
spliceBetween &&
!replaceExistingCallback &&
pipe.type === 'PipeExpression'
) {
const pathToNodeIndex = pathToNode.findIndex(
(x) => x[1] === 'PipeExpression'
)
const pipeIndex = pathToNode[pathToNodeIndex + 1][0]
if (typeof pipeIndex === 'undefined' || typeof pipeIndex === 'string') {
return new Error('pipeIndex is undefined')
}
pipe.body = [
...pipe.body.slice(0, pipeIndex),
newArc,
...pipe.body.slice(pipeIndex),
]
return {
modifiedAst: _node,
pathToNode,
}
}
if (replaceExistingCallback && pipe.type !== 'CallExpression') {
const { index: callIndex } = splitPathAtPipeExpression(pathToNode)
const result = replaceExistingCallback([
{
type: 'objectProperty',
key: 'center',
argType: 'xRelative',
expr: createLiteral(roundOff(center[0])),
},
{
type: 'objectProperty',
key: 'center',
argType: 'yRelative',
expr: createLiteral(roundOff(center[1])),
},
{
type: 'objectProperty',
key: 'radius',
argType: 'radius',
expr: createLiteral(roundOff(radius)),
},
{
type: 'objectProperty',
key: 'angle',
argType: 'angle',
expr: createLiteral(roundOff(startAngleDegrees)),
},
{
type: 'objectProperty',
key: 'angle',
argType: 'angle',
expr: createLiteral(roundOff(endAngleDegrees)),
},
])
if (err(result)) return result
const { callExp, valueUsedInTransform } = result
pipe.body[callIndex] = callExp
return {
modifiedAst: _node,
pathToNode: [
...pathToNode.slice(
0,
pathToNode.findIndex(([_, type]) => type === 'PipeExpression') + 1
),
[pipe.body.length - 1, 'CallExpression'],
],
valueUsedInTransform,
}
}
if (pipe.type === 'PipeExpression') {
pipe.body = [...pipe.body, newArc]
return {
modifiedAst: _node,
pathToNode: [
...pathToNode.slice(
0,
pathToNode.findIndex(([_, type]) => type === 'PipeExpression') + 1
),
[pipe.body.length - 1, 'CallExpression'],
],
}
} else {
varDec.init = createPipeExpression([varDec.init, newArc])
}
return {
modifiedAst: _node,
pathToNode,
}
},
updateArgs: ({ node, pathToNode, input }) => {
if (input.type !== 'arc-segment') return ARC_SEGMENT_ERR
const { center, radius, from, to } = input
const _node = { ...node }
const nodeMeta = getNodeFromPath<CallExpression>(_node, pathToNode)
if (err(nodeMeta)) return nodeMeta
const { node: callExpression, shallowPath } = nodeMeta
const firstArg = callExpression.arguments?.[0]
if (firstArg.type !== 'ObjectExpression') {
return new Error('Expected object expression as first argument')
}
// Calculate start angle (from center to 'from' point)
const startAngle = Math.atan2(from[1] - center[1], from[0] - center[0])
// Calculate end angle (from center to 'to' point)
const endAngle = Math.atan2(to[1] - center[1], to[0] - center[0])
// Create literals for the angles (convert to degrees)
const startAngleDegrees = (startAngle * 180) / Math.PI
const endAngleDegrees = (endAngle * 180) / Math.PI
// Update radius
const newRadius = createLiteral(roundOff(radius))
mutateObjExpProp(firstArg, newRadius, 'radius')
// Update angleStart
const newAngleStart = createLiteral(roundOff(startAngleDegrees))
mutateObjExpProp(firstArg, newAngleStart, 'angleStart')
// Update angleEnd
const newAngleEnd = createLiteral(roundOff(endAngleDegrees))
mutateObjExpProp(firstArg, newAngleEnd, 'angleEnd')
return {
modifiedAst: _node,
pathToNode: shallowPath,
}
},
getTag: getTag(),
addTag: addTag(),
getConstraintInfo: (callExp, code, pathToNode, filterValue) => {
// TODO this isn't quiet right, the filter value needs to be added to group the radius and start angle together
// with the end angle by itself,
// also both angles are just called angle, which is not correct
if (callExp.type !== 'CallExpression') return []
const args = callExp.arguments
if (args.length < 1) return []
const firstArg = args[0]
if (firstArg.type !== 'ObjectExpression') return []
// Find radius, angleStart, and angleEnd properties
const radiusProp = firstArg.properties.find(
(prop) =>
prop.type === 'ObjectProperty' &&
prop.key.type === 'Identifier' &&
prop.key.name === 'radius'
)
const angleStartProp = firstArg.properties.find(
(prop) =>
prop.type === 'ObjectProperty' &&
prop.key.type === 'Identifier' &&
prop.key.name === 'angleStart'
)
const angleEndProp = firstArg.properties.find(
(prop) =>
prop.type === 'ObjectProperty' &&
prop.key.type === 'Identifier' &&
prop.key.name === 'angleEnd'
)
if (!radiusProp || !angleStartProp || !angleEndProp) return []
const pathToFirstArg: PathToNode = [
...pathToNode,
['arguments', 'CallExpression'],
[0, 'index'],
]
const pathToRadiusProp: PathToNode = [
...pathToFirstArg,
['properties', 'ObjectExpression'],
[firstArg.properties.indexOf(radiusProp), 'index'],
]
const pathToAngleStartProp: PathToNode = [
...pathToFirstArg,
['properties', 'ObjectExpression'],
[firstArg.properties.indexOf(angleStartProp), 'index'],
]
const pathToAngleEndProp: PathToNode = [
...pathToFirstArg,
['properties', 'ObjectExpression'],
[firstArg.properties.indexOf(angleEndProp), 'index'],
]
const pathToRadiusValue: PathToNode = [
...pathToRadiusProp,
['value', 'ObjectProperty'],
]
const pathToAngleStartValue: PathToNode = [
...pathToAngleStartProp,
['value', 'ObjectProperty'],
]
const pathToAngleEndValue: PathToNode = [
...pathToAngleEndProp,
['value', 'ObjectProperty'],
]
const constraints: ConstrainInfo[] = [
constrainInfo(
'radius',
isNotLiteralArrayOrStatic(radiusProp.value),
code.slice(radiusProp.value.start, radiusProp.value.end),
'arc',
'radius',
topLevelRange(radiusProp.value.start, radiusProp.value.end),
pathToRadiusValue
),
constrainInfo(
'angle',
isNotLiteralArrayOrStatic(angleStartProp.value),
code.slice(angleStartProp.value.start, angleStartProp.value.end),
'arc',
'angleStart',
topLevelRange(angleStartProp.value.start, angleStartProp.value.end),
pathToAngleStartValue
),
constrainInfo(
'angle',
isNotLiteralArrayOrStatic(angleEndProp.value),
code.slice(angleEndProp.value.start, angleEndProp.value.end),
'arc',
'angleEnd',
topLevelRange(angleEndProp.value.start, angleEndProp.value.end),
pathToAngleEndValue
),
]
return constraints
},
}
export const arcTo: SketchLineHelper = {
add: ({
node,
variables,
pathToNode,
segmentInput,
replaceExistingCallback,
spliceBetween,
}) => {
if (segmentInput.type !== 'circle-three-point-segment')
return ARC_SEGMENT_ERR
const { p2, p3 } = segmentInput
const _node = { ...node }
const nodeMeta = getNodeFromPath<PipeExpression>(
_node,
pathToNode,
'PipeExpression'
)
if (err(nodeMeta)) return nodeMeta
const { node: pipe } = nodeMeta
// p1 is the start point (from the previous segment)
// p2 is the interior point
// p3 is the end point
const interior = createArrayExpression([
createLiteral(roundOff(p2[0], 2)),
createLiteral(roundOff(p2[1], 2)),
])
const end = createArrayExpression([
createLiteral(roundOff(p3[0], 2)),
createLiteral(roundOff(p3[1], 2)),
])
if (replaceExistingCallback) {
const result = replaceExistingCallback([
{
type: 'objectProperty',
key: 'interior' as InputArgKeys,
argType: 'xAbsolute',
expr: createLiteral(0) as any, // This is a workaround, the actual value will be set later
},
{
type: 'objectProperty',
key: 'end' as InputArgKeys,
argType: 'yAbsolute',
expr: createLiteral(0) as any, // This is a workaround, the actual value will be set later
},
])
if (err(result)) return result
const { callExp, valueUsedInTransform } = result
// Now manually update the object properties
if (
callExp.type === 'CallExpression' &&
callExp.arguments[0]?.type === 'ObjectExpression'
) {
const objExp = callExp.arguments[0]
const interiorProp = objExp.properties.find(
(p) =>
p.type === 'ObjectProperty' &&
p.key.type === 'Identifier' &&
p.key.name === 'interior'
)
const endProp = objExp.properties.find(
(p) =>
p.type === 'ObjectProperty' &&
p.key.type === 'Identifier' &&
p.key.name === 'end'
)
if (interiorProp && interiorProp.type === 'ObjectProperty') {
interiorProp.value = interior
}
if (endProp && endProp.type === 'ObjectProperty') {
endProp.value = end
}
}
const { index: callIndex } = splitPathAtPipeExpression(pathToNode)
pipe.body[callIndex] = callExp
return {
modifiedAst: _node,
pathToNode: [
...pathToNode.slice(
0,
pathToNode.findIndex(([_, type]) => type === 'PipeExpression') + 1
),
[pipe.body.length - 1, 'CallExpression'],
],
valueUsedInTransform,
}
}
const objExp = createObjectExpression({
interior,
end,
})
const newLine = createCallExpression('arcTo', [
objExp,
createPipeSubstitution(),
])
if (spliceBetween) {
const { index: callIndex } = splitPathAtPipeExpression(pathToNode)
pipe.body.splice(callIndex + 1, 0, newLine)
} else if (pipe.type === 'PipeExpression') {
pipe.body.push(newLine)
} else {
const nodeMeta2 = getNodeFromPath<VariableDeclarator>(
_node,
pathToNode,
'VariableDeclarator'
)
if (err(nodeMeta2)) return nodeMeta2
const { node: varDec } = nodeMeta2
varDec.init = createPipeExpression([varDec.init, newLine])
return {
modifiedAst: _node,
pathToNode: [
...pathToNode.slice(
0,
pathToNode.findIndex(([key, _]) => key === 'init') + 1
),
['body', 'PipeExpression'],
[1, 'CallExpression'],
],
}
}
return {
modifiedAst: _node,
pathToNode: [
...pathToNode.slice(
0,
pathToNode.findIndex(([_, type]) => type === 'PipeExpression') + 1
),
[pipe.body.length - 1, 'CallExpression'],
],
}
},
updateArgs: ({ node, pathToNode, input }) => {
if (input.type !== 'circle-three-point-segment') return ARC_SEGMENT_ERR
const { p1, p2, p3 } = input
const _node = { ...node }
const nodeMeta = getNodeFromPath<CallExpression>(_node, pathToNode)
if (err(nodeMeta)) return nodeMeta
const { node: callExpression } = nodeMeta
// Update the first argument which should be an object with interior and end properties
const firstArg = callExpression.arguments?.[0]
if (!firstArg) return new Error('Missing first argument in arcTo')
const interiorPoint = createArrayExpression([
createLiteral(roundOff(p2[0], 2)),
createLiteral(roundOff(p2[1], 2)),
])
const endPoint = createArrayExpression([
createLiteral(roundOff(p3[0], 2)),
createLiteral(roundOff(p3[1], 2)),
])
mutateObjExpProp(firstArg, interiorPoint, 'interior')
mutateObjExpProp(firstArg, endPoint, 'end')
return {
modifiedAst: _node,
pathToNode,
}
},
getTag: getTag(),
addTag: addTag(),
getConstraintInfo: (callExp, code, pathToNode, filterValue) => {
if (callExp.type !== 'CallExpression') return []
const args = callExp.arguments
if (args.length < 1) return []
const firstArg = args[0]
if (firstArg.type !== 'ObjectExpression') return []
// Find interior and end properties
const interiorProp = firstArg.properties.find(
(prop) =>
prop.type === 'ObjectProperty' &&
prop.key.type === 'Identifier' &&
prop.key.name === 'interior'
)
const endProp = firstArg.properties.find(
(prop) =>
prop.type === 'ObjectProperty' &&
prop.key.type === 'Identifier' &&
prop.key.name === 'end'
)
if (!interiorProp || !endProp) return []
if (
interiorProp.value.type !== 'ArrayExpression' ||
endProp.value.type !== 'ArrayExpression'
)
return []
const interiorArr = interiorProp.value
const endArr = endProp.value
if (interiorArr.elements.length < 2 || endArr.elements.length < 2) return []
const pathToFirstArg: PathToNode = [
...pathToNode,
['arguments', 'CallExpression'],
[0, 'index'],
]
const pathToInteriorProp: PathToNode = [
...pathToFirstArg,
['properties', 'ObjectExpression'],
[firstArg.properties.indexOf(interiorProp), 'index'],
]
const pathToEndProp: PathToNode = [
...pathToFirstArg,
['properties', 'ObjectExpression'],
[firstArg.properties.indexOf(endProp), 'index'],
]
const pathToInteriorValue: PathToNode = [
...pathToInteriorProp,
['value', 'ObjectProperty'],
]
const pathToEndValue: PathToNode = [
...pathToEndProp,
['value', 'ObjectProperty'],
]
const pathToInteriorX: PathToNode = [
...pathToInteriorValue,
['elements', 'ArrayExpression'],
[0, 'index'],
]
const pathToInteriorY: PathToNode = [
...pathToInteriorValue,
['elements', 'ArrayExpression'],
[1, 'index'],
]
const pathToEndX: PathToNode = [
...pathToEndValue,
['elements', 'ArrayExpression'],
[0, 'index'],
]
const pathToEndY: PathToNode = [
...pathToEndValue,
['elements', 'ArrayExpression'],
[1, 'index'],
]
const constraints: (ConstrainInfo & { filterValue: string })[] = [
{
type: 'xAbsolute',
isConstrained: isNotLiteralArrayOrStatic(interiorArr.elements[0]),
value: code.slice(
interiorArr.elements[0].start,
interiorArr.elements[0].end
),
stdLibFnName: 'arcTo',
argPosition: {
type: 'arrayInObject',
key: 'interior',
index: 0,
},
sourceRange: topLevelRange(
interiorArr.elements[0].start,
interiorArr.elements[0].end
),
pathToNode: pathToInteriorX,
filterValue: 'interior',
},
{
type: 'yAbsolute',
isConstrained: isNotLiteralArrayOrStatic(interiorArr.elements[1]),
value: code.slice(
interiorArr.elements[1].start,
interiorArr.elements[1].end
),
stdLibFnName: 'arcTo',
argPosition: {
type: 'arrayInObject',
key: 'interior',
index: 1,
},
sourceRange: topLevelRange(
interiorArr.elements[1].start,
interiorArr.elements[1].end
),
pathToNode: pathToInteriorY,
filterValue: 'interior',
},
{
type: 'xAbsolute',
isConstrained: isNotLiteralArrayOrStatic(endArr.elements[0]),
value: code.slice(endArr.elements[0].start, endArr.elements[0].end),
stdLibFnName: 'arcTo',
argPosition: {
type: 'arrayInObject',
key: 'end',
index: 0,
},
sourceRange: topLevelRange(
endArr.elements[0].start,
endArr.elements[0].end
),
pathToNode: pathToEndX,
filterValue: 'end',
},
{
type: 'yAbsolute',
isConstrained: isNotLiteralArrayOrStatic(endArr.elements[1]),
value: code.slice(endArr.elements[1].start, endArr.elements[1].end),
stdLibFnName: 'arcTo',
argPosition: {
type: 'arrayInObject',
key: 'end',
index: 1,
},
sourceRange: topLevelRange(
endArr.elements[1].start,
endArr.elements[1].end
),
pathToNode: pathToEndY,
filterValue: 'end',
},
]
const finalConstraints: ConstrainInfo[] = []
constraints.forEach((constraint) => {
if (!filterValue) {
finalConstraints.push(constraint)
}
if (filterValue && constraint.filterValue === filterValue) {
finalConstraints.push(constraint)
}
})
return finalConstraints
},
}
export const circleThreePoint: SketchLineHelperKw = {
add: ({ node, pathToNode, segmentInput, replaceExistingCallback }) => {
if (segmentInput.type !== 'circle-three-point-segment') {
@ -2270,6 +2926,8 @@ export const sketchLineHelperMap: { [key: string]: SketchLineHelper } = {
angledLineToY,
angledLineThatIntersects,
tangentialArcTo,
arc,
arcTo,
} as const
export const sketchLineHelperMapKw: { [key: string]: SketchLineHelperKw } = {
@ -2297,6 +2955,7 @@ export function changeSketchArguments(
},
input: SegmentInputs
): { modifiedAst: Node<Program>; pathToNode: PathToNode } | Error {
// TODO/less-than-ideal, this obvious relies on node getting mutated, as changing the following with `_node = structuredClone(node)` breaks the draft line animation.
const _node = { ...node }
const thePath =
sourceRangeOrPath.type === 'sourceRange'
@ -2529,7 +3188,7 @@ export function addCallExpressionsToPipe({
pathToNode: PathToNode
expressions: Node<CallExpression | CallExpressionKw>[]
}) {
const _node = { ...node }
const _node: Node<Program> = structuredClone(node)
const pipeExpression = getNodeFromPath<Node<PipeExpression>>(
_node,
pathToNode,
@ -2548,7 +3207,7 @@ export function addCloseToPipe({
node,
pathToNode,
}: {
node: Program
node: Node<Program>
variables: VariableMap
pathToNode: PathToNode
}) {

View File

@ -1351,8 +1351,11 @@ export function getRemoveConstraintsTransform(
}
if (
sketchFnExp.type === 'CallExpressionKw' &&
sketchFnExp.callee.name === 'circleThreePoint'
(sketchFnExp.type === 'CallExpressionKw' &&
sketchFnExp.callee.name === 'circleThreePoint') ||
(sketchFnExp.type === 'CallExpression' &&
(sketchFnExp.callee.name === 'arcTo' ||
sketchFnExp.callee.name === 'arc'))
) {
return false
}
@ -1613,6 +1616,9 @@ function getTransformMapPath(
if (!toolTips.includes(name)) {
return false
}
if (name === 'arcTo') {
return false
}
// check if the function is locked down and so can't be transformed
const firstArg = getFirstArg(sketchFnExp)
@ -2093,8 +2099,10 @@ export function transformAstSketchLines({
center: seg.center,
radius: seg.radius,
from,
to: from, // For a full circle, to is the same as from
ccw: true, // Default to counter-clockwise for circles
}
: seg.type === 'CircleThreePoint'
: seg.type === 'CircleThreePoint' || seg.type === 'ArcThreePoint'
? {
type: 'circle-three-point-segment',
p1: seg.p1,

View File

@ -42,8 +42,10 @@ interface StraightSegmentInput {
interface ArcSegmentInput {
type: 'arc-segment'
from: [number, number]
to: [number, number]
center: [number, number]
radius: number
ccw: boolean
}
/** Inputs for three point circle */
interface CircleThreePointSegmentInput {
@ -98,6 +100,9 @@ export type InputArgKeys =
| 'p1'
| 'p2'
| 'p3'
| 'end'
| 'interior'
| `angle${'Start' | 'End'}`
export interface SingleValueInput<T> {
type: 'singleValue'
argType: LineInputsType

View File

@ -414,10 +414,22 @@ export const toolbarConfig: Record<ToolbarModeName, ToolbarMode> = {
},
{
id: 'three-point-arc',
onClick: () => console.error('Three-point arc not yet implemented'),
onClick: ({ modelingState, modelingSend }) =>
modelingSend({
type: 'change tool',
data: {
tool: !modelingState.matches({ Sketch: 'Arc three point tool' })
? 'arcThreePoint'
: 'none',
},
}),
icon: 'arc',
status: 'unavailable',
status: 'available',
title: 'Three-point Arc',
hotkey: (state) =>
state.matches({ Sketch: 'Arc three point tool' })
? ['Esc', 'T']
: 'T',
showTitle: false,
description: 'Draw a circular arc defined by three points',
links: [
@ -426,6 +438,26 @@ export const toolbarConfig: Record<ToolbarModeName, ToolbarMode> = {
url: 'https://github.com/KittyCAD/modeling-app/issues/1659',
},
],
isActive: (state) =>
state.matches({ Sketch: 'Arc three point tool' }),
},
{
id: 'arc',
onClick: ({ modelingState, modelingSend }) =>
modelingSend({
type: 'change tool',
data: {
tool: !modelingState.matches({ Sketch: 'Arc tool' })
? 'arc'
: 'none',
},
}),
icon: 'arc',
status: DEV ? 'available' : 'unavailable',
title: 'Arc',
description: 'Start drawing an arc',
links: [],
isActive: (state) => state.matches({ Sketch: 'Arc tool' }),
},
],
{
@ -473,7 +505,7 @@ export const toolbarConfig: Record<ToolbarModeName, ToolbarMode> = {
tool: !modelingState.matches({
Sketch: 'Circle three point tool',
})
? 'circleThreePointNeo'
? 'circleThreePoint'
: 'none',
},
}),

View File

@ -5,6 +5,7 @@ import {
onDragNumberCalculation,
hasLeadingZero,
hasDigitsLeftOfDecimal,
isClockwise,
} from './utils'
import { SourceRange, topLevelRange } from '../lang/wasm'
@ -1253,3 +1254,56 @@ describe('testing onDragNumberCalculation', () => {
})
})
})
describe('testing isClockwise', () => {
it('returns for counter clockwise points', () => {
// Points in clockwise order (rectangle)
const clockwisePoints: [number, number][] = [
[0, 0], // bottom-left
[10, 0], // bottom-right
[10, 10], // top-right
[0, 10], // top-left
]
expect(isClockwise(clockwisePoints)).toBe(false)
})
it('returns true for clockwise points', () => {
// Points in counter-clockwise order (rectangle)
const counterClockwisePoints: [number, number][] = [
[0, 0], // bottom-left
[0, 10], // top-left
[10, 10], // top-right
[10, 0], // bottom-right
]
expect(isClockwise(counterClockwisePoints)).toBe(true)
})
it('returns false for less than 3 points', () => {
expect(
isClockwise([
[0, 0],
[10, 10],
])
).toBe(false)
expect(isClockwise([[0, 0]])).toBe(false)
expect(isClockwise([])).toBe(false)
})
it('correctly identifies counter-clockwise triangle', () => {
const clockwiseTriangle: [number, number][] = [
[0, 0],
[10, 0],
[5, 10],
]
expect(isClockwise(clockwiseTriangle)).toBe(false)
})
it('correctly identifies clockwise triangle', () => {
const counterClockwiseTriangle: [number, number][] = [
[0, 0],
[5, 10],
[10, 0],
]
expect(isClockwise(counterClockwiseTriangle)).toBe(true)
})
})

View File

@ -387,3 +387,22 @@ export function onMouseDragMakeANewNumber(
if (!newVal) return
setText(newVal)
}
export function isClockwise(points: [number, number][]): boolean {
// Need at least 3 points to determine orientation
if (points.length < 3) {
return false
}
// Calculate the sum of (x2 - x1) * (y2 + y1) for all edges
// This is the "shoelace formula" for calculating the signed area
let sum = 0
for (let i = 0; i < points.length; i++) {
const current = points[i]
const next = points[(i + 1) % points.length]
sum += (next[0] - current[0]) * (next[1] + current[1])
}
// If sum is positive, the points are in clockwise order
return sum > 0
}

File diff suppressed because one or more lines are too long