diff --git a/src/clientSideScene/sceneEntities.ts b/src/clientSideScene/sceneEntities.ts index d9560bd2f9..7b284f9cd3 100644 --- a/src/clientSideScene/sceneEntities.ts +++ b/src/clientSideScene/sceneEntities.ts @@ -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 { @@ -63,6 +69,7 @@ import { getNodeFromPath, getNodePathFromSourceRange } from 'lang/queryAst' import { executeAst, ToolTip } from 'lang/langHelpers' import { createProfileStartHandle, + createArcGeometry, SegmentUtils, segmentUtils, } from './segments' @@ -1206,6 +1213,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 = 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( + 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], diff --git a/src/clientSideScene/sceneInfra.ts b/src/clientSideScene/sceneInfra.ts index 7a35de291a..39e8a87cc9 100644 --- a/src/clientSideScene/sceneInfra.ts +++ b/src/clientSideScene/sceneInfra.ts @@ -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 diff --git a/src/lib/toolbar.ts b/src/lib/toolbar.ts index e5512cf74d..cbc43a3180 100644 --- a/src/lib/toolbar.ts +++ b/src/lib/toolbar.ts @@ -440,10 +440,19 @@ export const toolbarConfig: Record = { }, { 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', diff --git a/src/machines/modelingMachine.ts b/src/machines/modelingMachine.ts index 3bb313b9d1..e56c9fdf49 100644 --- a/src/machines/modelingMachine.ts +++ b/src/machines/modelingMachine.ts @@ -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' } @@ -317,6 +318,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' } @@ -570,6 +572,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', }, @@ -978,6 +983,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 @@ -2338,6 +2362,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'], @@ -2371,6 +2399,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',