3-point circle interactive component (#4982)

* Add dragging behavior to 3 point circle

Uses our talked about technique of calling Rust functions to calculate new
geometry coordinates and parameters. It works very well!

Need to have the modeling app show "edit sketch" still.

* Cargo fmt

* cargo fmt

* Address Jon's comments

* Fix clippy

* Dont use isNaN

* Make points easier to select (enlarge)

* Fix circle button not being activated

* Ensure efficiency of updating editor vs execution

* Make cargo clippy happy
This commit is contained in:
49fl
2025-01-16 11:10:36 -05:00
committed by GitHub
parent d4e955289c
commit 0a1a6e50cf
8 changed files with 377 additions and 134 deletions

View File

@ -1,5 +1,6 @@
import {
BoxGeometry,
Color,
DoubleSide,
Group,
Intersection,
@ -59,6 +60,7 @@ import {
resultIsOk,
SourceRange,
} from 'lang/wasm'
import { calculate_circle_from_3_points } from '../wasm-lib/pkg/wasm_lib'
import {
engineCommandManager,
kclManager,
@ -70,7 +72,7 @@ import { getNodeFromPath, getNodePathFromSourceRange } from 'lang/queryAst'
import { executeAst, ToolTip } from 'lang/langHelpers'
import {
createProfileStartHandle,
createArcGeometry,
createCircleGeometry,
SegmentUtils,
segmentUtils,
} from './segments'
@ -109,6 +111,8 @@ import { CSS2DObject } from 'three/examples/jsm/renderers/CSS2DRenderer'
import { Point3d } from 'wasm-lib/kcl/bindings/Point3d'
import { SegmentInputs } from 'lang/std/stdTypes'
import { Node } from 'wasm-lib/kcl/bindings/Node'
import { LabeledArg } from 'wasm-lib/kcl/bindings/LabeledArg'
import { Literal } from 'wasm-lib/kcl/bindings/Literal'
import { radToDeg } from 'three/src/math/MathUtils'
import { getArtifactFromRange, codeRefFromRange } from 'lang/std/artifactGraph'
@ -1261,110 +1265,98 @@ export class SceneEntities {
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)
// If you use 3D points, do not rotate anything.
// If you use 2D points (easier to deal with, generally do this!), then
// rotate the group just like this! Remember to rotate other groups too!
groupOfDrafts.setRotationFromQuaternion(orientation)
this.scene.add(groupOfDrafts)
const DRAFT_POINT_RADIUS = 6
// How large the points on the circle will render as
const DRAFT_POINT_RADIUS = 10 // px
const createPoint = (center: Vector3): number => {
// The target of our dragging
let target: Object3D | undefined = undefined
// The KCL this will generate.
const kclCircle3Point = parse(`circleThreePoint(
p1 = [0.0, 0.0],
p2 = [0.0, 0.0],
p3 = [0.0, 0.0],
)`)
const createPoint = (
center: Vector3,
opts?: { noInteraction?: boolean }
): Mesh => {
const geometry = new SphereGeometry(DRAFT_POINT_RADIUS)
const color = getThemeColorForThreeJs(sceneInfra._theme)
const material = new MeshBasicMaterial({ color })
const material = new MeshBasicMaterial({
color: opts?.noInteraction
? sceneInfra._theme === 'light'
? new Color(color).multiplyScalar(0.15)
: new Color(0x010101).multiplyScalar(2000)
: color,
})
const mesh = new Mesh(geometry, material)
mesh.userData = { type: CIRCLE_3_POINT_DRAFT_POINT }
mesh.userData = {
type: opts?.noInteraction ? 'ghost' : CIRCLE_3_POINT_DRAFT_POINT,
}
mesh.renderOrder = 1000
mesh.layers.set(SKETCH_LAYER)
mesh.position.copy(center)
mesh.scale.set(scale, scale, scale)
mesh.renderOrder = 100
groupOfDrafts.add(mesh)
return mesh.id
return mesh
}
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 createCircle3PointGraphic = async (
points: Vector2[],
center: Vector2,
radius: number
) => {
if (
Number.isNaN(radius) ||
Number.isNaN(center.x) ||
Number.isNaN(center.y)
)
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 lineCircle = createCircleGeometry({
center: [center.x, center.y],
radius,
color,
isDashed: false,
scale: 1,
})
const materialCircle = new MeshBasicMaterial({ color })
lineCircle.userData = { type: CIRCLE_3_POINT_DRAFT_CIRCLE }
lineCircle.layers.set(SKETCH_LAYER)
// devnote: it's a mistake to use these with EllipseCurve :)
// lineCircle.position.set(center.x, center.y, 0)
// lineCircle.scale.set(scale, scale, scale)
if (groupCircle) groupOfDrafts.remove(groupCircle)
groupCircle = new Group()
groupCircle.renderOrder = 1
groupCircle.add(lineCircle)
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 pointMesh = createPoint(new Vector3(center.x, center.y, 0), {
noInteraction: true,
})
groupCircle.add(pointMesh)
const geometryPolyLine = new BufferGeometry().setFromPoints([
...points,
points[0],
...points.map((p) => new Vector3(p.x, p.y, 0)),
new Vector3(points[0].x, points[0].y, 0),
])
const materialPolyLine = new LineDashedMaterial({
color,
scale,
scale: 1 / scale,
dashSize: 6,
gapSize: 6,
})
@ -1375,13 +1367,146 @@ export class SceneEntities {
groupOfDrafts.add(groupCircle)
}
// The target of our dragging
let target: Object3D | undefined = undefined
const insertCircle3PointKclIntoAstSnapshot = (
points: Vector2[]
): Program => {
if (err(kclCircle3Point) || kclCircle3Point.program === null)
return kclManager.ast
if (kclCircle3Point.program.body[0].type !== 'ExpressionStatement')
return kclManager.ast
if (
kclCircle3Point.program.body[0].expression.type !== 'CallExpressionKw'
)
return kclManager.ast
const arg = (x: LabeledArg): Literal[] | undefined => {
if (
'arg' in x &&
'elements' in x.arg &&
x.arg.type === 'ArrayExpression'
) {
if (x.arg.elements.every((x) => x.type === 'Literal')) {
return x.arg.elements
}
}
return undefined
}
const kclCircle3PointArgs =
kclCircle3Point.program.body[0].expression.arguments
const arg0 = arg(kclCircle3PointArgs[0])
if (!arg0) return kclManager.ast
arg0[0].value = points[0].x
arg0[0].raw = points[0].x.toString()
arg0[1].value = points[0].y
arg0[1].raw = points[0].y.toString()
const arg1 = arg(kclCircle3PointArgs[1])
if (!arg1) return kclManager.ast
arg1[0].value = points[1].x
arg1[0].raw = points[1].x.toString()
arg1[1].value = points[1].y
arg1[1].raw = points[1].y.toString()
const arg2 = arg(kclCircle3PointArgs[2])
if (!arg2) return kclManager.ast
arg2[0].value = points[2].x
arg2[0].raw = points[2].x.toString()
arg2[1].value = points[2].y
arg2[1].raw = points[2].y.toString()
const astSnapshot = structuredClone(kclManager.ast)
const startSketchOnASTNode = getNodeFromPath<VariableDeclaration>(
astSnapshot,
startSketchOnASTNodePath,
'VariableDeclaration'
)
if (err(startSketchOnASTNode)) return astSnapshot
// It's possible we're already dealing with a PipeExpression.
// Modify the current one.
if (
startSketchOnASTNode.node.declaration.init.type === 'PipeExpression' &&
startSketchOnASTNode.node.declaration.init.body[1].type ===
'CallExpressionKw' &&
startSketchOnASTNode.node.declaration.init.body.length >= 2
) {
startSketchOnASTNode.node.declaration.init.body[1].arguments =
kclCircle3Point.program.body[0].expression.arguments
} else {
// Clone a new node based on the old, and replace the old with the new.
const clonedStartSketchOnASTNode = structuredClone(startSketchOnASTNode)
startSketchOnASTNode.node.declaration.init = createPipeExpression([
clonedStartSketchOnASTNode.node.declaration.init,
kclCircle3Point.program.body[0].expression,
])
}
// Return the `Program`
return astSnapshot
}
const updateCircle3Point = async (opts?: { execute?: true }) => {
const points_ = Array.from(points.values())
const circleParams = calculate_circle_from_3_points(
points_[0].x,
points_[0].y,
points_[1].x,
points_[1].y,
points_[2].x,
points_[2].y
)
if (Number.isNaN(circleParams.radius)) return
await createCircle3PointGraphic(
points_,
new Vector2(circleParams.center_x, circleParams.center_y),
circleParams.radius
)
const astWithNewCode = insertCircle3PointKclIntoAstSnapshot(points_)
const codeAsString = recast(astWithNewCode)
if (err(codeAsString)) return
codeManager.updateCodeStateEditor(codeAsString)
}
const cleanupFn = () => {
this.scene.remove(groupOfDrafts)
}
// The AST node we extracted earlier may already have a circleThreePoint!
// Use the points in the AST as starting points.
const astSnapshot = structuredClone(kclManager.ast)
const maybeVariableDeclaration = getNodeFromPath<VariableDeclaration>(
astSnapshot,
startSketchOnASTNodePath,
'VariableDeclaration'
)
if (err(maybeVariableDeclaration))
return () => {
done()
}
const maybeCallExpressionKw = maybeVariableDeclaration.node.declaration.init
if (
maybeCallExpressionKw.type === 'PipeExpression' &&
maybeCallExpressionKw.body[1].type === 'CallExpressionKw' &&
maybeCallExpressionKw.body[1]?.callee.name === 'circleThreePoint'
) {
maybeCallExpressionKw?.body[1].arguments
.map(
({ arg }: any) =>
new Vector2(arg.elements[0].value, arg.elements[1].value)
)
.forEach((point: Vector2) => {
const pointMesh = createPoint(new Vector3(point.x, point.y, 0))
groupOfDrafts.add(pointMesh)
points.set(pointMesh.id, point)
})
void updateCircle3Point()
}
sceneInfra.setCallbacks({
async onDrag(args) {
const draftPointsIntersected = args.intersects.filter(
@ -1397,8 +1522,18 @@ export class SceneEntities {
// The user was off their mark! Missed the object to select.
if (!target) return
target.position.copy(args.intersectionPoint.threeD)
target.position.copy(
new Vector3(
args.intersectionPoint.twoD.x,
args.intersectionPoint.twoD.y,
0
)
)
points.set(target.id, args.intersectionPoint.twoD)
if (points.size <= 2) return
await updateCircle3Point()
},
async onDragEnd(_args) {
target = undefined
@ -1407,45 +1542,19 @@ export class SceneEntities {
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'
const pointMesh = createPoint(
new Vector3(
args.intersectionPoint.twoD.x,
args.intersectionPoint.twoD.y,
0
)
if (err(nodeQueryResult)) return Promise.reject(nodeQueryResult)
const startSketchOnASTNode = nodeQueryResult
)
groupOfDrafts.add(pointMesh)
points.set(pointMesh.id, args.intersectionPoint.twoD)
const circleParams = circle3Point(Array.from(points.values()))
if (points.size <= 2) return
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)
done()
await updateCircle3Point()
},
})

View File

@ -9,6 +9,9 @@ import {
ExtrudeGeometry,
Group,
LineCurve3,
LineBasicMaterial,
LineDashedMaterial,
Line,
Mesh,
MeshBasicMaterial,
NormalBufferAttributes,
@ -1003,6 +1006,49 @@ export function createArcGeometry({
return geo
}
// (lee) The above is much more complex than necessary.
// I've derived the new code from:
// https://threejs.org/docs/#api/en/extras/curves/EllipseCurve
// I'm not sure why it wasn't done like this in the first place?
// I don't touch the code above because it may break something else.
export function createCircleGeometry({
center,
radius,
color,
isDashed = false,
scale = 1,
}: {
center: Coords2d
radius: number
color: number
isDashed?: boolean
scale?: number
}): Line {
const circle = new EllipseCurve(
center[0],
center[1],
radius,
radius,
0,
Math.PI * 2,
true,
scale
)
const points = circle.getPoints(75) // just enough points to not see edges.
const geometry = new BufferGeometry().setFromPoints(points)
const material = !isDashed
? new LineBasicMaterial({ color })
: new LineDashedMaterial({
color,
scale,
dashSize: 6,
gapSize: 6,
})
const line = new Line(geometry, material)
line.computeLineDistances()
return line
}
export function dashedStraight(
from: Coords2d,
to: Coords2d,

View File

@ -460,18 +460,16 @@ export const toolbarConfig: Record<ToolbarModeName, ToolbarMode> = {
disabled: (state) =>
state.matches('Sketch no face') ||
(!canRectangleOrCircleTool(state.context) &&
!state.matches({ Sketch: 'Circle tool' })),
isActive: (state) => state.matches({ Sketch: 'Circle tool' }),
!state.matches({ Sketch: 'Circle tool' }) &&
!state.matches({ Sketch: 'circle3PointToolSelect' })),
isActive: (state) =>
state.matches({ Sketch: 'Circle tool' }) ||
state.matches({ Sketch: 'circle3PointToolSelect' }),
hotkey: (state) =>
state.matches({ Sketch: 'Circle tool' }) ? ['Esc', 'C'] : 'C',
showTitle: false,
description: 'Start drawing a circle from its center',
links: [
{
label: 'GitHub issue',
url: 'https://github.com/KittyCAD/modeling-app/issues/1501',
},
],
links: [],
},
{
id: 'circle-three-points',
@ -488,7 +486,7 @@ export const toolbarConfig: Record<ToolbarModeName, ToolbarMode> = {
}),
icon: 'circle',
status: 'available',
title: 'Three-point circle',
title: '3-point circle',
showTitle: false,
description: 'Draw a circle defined by three points',
links: [],

View File

@ -422,6 +422,8 @@ export const modelingMachine = setup({
},
'is editing existing sketch': ({ context: { sketchDetails } }) =>
isEditingExistingSketch({ sketchDetails }),
'is editing 3-point circle': ({ context: { sketchDetails } }) =>
isEditing3PointCircle({ sketchDetails }),
'Can make selection horizontal': ({ context: { selectionRanges } }) => {
const info = horzVertInfo(selectionRanges, 'horizontal')
if (trap(info)) return false
@ -2187,6 +2189,10 @@ export const modelingMachine = setup({
target: 'SketchIdle',
guard: 'is editing existing sketch',
},
{
target: 'circle3PointToolSelect',
guard: 'is editing 3-point circle',
},
'Line tool',
],
},
@ -2518,13 +2524,8 @@ export const modelingMachine = setup({
circle3PointToolSelect: {
invoke: {
id: 'actor-circle-3-point',
input: function ({ context, event }) {
// These are not really necessary but I believe they are needed
// to satisfy TypeScript type narrowing or undefined check.
if (event.type !== 'change tool') return
if (event.data?.tool !== 'circle3Points') return
input: function ({ context }) {
if (!context.sketchDetails) return
return context.sketchDetails
},
src: 'actorCircle3Point',
@ -2782,6 +2783,34 @@ export function isEditingExistingSketch({
)
return (hasStartProfileAt && pipeExpression.body.length > 2) || hasCircle
}
export function isEditing3PointCircle({
sketchDetails,
}: {
sketchDetails: SketchDetails | null
}): boolean {
if (!sketchDetails?.sketchPathToNode) return false
const variableDeclaration = getNodeFromPath<VariableDeclarator>(
kclManager.ast,
sketchDetails.sketchPathToNode,
'VariableDeclarator'
)
if (err(variableDeclaration)) return false
if (variableDeclaration.node.type !== 'VariableDeclarator') return false
const pipeExpression = variableDeclaration.node.init
if (pipeExpression.type !== 'PipeExpression') return false
const hasStartProfileAt = pipeExpression.body.some(
(item) =>
item.type === 'CallExpression' && item.callee.name === 'startProfileAt'
)
const hasCircle3Point = pipeExpression.body.some(
(item) =>
item.type === 'CallExpressionKw' &&
item.callee.name === 'circleThreePoint'
)
return (
(hasStartProfileAt && pipeExpression.body.length > 2) || hasCircle3Point
)
}
export function pipeHasCircle({
sketchDetails,
}: {
@ -2802,6 +2831,27 @@ export function pipeHasCircle({
)
return hasCircle
}
export function pipeHasCircleThreePoint({
sketchDetails,
}: {
sketchDetails: SketchDetails | null
}): boolean {
if (!sketchDetails?.sketchPathToNode) return false
const variableDeclaration = getNodeFromPath<VariableDeclarator>(
kclManager.ast,
sketchDetails.sketchPathToNode,
'VariableDeclarator'
)
if (err(variableDeclaration)) return false
if (variableDeclaration.node.type !== 'VariableDeclarator') return false
const pipeExpression = variableDeclaration.node.init
if (pipeExpression.type !== 'PipeExpression') return false
const hasCircle = pipeExpression.body.some(
(item) =>
item.type === 'CallExpression' && item.callee.name === 'circleThreePoint'
)
return hasCircle
}
export function canRectangleOrCircleTool({
sketchDetails,

View File

@ -70,7 +70,7 @@ mod settings;
#[cfg(test)]
mod simulation_tests;
mod source_range;
mod std;
pub mod std;
#[cfg(not(target_arch = "wasm32"))]
pub mod test_server;
mod thread;
@ -84,7 +84,7 @@ pub use engine::{EngineManager, ExecutionKind};
pub use errors::{CompilationError, ConnectionError, ExecError, KclError, KclErrorWithOutputs};
pub use execution::{
cache::{CacheInformation, OldAstState},
ExecState, ExecutorContext, ExecutorSettings,
ExecState, ExecutorContext, ExecutorSettings, Point2d,
};
pub use lsp::{
copilot::Backend as CopilotLspBackend,

View File

@ -55,6 +55,10 @@ impl KwArgs {
pub fn len(&self) -> usize {
self.labeled.len() + if self.unlabeled.is_some() { 1 } else { 0 }
}
/// Are there no arguments?
pub fn is_empty(&self) -> bool {
self.labeled.len() == 0 && self.unlabeled.is_none()
}
}
#[derive(Debug, Clone)]

View File

@ -270,6 +270,19 @@ pub fn calculate_circle_center(p1: [f64; 2], p2: [f64; 2], p3: [f64; 2]) -> [f64
[x, y]
}
pub struct CircleParams {
pub center: Point2d,
pub radius: f64,
}
pub fn calculate_circle_from_3_points(points: [Point2d; 3]) -> CircleParams {
let center: Point2d = calculate_circle_center(points[0].into(), points[1].into(), points[2].into()).into();
CircleParams {
center,
radius: distance(center, points[1]),
}
}
#[cfg(test)]
mod tests {
// Here you can bring your functions into scope

View File

@ -5,7 +5,7 @@ use std::sync::Arc;
use futures::stream::TryStreamExt;
use gloo_utils::format::JsValueSerdeExt;
use kcl_lib::{
exec::IdGenerator, CacheInformation, CoreDump, EngineManager, ExecState, ModuleId, OldAstState, Program,
exec::IdGenerator, CacheInformation, CoreDump, EngineManager, ExecState, ModuleId, OldAstState, Point2d, Program,
};
use tokio::sync::RwLock;
use tower_lsp::{LspService, Server};
@ -576,3 +576,26 @@ pub fn base64_decode(input: &str) -> Result<Vec<u8>, JsValue> {
Err(JsValue::from_str("Invalid base64 encoding"))
}
#[wasm_bindgen]
pub struct WasmCircleParams {
pub center_x: f64,
pub center_y: f64,
pub radius: f64,
}
/// Calculate a circle from 3 points.
#[wasm_bindgen]
pub fn calculate_circle_from_3_points(ax: f64, ay: f64, bx: f64, by: f64, cx: f64, cy: f64) -> WasmCircleParams {
let result = kcl_lib::std::utils::calculate_circle_from_3_points([
Point2d { x: ax, y: ay },
Point2d { x: bx, y: by },
Point2d { x: cx, y: cy },
]);
WasmCircleParams {
center_x: result.center.x,
center_y: result.center.y,
radius: result.radius,
}
}