Add 3-point circle tool (#4832)
* 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
This commit is contained in:
		@ -3,6 +3,9 @@ import {
 | 
			
		||||
  DoubleSide,
 | 
			
		||||
  Group,
 | 
			
		||||
  Intersection,
 | 
			
		||||
  Line,
 | 
			
		||||
  LineDashedMaterial,
 | 
			
		||||
  BufferGeometry,
 | 
			
		||||
  Mesh,
 | 
			
		||||
  MeshBasicMaterial,
 | 
			
		||||
  Object3D,
 | 
			
		||||
@ -13,6 +16,7 @@ import {
 | 
			
		||||
  Points,
 | 
			
		||||
  Quaternion,
 | 
			
		||||
  Scene,
 | 
			
		||||
  SphereGeometry,
 | 
			
		||||
  Vector2,
 | 
			
		||||
  Vector3,
 | 
			
		||||
} from 'three'
 | 
			
		||||
@ -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 {
 | 
			
		||||
@ -64,6 +70,7 @@ import { getNodeFromPath, getNodePathFromSourceRange } from 'lang/queryAst'
 | 
			
		||||
import { executeAst, ToolTip } from 'lang/langHelpers'
 | 
			
		||||
import {
 | 
			
		||||
  createProfileStartHandle,
 | 
			
		||||
  createArcGeometry,
 | 
			
		||||
  SegmentUtils,
 | 
			
		||||
  segmentUtils,
 | 
			
		||||
} from './segments'
 | 
			
		||||
@ -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],
 | 
			
		||||
 | 
			
		||||
@ -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>
 | 
			
		||||
 | 
			
		||||
@ -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',
 | 
			
		||||
 | 
			
		||||
@ -215,6 +215,7 @@ export type SketchTool =
 | 
			
		||||
  | 'rectangle'
 | 
			
		||||
  | 'center rectangle'
 | 
			
		||||
  | 'circle'
 | 
			
		||||
  | 'circle3Points'
 | 
			
		||||
  | 'none'
 | 
			
		||||
 | 
			
		||||
export type ModelingMachineEvent =
 | 
			
		||||
@ -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' }
 | 
			
		||||
@ -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' }
 | 
			
		||||
 | 
			
		||||
@ -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',
 | 
			
		||||
  },
 | 
			
		||||
@ -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
 | 
			
		||||
 | 
			
		||||
@ -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'],
 | 
			
		||||
@ -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',
 | 
			
		||||
 | 
			
		||||
		Reference in New Issue
	
	Block a user