Skip to content

Commit

Permalink
Add 3-point circle tool (#4832)
Browse files Browse the repository at this point in the history
* Add 3-point circle tool

This adds a 1st pass for the 3-point circle tool.

There is disabled code to drag around the 3 points and redraw the circle and
a triangle created by those points. It will be enabled in a follow-up PR
when we have circle3Point in the stdlib.

For now, all it does is after the 3rd click, will insert circle center-radius
KCL code for users to modify.

* PR comments
  • Loading branch information
lf94 authored and guptaarnav committed Jan 7, 2025
1 parent fcb4ba7 commit 1600568
Show file tree
Hide file tree
Showing 4 changed files with 291 additions and 4 deletions.
229 changes: 229 additions & 0 deletions src/clientSideScene/sceneEntities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ import {
DoubleSide,
Group,
Intersection,
Line,
LineDashedMaterial,
BufferGeometry,
Mesh,
MeshBasicMaterial,
Object3D,
Expand All @@ -13,6 +16,7 @@ import {
Points,
Quaternion,
Scene,
SphereGeometry,
Vector2,
Vector3,
} from 'three'
Expand All @@ -31,6 +35,8 @@ import {
SKETCH_LAYER,
X_AXIS,
Y_AXIS,
CIRCLE_3_POINT_DRAFT_POINT,
CIRCLE_3_POINT_DRAFT_CIRCLE,
} from './sceneInfra'
import { isQuaternionVertical, quaternionFromUpNForward } from './helpers'
import {
Expand Down Expand Up @@ -64,6 +70,7 @@ import { getNodeFromPath, getNodePathFromSourceRange } from 'lang/queryAst'
import { executeAst, ToolTip } from 'lang/langHelpers'
import {
createProfileStartHandle,
createArcGeometry,
SegmentUtils,
segmentUtils,
} from './segments'
Expand Down Expand Up @@ -1219,6 +1226,228 @@ export class SceneEntities {
},
})
}

// lee: Well, it appears all our code in sceneEntities each act as their own
// kind of classes. In this case, I'll keep utility functions pertaining to
// circle3Point here. Feel free to extract as needed.
entryDraftCircle3Point = async (
startSketchOnASTNodePath: PathToNode,
forward: Vector3,
up: Vector3,
sketchOrigin: Vector3
) => {
// lee: Not a fan we need to re-iterate this dummy object all over the place
// just to get the scale but okie dokie.
const dummy = new Mesh()
dummy.position.set(0, 0, 0)
const scale = sceneInfra.getClientSceneScaleFactor(dummy)

const orientation = quaternionFromUpNForward(up, forward)

// Reminder: the intersection plane is the primary way to derive a XY
// position from a mouse click in ThreeJS.
// Here, we position and orient so it's facing the viewer.
this.intersectionPlane!.setRotationFromQuaternion(orientation)
this.intersectionPlane!.position.copy(sketchOrigin)

// Keep track of points in the scene with their ThreeJS ids.
const points: Map<number, Vector2> = new Map()

// Keep a reference so we can destroy and recreate as needed.
let groupCircle: Group | undefined

// Add our new group to the list of groups to render
const groupOfDrafts = new Group()
groupOfDrafts.name = 'circle-3-point-group'
groupOfDrafts.position.copy(sketchOrigin)
// lee: I'm keeping this here as a developer gotchya:
// Do not reorient your surfaces to the intersection plane. Your points are
// already in 3D space, not 2D. If you intersect say XZ, you want the points
// to continue to live at the 3D intersection point, not be rotated to end
// up elsewhere!
// groupOfDrafts.setRotationFromQuaternion(orientation)
this.scene.add(groupOfDrafts)

const DRAFT_POINT_RADIUS = 6

const createPoint = (center: Vector3): number => {
const geometry = new SphereGeometry(DRAFT_POINT_RADIUS)
const color = getThemeColorForThreeJs(sceneInfra._theme)
const material = new MeshBasicMaterial({ color })

const mesh = new Mesh(geometry, material)
mesh.userData = { type: CIRCLE_3_POINT_DRAFT_POINT }
mesh.layers.set(SKETCH_LAYER)
mesh.position.copy(center)
mesh.scale.set(scale, scale, scale)
mesh.renderOrder = 100

groupOfDrafts.add(mesh)

return mesh.id
}

const circle3Point = (
points: Vector2[]
): undefined | { center: Vector3; radius: number } => {
// A 3-point circle is undefined if it doesn't have 3 points :)
if (points.length !== 3) return undefined

// y = (i/j)(x-h) + b
// i and j variables for the slopes
const i = [points[1].x - points[0].x, points[2].x - points[1].x]
const j = [points[1].y - points[0].y, points[2].y - points[1].y]

// Our / threejs coordinate system affects this a lot. If you take this
// code into a different code base, you may have to adjust a/b to being
// -1/a/b, b/a, etc! In this case, a/-b did the trick.
const m = [i[0] / -j[0], i[1] / -j[1]]

const h = [
(points[0].x + points[1].x) / 2,
(points[1].x + points[2].x) / 2,
]
const b = [
(points[0].y + points[1].y) / 2,
(points[1].y + points[2].y) / 2,
]

// Algebraically derived
const x = (-m[0] * h[0] + b[0] - b[1] + m[1] * h[1]) / (m[1] - m[0])
const y = m[0] * (x - h[0]) + b[0]

const center = new Vector3(x, y, 0)
const radius = Math.sqrt((points[1].x - x) ** 2 + (points[1].y - y) ** 2)

return {
center,
radius,
}
}

// TO BE SHORT LIVED: unused function to draw the circle and lines.
// @ts-ignore
// eslint-disable-next-line
const createCircle3Point = (points: Vector2[]) => {
const circleParams = circle3Point(points)

// A circle cannot be created for these points.
if (!circleParams) return

const color = getThemeColorForThreeJs(sceneInfra._theme)
const geometryCircle = createArcGeometry({
center: [circleParams.center.x, circleParams.center.y],
radius: circleParams.radius,
startAngle: 0,
endAngle: Math.PI * 2,
ccw: true,
isDashed: true,
scale,
})
const materialCircle = new MeshBasicMaterial({ color })

if (groupCircle) groupOfDrafts.remove(groupCircle)
groupCircle = new Group()
groupCircle.renderOrder = 1

const meshCircle = new Mesh(geometryCircle, materialCircle)
meshCircle.userData = { type: CIRCLE_3_POINT_DRAFT_CIRCLE }
meshCircle.layers.set(SKETCH_LAYER)
meshCircle.position.set(circleParams.center.x, circleParams.center.y, 0)
meshCircle.scale.set(scale, scale, scale)
groupCircle.add(meshCircle)

const geometryPolyLine = new BufferGeometry().setFromPoints([
...points,
points[0],
])
const materialPolyLine = new LineDashedMaterial({
color,
scale,
dashSize: 6,
gapSize: 6,
})
const meshPolyLine = new Line(geometryPolyLine, materialPolyLine)
meshPolyLine.computeLineDistances()
groupCircle.add(meshPolyLine)

groupOfDrafts.add(groupCircle)
}

const cleanup = () => {
this.scene.remove(groupOfDrafts)
}

// The target of our dragging
let target: Object3D | undefined = undefined

sceneInfra.setCallbacks({
async onDrag(args) {
const draftPointsIntersected = args.intersects.filter(
(intersected) =>
intersected.object.userData.type === CIRCLE_3_POINT_DRAFT_POINT
)

const firstPoint = draftPointsIntersected[0]
if (firstPoint && !target) {
target = firstPoint.object
}

// The user was off their mark! Missed the object to select.
if (!target) return

target.position.copy(args.intersectionPoint.threeD)
points.set(target.id, args.intersectionPoint.twoD)
},
async onDragEnd(_args) {
target = undefined
},
async onClick(args) {
if (points.size >= 3) return
if (!args.intersectionPoint) return

const id = createPoint(args.intersectionPoint.threeD)
points.set(id, args.intersectionPoint.twoD)

if (points.size < 2) return

// We've now got 3 points, let's create our circle!
const astSnapshot = structuredClone(kclManager.ast)
let nodeQueryResult
nodeQueryResult = getNodeFromPath<VariableDeclaration>(
astSnapshot,
startSketchOnASTNodePath,
'VariableDeclaration'
)
if (err(nodeQueryResult)) return Promise.reject(nodeQueryResult)
const startSketchOnASTNode = nodeQueryResult

const circleParams = circle3Point(Array.from(points.values()))

if (!circleParams) return

const kclCircle3Point = parse(`circle({
center = [${circleParams.center.x}, ${circleParams.center.y}],
radius = ${circleParams.radius},
}, %)`)

if (err(kclCircle3Point) || kclCircle3Point.program === null) return
if (kclCircle3Point.program.body[0].type !== 'ExpressionStatement')
return

const clonedStartSketchOnASTNode = structuredClone(startSketchOnASTNode)
startSketchOnASTNode.node.declaration.init = createPipeExpression([
clonedStartSketchOnASTNode.node.declaration.init,
kclCircle3Point.program.body[0].expression,
])

await kclManager.executeAstMock(astSnapshot)
await codeManager.updateEditorWithAstAndWriteToFile(astSnapshot)

sceneInfra.modelingSend({ type: 'circle3PointsFinished', cleanup })
},
})
}
setupDraftCircle = async (
sketchPathToNode: PathToNode,
forward: [number, number, number],
Expand Down
2 changes: 2 additions & 0 deletions src/clientSideScene/sceneInfra.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ export const ARROWHEAD = 'arrowhead'
export const SEGMENT_LENGTH_LABEL = 'segment-length-label'
export const SEGMENT_LENGTH_LABEL_TEXT = 'segment-length-label-text'
export const SEGMENT_LENGTH_LABEL_OFFSET_PX = 30
export const CIRCLE_3_POINT_DRAFT_POINT = 'circle-3-point-draft-point'
export const CIRCLE_3_POINT_DRAFT_CIRCLE = 'circle-3-point-draft-circle'

export interface OnMouseEnterLeaveArgs {
selected: Object3D<Object3DEventMap>
Expand Down
15 changes: 12 additions & 3 deletions src/lib/toolbar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -432,10 +432,19 @@ export const toolbarConfig: Record<ToolbarModeName, ToolbarMode> = {
},
{
id: 'circle-three-points',
onClick: () =>
console.error('Three-point circle not yet implemented'),
onClick: ({ modelingState, modelingSend }) =>
modelingSend({
type: 'change tool',
data: {
tool: !modelingState.matches({
Sketch: 'circle3PointToolSelect',
})
? 'circle3Points'
: 'none',
},
}),
icon: 'circle',
status: 'unavailable',
status: 'available',
title: 'Three-point circle',
showTitle: false,
description: 'Draw a circle defined by three points',
Expand Down
49 changes: 48 additions & 1 deletion src/machines/modelingMachine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,7 @@ export type SketchTool =
| 'rectangle'
| 'center rectangle'
| 'circle'
| 'circle3Points'
| 'none'

export type ModelingMachineEvent =
Expand All @@ -238,7 +239,7 @@ export type ModelingMachineEvent =
}
| { type: 'Sketch no face' }
| { type: 'Toggle gui mode' }
| { type: 'Cancel' }
| { type: 'Cancel'; cleanup?: () => void }
| { type: 'CancelSketch' }
| { type: 'Add start point' }
| { type: 'Make segment horizontal' }
Expand Down Expand Up @@ -318,6 +319,7 @@ export type ModelingMachineEvent =
| { type: 'Finish rectangle' }
| { type: 'Finish center rectangle' }
| { type: 'Finish circle' }
| { type: 'circle3PointsFinished'; cleanup?: () => void }
| { type: 'Artifact graph populated' }
| { type: 'Artifact graph emptied' }

Expand Down Expand Up @@ -566,6 +568,9 @@ export const modelingMachine = setup({
canRectangleOrCircleTool({ sketchDetails }),
'next is circle': ({ context: { sketchDetails, currentTool } }) =>
currentTool === 'circle' && canRectangleOrCircleTool({ sketchDetails }),
'next is circle 3 point': ({ context: { sketchDetails, currentTool } }) =>
currentTool === 'circle3Points' &&
canRectangleOrCircleTool({ sketchDetails }),
'next is line': ({ context }) => context.currentTool === 'line',
'next is none': ({ context }) => context.currentTool === 'none',
},
Expand Down Expand Up @@ -974,6 +979,25 @@ export const modelingMachine = setup({
return codeManager.updateEditorWithAstAndWriteToFile(kclManager.ast)
})
},
entryDraftCircle3Point: ({ context: { sketchDetails }, event }) => {
if (event.type !== 'change tool') return
if (event.data?.tool !== 'circle3Points') return
if (!sketchDetails) return

// eslint-disable-next-line @typescript-eslint/no-floating-promises
sceneEntitiesManager.entryDraftCircle3Point(
sketchDetails.sketchPathToNode,
new Vector3(...sketchDetails.zAxis),
new Vector3(...sketchDetails.yAxis),
new Vector3(...sketchDetails.origin)
)
},
exitDraftCircle3Point: ({ event }) => {
if (event.type !== 'circle3PointsFinished' && event.type !== 'Cancel')
return
if (!event.cleanup) return
event.cleanup()
},
'set up draft line without teardown': ({ context: { sketchDetails } }) => {
if (!sketchDetails) return

Expand Down Expand Up @@ -2336,6 +2360,10 @@ export const modelingMachine = setup({
target: 'Center Rectangle tool',
guard: 'next is center rectangle',
},
{
target: 'circle3PointToolSelect',
guard: 'next is circle 3 point',
},
],

entry: ['assign tool in context', 'reset selections'],
Expand Down Expand Up @@ -2369,6 +2397,25 @@ export const modelingMachine = setup({
initial: 'Awaiting origin',
entry: 'listen for circle origin',
},
circle3PointToolSelect: {
on: {
'change tool': 'Change Tool',
},

states: {
circle3PointsAwaiting: {
on: {
circle3PointsFinished: {
target: '#Modeling.Sketch.SketchIdle',
},
},
},
},

initial: 'circle3PointsAwaiting',
entry: 'entryDraftCircle3Point',
exit: 'exitDraftCircle3Point',
},
},

initial: 'Init',
Expand Down

0 comments on commit 1600568

Please sign in to comment.