@@ -830,6 +832,30 @@ const Toolbar_ = memo(
oldP.context?.currentTool === newP.context?.currentTool
)
+const ToolbarToasts = memo(function ToolbarToasts() {
+ useSignals()
+ const toasts = toolbarToastsSignal.value
+
+ if (toasts.length === 0) {
+ return null
+ }
+
+ return (
+ <>
+ {toasts.map((toast) => (
+
+ {toast.message}
+
+ ))}
+ >
+ )
+})
+
interface ToolbarItemContentsProps extends React.PropsWithChildren {
itemConfig: ToolbarItemResolved
configCallbackProps: ToolbarItemCallbackProps
diff --git a/src/lang/KclManager.ts b/src/lang/KclManager.ts
index 37eb4076308..8a086ea4823 100644
--- a/src/lang/KclManager.ts
+++ b/src/lang/KclManager.ts
@@ -35,6 +35,7 @@ import {
getSketchCheckpointLimit,
parse,
recast,
+ resultIsOk,
} from '@src/lang/wasm'
import type { ArtifactIndex } from '@src/lib/artifactIndex'
import { buildArtifactIndex } from '@src/lib/artifactIndex'
@@ -1268,6 +1269,9 @@ export class KclManager extends File {
syncSketchSolveOutcome(code: string, sceneGraphDelta: SceneGraphDelta): void {
const execState = execStateFromRust(sceneGraphDelta.exec_outcome)
+ this.diagnostics = []
+ void this.refreshSketchSolveLintDiagnostics(code, execState)
+
this.execState = execState
this.lastSuccessfulVariables = execState.variables
this.lastSuccessfulOperations = execState.operations
@@ -1283,6 +1287,30 @@ export class KclManager extends File {
void this.updateArtifactGraph(execState.artifactGraph)
}
+ private async refreshSketchSolveLintDiagnostics(
+ code: string,
+ execState: ExecState
+ ): Promise
{
+ const instance = await this.systemDeps.wasmInstancePromise
+ const parseResult = parse(code, instance)
+ if (err(parseResult) || !resultIsOk(parseResult)) {
+ return
+ }
+
+ const diagnostics = await lintAst({
+ ast: parseResult.program,
+ sourceCode: code,
+ instance,
+ rustContext: this.rustContext,
+ legacyAngleRefactorMetadata: execState.legacyAngleRefactorMetadata,
+ })
+
+ if (this.code !== code) {
+ return
+ }
+ this.addDiagnostics(diagnostics)
+ }
+
hasErrors(): boolean {
return this._astParseFailed || this.errors.length > 0
}
@@ -2327,6 +2355,7 @@ export class KclManager extends File {
sourceCode: this.code,
instance: await this.systemDeps.wasmInstancePromise,
rustContext: this.rustContext,
+ legacyAngleRefactorMetadata: execState.legacyAngleRefactorMetadata,
})
)
if (this.sceneEntitiesManager) {
diff --git a/src/lang/langHelpers.ts b/src/lang/langHelpers.ts
index de337de8a2b..4fb8146b89c 100644
--- a/src/lang/langHelpers.ts
+++ b/src/lang/langHelpers.ts
@@ -1,13 +1,17 @@
import type { Diagnostic } from '@codemirror/lint'
import { lspCodeActionEvent } from '@kittycad/codemirror-lsp-client'
+import type { LegacyAngleRefactorMeta } from '@rust/kcl-lib/bindings/LegacyAngleRefactorMeta'
import type { Node } from '@rust/kcl-lib/bindings/Node'
+import type { SourceRange } from '@rust/kcl-lib/bindings/SourceRange'
import { KCLError, toUtf16 } from '@src/lang/errors'
+import { convertLegacyAngleToAngleDimension } from '@src/lang/modifyAst/angle'
import type { ExecCallbacks, ExecState, Program } from '@src/lang/wasm'
-import { emptyExecState, kclLint } from '@src/lang/wasm'
+import { emptyExecState, kclLint, recast } from '@src/lang/wasm'
import { EXECUTE_AST_INTERRUPT_ERROR_STRING } from '@src/lib/constants'
import type RustContext from '@src/lib/rustContext'
import { jsAppSettings } from '@src/lib/settings/settingsUtils'
+import { isErr } from '@src/lib/trap'
import type { ModuleType } from '@src/lib/wasm_lib_wrapper'
import { REJECTED_TOO_EARLY_WEBSOCKET_MESSAGE } from '@src/network/utils'
import type { EditorView } from 'codemirror'
@@ -33,6 +37,9 @@ export type ToolTip =
| 'arc'
| 'startProfile'
+const sourceRangesEqual = (left: SourceRange, right: SourceRange) =>
+ left[0] === right[0] && left[1] === right[1] && left[2] === right[2]
+
export const toolTips: Array = [
'line',
'lineTo',
@@ -182,15 +189,25 @@ export async function lintAst({
sourceCode,
instance,
rustContext,
+ legacyAngleRefactorMetadata = [],
}: {
- ast: Program
+ ast: Node
sourceCode: string
instance: ModuleType
rustContext?: RustContext
+ legacyAngleRefactorMetadata?: LegacyAngleRefactorMeta[]
}): Promise> {
try {
let discovered_findings = await kclLint(ast, instance)
+ discovered_findings = discovered_findings.filter(
+ (lint) =>
+ lint.finding.code !== 'Z0007' ||
+ legacyAngleRefactorMetadata.some((metadata) =>
+ sourceRangesEqual(metadata.sourceRange, lint.pos)
+ )
+ )
+
// Filter out Z0005 if sketch solve mode is not enabled
// Only show Z0005 when useSketchSolveMode setting is enabled
let shouldShowZ0005 = false
@@ -217,6 +234,9 @@ export async function lintAst({
'Deprecated sketch syntax. This sketch cannot be converted to new sketch block syntax at this time.'
let message = lint.finding.title
const suggestion = lint.suggestion
+ const legacyAngleMetadata = legacyAngleRefactorMetadata.find((metadata) =>
+ sourceRangesEqual(metadata.sourceRange, lint.pos)
+ )
if (suggestion) {
actions = [
@@ -234,6 +254,35 @@ export async function lintAst({
},
},
]
+ } else if (lint.finding.code === 'Z0007' && legacyAngleMetadata) {
+ const modifiedAst = convertLegacyAngleToAngleDimension(
+ ast,
+ legacyAngleMetadata.sourceRange,
+ legacyAngleMetadata.sector,
+ legacyAngleMetadata.inverse,
+ instance
+ )
+ const convertedSource = isErr(modifiedAst)
+ ? modifiedAst
+ : recast(modifiedAst, instance)
+ if (!isErr(convertedSource)) {
+ actions = [
+ {
+ name: 'Convert to angleDimension',
+ apply: (view: EditorView, _from: number, _to: number) => {
+ if (view.state.doc.toString() !== sourceCode) return
+ view.dispatch({
+ changes: {
+ from: 0,
+ to: view.state.doc.length,
+ insert: convertedSource,
+ },
+ annotations: [lspCodeActionEvent],
+ })
+ },
+ },
+ ]
+ }
} else if (
lint.finding.code === 'Z0005' &&
rustContext &&
diff --git a/src/lang/modifyAst/angle.spec.ts b/src/lang/modifyAst/angle.spec.ts
new file mode 100644
index 00000000000..ba7d752f27d
--- /dev/null
+++ b/src/lang/modifyAst/angle.spec.ts
@@ -0,0 +1,83 @@
+import { join } from 'node:path'
+import { convertLegacyAngleToAngleDimension } from '@src/lang/modifyAst/angle'
+import type { Node } from '@rust/kcl-lib/bindings/Node'
+import { assertParse, recast } from '@src/lang/wasm'
+import type { Program, SourceRange } from '@src/lang/wasm'
+import { loadAndInitialiseWasmInstance } from '@src/lang/wasmUtilsNode'
+import { err } from '@src/lib/trap'
+import type { ModuleType } from '@src/lib/wasm_lib_wrapper'
+import { beforeAll, describe, expect, it } from 'vitest'
+
+const WASM_PATH = join(process.cwd(), 'public/kcl_wasm_lib_bg.wasm')
+let instance: ModuleType
+
+beforeAll(async () => {
+ instance = await loadAndInitialiseWasmInstance(WASM_PATH)
+})
+
+function legacyAngleRange(ast: Node): SourceRange {
+ const call = ast.body[0]
+ if (call.type !== 'ExpressionStatement') throw new Error('Expected sketch')
+ const sketch = call.expression
+ if (sketch.type !== 'SketchBlock') throw new Error('Expected sketch block')
+ const statement = sketch.body.items[0]
+ if (statement.type !== 'ExpressionStatement')
+ throw new Error('Expected expression')
+ const binary = statement.expression
+ if (binary.type !== 'BinaryExpression') throw new Error('Expected binary')
+ const angle = binary.left
+ if (angle.type !== 'CallExpressionKw') throw new Error('Expected angle call')
+ return [angle.start, angle.end, angle.moduleId]
+}
+
+describe('convertLegacyAngleToAngleDimension', () => {
+ it('preserves the line and label expressions while adding the solved sector', () => {
+ const ast = assertParse(
+ `sketch(on = XY) {
+ angle([line1, line2], labelPosition = [10mm, 11mm]) == targetAngle
+}`,
+ instance
+ )
+ const result = convertLegacyAngleToAngleDimension(
+ ast,
+ legacyAngleRange(ast),
+ 2,
+ true,
+ instance
+ )
+ if (err(result)) throw result
+ const code = recast(result, instance)
+ if (err(code)) throw code
+
+ expect(code).toContain(`angleDimension(
+ lines = [line1, line2],
+ sector = 2,
+ inverse = true,
+ labelPosition = [10mm, 11mm],
+) == targetAngle`)
+ })
+
+ it('omits inverse for the default directed angle', () => {
+ const ast = assertParse(
+ `sketch(on = XY) {
+ angle([line1, line2]) == 60deg
+}`,
+ instance
+ )
+ const result = convertLegacyAngleToAngleDimension(
+ ast,
+ legacyAngleRange(ast),
+ 1,
+ false,
+ instance
+ )
+ if (err(result)) throw result
+ const code = recast(result, instance)
+ if (err(code)) throw code
+
+ expect(code).toContain(
+ 'angleDimension(lines = [line1, line2], sector = 1) == 60deg'
+ )
+ expect(code).not.toContain('inverse')
+ })
+})
diff --git a/src/lang/modifyAst/angle.ts b/src/lang/modifyAst/angle.ts
new file mode 100644
index 00000000000..f44e28a5bc6
--- /dev/null
+++ b/src/lang/modifyAst/angle.ts
@@ -0,0 +1,49 @@
+import { createLabeledArg, createLiteral } from '@src/lang/create'
+import { traverse } from '@src/lang/queryAst'
+import type { Node } from '@rust/kcl-lib/bindings/Node'
+import type { Program, SourceRange } from '@src/lang/wasm'
+import type { ModuleType } from '@src/lib/wasm_lib_wrapper'
+
+export function convertLegacyAngleToAngleDimension(
+ ast: Node,
+ sourceRange: SourceRange,
+ sector: number,
+ inverse: boolean,
+ instance: ModuleType
+): Node | Error {
+ const modifiedAst = structuredClone(ast)
+ let converted = false
+
+ traverse(modifiedAst, {
+ enter(node) {
+ if (
+ converted ||
+ node.type !== 'CallExpressionKw' ||
+ node.start !== sourceRange[0] ||
+ node.end !== sourceRange[1] ||
+ node.moduleId !== sourceRange[2] ||
+ node.callee.name.name !== 'angle' ||
+ node.unlabeled === null
+ ) {
+ return
+ }
+
+ const lines = node.unlabeled
+ node.callee.name.name = 'angleDimension'
+ node.unlabeled = null
+ node.arguments = [
+ createLabeledArg('lines', lines),
+ createLabeledArg('sector', createLiteral(sector, instance)),
+ ...(inverse
+ ? [createLabeledArg('inverse', createLiteral(true, instance))]
+ : []),
+ ...node.arguments,
+ ]
+ converted = true
+ },
+ })
+
+ return converted
+ ? modifiedAst
+ : new Error('Could not find the legacy angle call for this refactor')
+}
diff --git a/src/lang/queryAst.ts b/src/lang/queryAst.ts
index 5675ac7381b..603301266bd 100644
--- a/src/lang/queryAst.ts
+++ b/src/lang/queryAst.ts
@@ -1,6 +1,7 @@
import type { FunctionExpression } from '@rust/kcl-lib/bindings/FunctionExpression'
import type { ImportStatement } from '@rust/kcl-lib/bindings/ImportStatement'
import type { Node } from '@rust/kcl-lib/bindings/Node'
+import type { Block } from '@rust/kcl-lib/bindings/Block'
import type { TypeDeclaration } from '@rust/kcl-lib/bindings/TypeDeclaration'
import {
createLiteral,
@@ -232,6 +233,7 @@ type KCLNode = Node<
| TypeDeclaration
| ReturnStatement
| Identifier
+ | Block
>
export function traverse(
@@ -285,6 +287,20 @@ export function traverse(
])
)
}
+ } else if (_node.type === 'SketchBlock') {
+ _node.arguments.forEach((arg, index) =>
+ _traverse(arg.arg, [
+ ...pathToNode,
+ ['arguments', 'SketchBlock'],
+ [index, ARG_INDEX_FIELD],
+ ['arg', LABELED_ARG_FIELD],
+ ])
+ )
+ _traverse(_node.body, [...pathToNode, ['body', 'SketchBlock']])
+ } else if (_node.type === 'Block') {
+ _node.items.forEach((item, index) =>
+ _traverse(item, [...pathToNode, ['items', 'Block'], [index, 'index']])
+ )
} else if (_node.type === 'BinaryExpression') {
_traverse(_node.left, [...pathToNode, ['left', 'BinaryExpression']])
_traverse(_node.right, [...pathToNode, ['right', 'BinaryExpression']])
diff --git a/src/lang/wasm.ts b/src/lang/wasm.ts
index 9011ea21216..1c31adea279 100644
--- a/src/lang/wasm.ts
+++ b/src/lang/wasm.ts
@@ -8,6 +8,7 @@ import type { ExecOutcome as RustExecOutcome } from '@rust/kcl-lib/bindings/Exec
import type { KclError as RustKclError } from '@rust/kcl-lib/bindings/KclError'
import type { KclErrorWithOutputs } from '@rust/kcl-lib/bindings/KclErrorWithOutputs'
import type { KclValueView } from '@rust/kcl-lib/bindings/KclValueView'
+import type { LegacyAngleRefactorMeta } from '@rust/kcl-lib/bindings/LegacyAngleRefactorMeta'
import type { MetaSettings } from '@rust/kcl-lib/bindings/MetaSettings'
import type { UnitLength } from '@rust/kcl-lib/bindings/ModelingCmd'
import type { ModulePath } from '@rust/kcl-lib/bindings/ModulePath'
@@ -18,6 +19,7 @@ import type { Operation } from '@rust/kcl-lib/bindings/Operation'
import type { OperationCallbackArgs } from '@rust/kcl-lib/bindings/OperationCallbackArgs'
import type { Program } from '@rust/kcl-lib/bindings/Program'
import type { ProjectConfiguration } from '@rust/kcl-lib/bindings/ProjectConfiguration'
+import type { RefactorMetadata } from '@rust/kcl-lib/bindings/RefactorMetadata'
import type { Sketch } from '@rust/kcl-lib/bindings/Sketch'
import type { SourceRange } from '@rust/kcl-lib/bindings/SourceRange'
@@ -271,6 +273,7 @@ export interface ExecState {
variables: { [key in string]?: KclValueView }
operations: OperationsByModule
artifactGraph: ArtifactGraph
+ legacyAngleRefactorMetadata: LegacyAngleRefactorMeta[]
issues: CompilationIssue[]
filenames: { [x: number]: ModulePath | undefined }
defaultPlanes: DefaultPlanes | null
@@ -310,6 +313,7 @@ export function emptyExecState(): ExecState {
variables: {},
operations: emptyOperationsByModule(),
artifactGraph: defaultArtifactGraph(),
+ legacyAngleRefactorMetadata: [],
issues: [],
filenames: [],
defaultPlanes: null,
@@ -376,11 +380,20 @@ export function countOperations(
export function execStateFromRust(execOutcome: RustExecOutcome): ExecState {
const artifactGraph = artifactGraphFromRust(execOutcome.artifactGraph)
+ const legacyAngleRefactorMetadata = execOutcome.refactorMetadata
+ .filter(
+ (
+ metadata
+ ): metadata is Extract =>
+ metadata.kind === 'legacyAngle'
+ )
+ .map((metadata) => metadata.data)
return {
variables: execOutcome.variables,
operations: execOutcome.operations,
artifactGraph,
+ legacyAngleRefactorMetadata,
issues: execOutcome.issues,
filenames: execOutcome.filenames,
defaultPlanes: execOutcome.defaultPlanes,
diff --git a/src/lib/rustContext.ts b/src/lib/rustContext.ts
index 0cd643e1246..29f086c72c3 100644
--- a/src/lib/rustContext.ts
+++ b/src/lib/rustContext.ts
@@ -701,6 +701,49 @@ export default class RustContext {
}
}
+ async editAngleConstraint(
+ version: ApiVersion,
+ sketch: ApiObjectId,
+ constraintId: ApiObjectId,
+ constraint: Extract,
+ settings: DeepPartial,
+ createCheckpoint = false,
+ commitSolverResults = true
+ ): Promise {
+ const instance =
+ (await this._checkContextInstance()) as ContextWithAngleConstraintEdit
+
+ try {
+ if (!commitSolverResults && createCheckpoint) {
+ return Promise.reject(
+ new Error('Preview angle edits cannot create sketch checkpoints')
+ )
+ }
+
+ const result = await instance.edit_angle_constraint(
+ JSON.stringify(version),
+ JSON.stringify(sketch),
+ JSON.stringify(constraintId),
+ JSON.stringify(constraint),
+ JSON.stringify(settings),
+ createCheckpoint,
+ commitSolverResults
+ )
+ const checkpointId = normalizeSketchCheckpointId(result.checkpointId)
+ if (checkpointId instanceof Error) {
+ return Promise.reject(checkpointId)
+ }
+ return {
+ kclSource: result.sourceDelta,
+ sceneGraphDelta: result.sceneGraphDelta,
+ checkpointId,
+ }
+ } catch (e: any) {
+ const err = errFromErrWithOutputs(e)
+ return Promise.reject(err)
+ }
+ }
+
async editDistanceConstraintLabelPosition(
version: ApiVersion,
sketch: ApiObjectId,
@@ -908,6 +951,22 @@ type SketchMutationResult = {
checkpointId?: number | null
}
+type ContextWithAngleConstraintEdit = Context & {
+ edit_angle_constraint(
+ versionJson: string,
+ sketchJson: string,
+ constraintIdJson: string,
+ constraintJson: string,
+ settings: string,
+ createCheckpoint: boolean,
+ commitSolverResults: boolean
+ ): Promise<{
+ sourceDelta: SourceDelta
+ sceneGraphDelta: SceneGraphDelta
+ checkpointId?: number | null
+ }>
+}
+
type SetProgramOutcome =
| (Omit<
Extract,
diff --git a/src/lib/toolbar.ts b/src/lib/toolbar.ts
index 67daa9e480e..55fa46840b0 100644
--- a/src/lib/toolbar.ts
+++ b/src/lib/toolbar.ts
@@ -2498,11 +2498,15 @@ export function buildToolbarConfig(
{
id: 'Dimension',
command: TOOLBAR_COMMAND_IDS.sketchSolve.dimension,
- onClick: ({ modelingSend, keepSelection }) =>
- modelingSend({
- type: 'Dimension',
- keepSelection,
- }),
+ onClick: ({ modelingSend, isActive, keepSelection }) =>
+ isActive
+ ? modelingSend({
+ type: 'unequip tool',
+ })
+ : modelingSend({
+ type: 'Dimension',
+ keepSelection,
+ }),
icon: 'dimension',
status: 'available',
title: 'Dimension',
@@ -2510,7 +2514,9 @@ export function buildToolbarConfig(
'Constrain distance between points, length of lines, or radius of arcs.',
extraInfo: constraintsExtraInfo,
links: [],
- isActive: (state) => false,
+ isActive: (state) =>
+ state.matches('sketchSolveMode') &&
+ state.context.sketchSolveToolName === 'dimensionTool',
},
{
id: 'HorizontalDistance',
diff --git a/src/lib/toolbarToast.ts b/src/lib/toolbarToast.ts
new file mode 100644
index 00000000000..653b97f5377
--- /dev/null
+++ b/src/lib/toolbarToast.ts
@@ -0,0 +1,72 @@
+import { signal } from '@preact/signals-core'
+
+export type ToolbarToast = {
+ id: string
+ message: string
+}
+
+type ToolbarToastOptions = {
+ id?: string
+ duration?: number
+}
+
+type ToastToolbar = {
+ (message: string, options?: ToolbarToastOptions): string
+ dismiss: (id?: string) => void
+}
+
+export const toolbarToastsSignal = signal([])
+
+const dismissTimers = new Map>()
+let nextToolbarToastId = 0
+
+export const toastToolbar: ToastToolbar = Object.assign(
+ (message: string, options: ToolbarToastOptions = {}) => {
+ const id = options.id ?? `toolbar-toast-${++nextToolbarToastId}`
+ const duration = options.duration ?? 4000
+
+ clearDismissTimer(id)
+
+ toolbarToastsSignal.value = [
+ { id, message },
+ ...toolbarToastsSignal.value.filter((toast) => toast.id !== id),
+ ]
+
+ if (Number.isFinite(duration)) {
+ dismissTimers.set(
+ id,
+ setTimeout(() => dismissToolbarToast(id), duration)
+ )
+ }
+
+ return id
+ },
+ {
+ dismiss: dismissToolbarToast,
+ }
+)
+
+function dismissToolbarToast(id?: string) {
+ if (id === undefined) {
+ for (const toastId of dismissTimers.keys()) {
+ clearDismissTimer(toastId)
+ }
+ toolbarToastsSignal.value = []
+ return
+ }
+
+ clearDismissTimer(id)
+ toolbarToastsSignal.value = toolbarToastsSignal.value.filter(
+ (toast) => toast.id !== id
+ )
+}
+
+function clearDismissTimer(id: string) {
+ const timer = dismissTimers.get(id)
+ if (!timer) {
+ return
+ }
+
+ clearTimeout(timer)
+ dismissTimers.delete(id)
+}
diff --git a/src/lib/utils.ts b/src/lib/utils.ts
index 5715408bfd8..6525afbd714 100644
--- a/src/lib/utils.ts
+++ b/src/lib/utils.ts
@@ -148,42 +148,6 @@ export function normaliseAngle(angle: number): number {
return result > 180 ? result - 360 : result
}
-/**
- * Computes the directed angular distance from startAngle to endAngle
- * in radians, going either CCW or CW.
- *
- * Notes:
- * - If startAngle === endAngle, the result is 0 for both directions (not 2Ï€).
- * - Inputs are typically within [-π, π], but any value work,
- *
- * @param startAngle - Start angle in radians.
- * @param endAngle - End angle in radians.
- * @param ccw - If true, measure the CCW distance from start to end. If false, measure CW.
- * @returns Angular distance in radians in the range [0, 2Ï€).
- *
- * @example
- * getAngleDiff(0, Math.PI / 2, true) => Math.PI / 2
- * getAngleDiff(0, Math.PI / 2, false) => 3 * Math.PI / 2
- * getAngleDiff(0.1, -0.1, true) => 2 * Math.PI - 0.2
- * getAngleDiff(0.1, -0.1, false) => 0.2
- * getAngleDiff(-0.1, 0.1, true) => 0.2
- */
-export function getAngleDiff(
- startAngle: number,
- endAngle: number,
- ccw: boolean
-) {
- const TWO_PI = Math.PI * 2
-
- let d = endAngle - startAngle
-
- // Wrap into [0, 2Ï€)
- d = ((d % TWO_PI) + TWO_PI) % TWO_PI
-
- // If going CW, take the other way around (but still wrap into [0, 2Ï€))
- return ccw ? d : (TWO_PI - d) % TWO_PI
-}
-
export function throttle(
func: (args: T) => any,
wait: number
diff --git a/src/lib/utils2d.ts b/src/lib/utils2d.ts
index 28c21405b09..7e97ea9f9cb 100644
--- a/src/lib/utils2d.ts
+++ b/src/lib/utils2d.ts
@@ -1,12 +1,44 @@
import type { Coords2d } from '@src/lang/util'
import { getAngle } from '@src/lib/utils'
+export type LineCoords = readonly [Coords2d, Coords2d]
+
export function deg2Rad(deg: number): number {
return (deg * Math.PI) / 180
}
export const TAU = Math.PI * 2
+// Normalize a radian angle into [0, 2PI).
+export function normalizeAngle(angle: number) {
+ return ((angle % TAU) + TAU) % TAU
+}
+
+// Returns the counter-clockwise angular distance from start to end in radians,
+// normalized into [0, 2PI).
+export function getCcwSweep(start: Coords2d, end: Coords2d) {
+ return normalizeAngle(
+ Math.atan2(end[1], end[0]) - Math.atan2(start[1], start[0])
+ )
+}
+
+/**
+ * Computes the directed angular distance from startAngle to endAngle
+ * in radians, going either CCW or CW.
+ *
+ * Notes:
+ * - If startAngle === endAngle, the result is 0 for both directions (not 2PI).
+ * - Inputs are typically within [-PI, PI], but any value works.
+ */
+export function getAngleDiff(
+ startAngle: number,
+ endAngle: number,
+ ccw: boolean
+) {
+ const diff = normalizeAngle(endAngle - startAngle)
+ return ccw ? diff : (TAU - diff) % TAU
+}
+
export function getTangentPointFromPreviousArc(
lastArcCenter: Coords2d,
lastArcCCW: boolean,
@@ -71,6 +103,13 @@ export function isParallel(
return Math.abs(cross2d(a, b) / denominator) < Math.sin(epsilonRadians)
}
+export function linesAreParallel(line1: LineCoords, line2: LineCoords) {
+ const line1Dir = subVec(line1[1], line1[0])
+ const line2Dir = subVec(line2[1], line2[0])
+
+ return isParallel(line1Dir, line2Dir)
+}
+
export function addVec(a: Coords2d, b: Coords2d): Coords2d {
return [a[0] + b[0], a[1] + b[1]]
}
@@ -101,6 +140,26 @@ export function cross2d(a: Coords2d, b: Coords2d): number {
return a[0] * b[1] - a[1] * b[0]
}
+// Returns the intersection of two infinite lines defined by two points each.
+// Returns null when the lines are parallel or nearly parallel.
+export function getLineIntersection(
+ line0: readonly [Coords2d, Coords2d],
+ line1: readonly [Coords2d, Coords2d]
+): Coords2d | null {
+ const p = line0[0]
+ const q = line1[0]
+ const r = subVec(line0[1], line0[0])
+ const s = subVec(line1[1], line1[0])
+ const denominator = cross2d(r, s)
+ if (Math.abs(denominator) <= 1e-9) {
+ return null
+ }
+
+ const qp = subVec(q, p)
+ const t = cross2d(qp, s) / denominator
+ return [p[0] + r[0] * t, p[1] + r[1] * t]
+}
+
// Takes a vector given by 2 coords and rotates it 90deg CCW.
export function perpendicular(v: Coords2d): Coords2d {
return [-v[1], v[0]]
diff --git a/src/machines/modelingSharedContext.ts b/src/machines/modelingSharedContext.ts
index d3bcf177e58..3da6b2d4760 100644
--- a/src/machines/modelingSharedContext.ts
+++ b/src/machines/modelingSharedContext.ts
@@ -33,6 +33,7 @@ export const dummyInitSketchGraphDelta: SceneGraphDelta = Object.freeze({
exec_outcome: {
issues: [],
variables: {},
+ refactorMetadata: [],
operations: emptyOperationsByModule(),
artifactGraph: { map: {}, itemCount: 0 },
filenames: {},
diff --git a/src/machines/sketchSolve/constraints/AngleConstraintBuilder.test.ts b/src/machines/sketchSolve/constraints/AngleConstraintBuilder.test.ts
new file mode 100644
index 00000000000..56c7ccf34b8
--- /dev/null
+++ b/src/machines/sketchSolve/constraints/AngleConstraintBuilder.test.ts
@@ -0,0 +1,249 @@
+import type { ApiObject } from '@rust/kcl-lib/bindings/FrontendApi'
+import { calculateArcRenderInput as calculateArcInput } from '@src/machines/sketchSolve/constraints/AngleConstraintBuilder'
+import {
+ createLineApiObject,
+ createPointApiObject,
+} from '@src/machines/sketchSolve/tools/sketchToolTestUtils'
+import { describe, expect, it } from 'vitest'
+
+function createObjectsArray(objects: ApiObject[]) {
+ const array: ApiObject[] = []
+ for (const object of objects) {
+ array[object.id] = object
+ }
+ return array
+}
+
+function createAngleConstraintApiObject({
+ id,
+ lines,
+ angle,
+ sector,
+ inverse,
+ labelPosition,
+}: {
+ id: number
+ lines: [number, number]
+ angle: number
+ sector?: 1 | 2 | 3 | 4
+ inverse?: boolean
+ labelPosition?: [number, number]
+}): ApiObject {
+ return {
+ id,
+ kind: {
+ type: 'Constraint',
+ constraint: {
+ type: 'Angle',
+ lines,
+ angle: { value: angle, units: 'Deg' },
+ sector,
+ inverse,
+ labelPosition: labelPosition
+ ? {
+ x: { value: labelPosition[0], units: 'Mm' },
+ y: { value: labelPosition[1], units: 'Mm' },
+ }
+ : undefined,
+ source: { expr: `${angle}deg`, is_literal: true },
+ },
+ },
+ label: '',
+ comments: '',
+ artifact_id: '0',
+ source: { type: 'Simple', range: [0, 0, 0], node_path: null },
+ }
+}
+
+function createSectorTestObjects(
+ sector: 1 | 2 | 3 | 4,
+ angle = 60,
+ inverse = false
+) {
+ const origin = createPointApiObject({ id: 1, x: 0, y: 0 })
+ const east = createPointApiObject({ id: 2, x: 10, y: 0 })
+ const sixtyDegrees = createPointApiObject({
+ id: 3,
+ x: 5,
+ y: 5 * Math.sqrt(3),
+ })
+ const horizontalLine = createLineApiObject({ id: 10, start: 1, end: 2 })
+ const angledLine = createLineApiObject({ id: 11, start: 1, end: 3 })
+ const angleConstraint = createAngleConstraintApiObject({
+ id: 20,
+ lines: [10, 11],
+ angle,
+ sector,
+ inverse,
+ })
+
+ return {
+ angleConstraint,
+ objects: createObjectsArray([
+ origin,
+ east,
+ sixtyDegrees,
+ horizontalLine,
+ angledLine,
+ angleConstraint,
+ ]),
+ }
+}
+
+describe('calculateArcRenderInput', () => {
+ it('renders sector 1 from line0 forward to line1 forward', () => {
+ const { angleConstraint, objects } = createSectorTestObjects(1)
+
+ const arcInput = calculateArcInput(angleConstraint, objects, 1)
+
+ expect(arcInput?.line1).toEqual([
+ [0, 0],
+ [10, 0],
+ ])
+ expect(arcInput?.line2).toEqual([
+ [0, 0],
+ [5, 5 * Math.sqrt(3)],
+ ])
+ expect(arcInput?.startAngle).toBeCloseTo(0)
+ expect(arcInput?.sweepAngle).toBeCloseTo(Math.PI / 3)
+ })
+
+ it('renders the inverse sweep of the selected sector', () => {
+ const { angleConstraint, objects } = createSectorTestObjects(1, 300, true)
+
+ const arcInput = calculateArcInput(angleConstraint, objects, 1)
+
+ expect(arcInput?.line1).toEqual([
+ [0, 0],
+ [5, 5 * Math.sqrt(3)],
+ ])
+ expect(arcInput?.line2).toEqual([
+ [0, 0],
+ [10, 0],
+ ])
+ expect(arcInput?.startAngle).toBeCloseTo(Math.PI / 3)
+ expect(arcInput?.sweepAngle).toBeCloseTo((5 * Math.PI) / 3)
+ })
+
+ it('uses explicit label position for sector angle rendering', () => {
+ const { objects } = createSectorTestObjects(1)
+ const angleConstraint = createAngleConstraintApiObject({
+ id: 20,
+ lines: [10, 11],
+ angle: 60,
+ sector: 1,
+ labelPosition: [20, 30],
+ })
+ objects[20] = angleConstraint
+
+ const arcInput = calculateArcInput(angleConstraint, objects, 1)
+
+ expect(arcInput?.labelPosition).toEqual([20, 30])
+ expect(arcInput?.labelAngle).toBeCloseTo(Math.atan2(30, 20))
+ expect(arcInput?.radius).toBeCloseTo(Math.hypot(20, 30))
+ expect(arcInput?.startAngle).toBeCloseTo(0)
+ expect(arcInput?.sweepAngle).toBeCloseTo(Math.PI / 3)
+ })
+
+ it('does not infer inverse sweep from a major angle value', () => {
+ const { angleConstraint, objects } = createSectorTestObjects(1, 300)
+
+ const arcInput = calculateArcInput(angleConstraint, objects, 1)
+
+ expect(arcInput?.line1).toEqual([
+ [0, 0],
+ [10, 0],
+ ])
+ expect(arcInput?.line2).toEqual([
+ [0, 0],
+ [5, 5 * Math.sqrt(3)],
+ ])
+ expect(arcInput?.startAngle).toBeCloseTo(0)
+ expect(arcInput?.sweepAngle).toBeCloseTo(Math.PI / 3)
+ })
+
+ it('renders sector 2 from line1 forward to line0 reverse', () => {
+ const { angleConstraint, objects } = createSectorTestObjects(2, 120)
+
+ const arcInput = calculateArcInput(angleConstraint, objects, 1)
+
+ expect(arcInput?.line1).toEqual([
+ [0, 0],
+ [5, 5 * Math.sqrt(3)],
+ ])
+ expect(arcInput?.line2).toEqual([
+ [0, 0],
+ [10, 0],
+ ])
+ expect(arcInput?.startAngle).toBeCloseTo(Math.PI / 3)
+ expect(arcInput?.sweepAngle).toBeCloseTo((2 * Math.PI) / 3)
+ })
+
+ it('renders sector 3 from line0 reverse to line1 reverse', () => {
+ const { angleConstraint, objects } = createSectorTestObjects(3)
+
+ const arcInput = calculateArcInput(angleConstraint, objects, 1)
+
+ expect(arcInput?.line1).toEqual([
+ [0, 0],
+ [10, 0],
+ ])
+ expect(arcInput?.line2).toEqual([
+ [0, 0],
+ [5, 5 * Math.sqrt(3)],
+ ])
+ expect(arcInput?.startAngle).toBeCloseTo(-Math.PI)
+ expect(arcInput?.sweepAngle).toBeCloseTo(Math.PI / 3)
+ })
+
+ it('renders sector 4 from line1 reverse to line0 forward', () => {
+ const { angleConstraint, objects } = createSectorTestObjects(4, 120)
+
+ const arcInput = calculateArcInput(angleConstraint, objects, 1)
+
+ expect(arcInput?.line1).toEqual([
+ [0, 0],
+ [5, 5 * Math.sqrt(3)],
+ ])
+ expect(arcInput?.line2).toEqual([
+ [0, 0],
+ [10, 0],
+ ])
+ expect(arcInput?.startAngle).toBeCloseTo((-2 * Math.PI) / 3)
+ expect(arcInput?.sweepAngle).toBeCloseTo((2 * Math.PI) / 3)
+ })
+
+ it('uses the legacy heuristic when sector metadata is absent', () => {
+ const { angleConstraint, objects } = createSectorTestObjects(1)
+ if (
+ angleConstraint.kind.type === 'Constraint' &&
+ angleConstraint.kind.constraint.type === 'Angle'
+ ) {
+ delete angleConstraint.kind.constraint.sector
+ }
+
+ const arcInput = calculateArcInput(angleConstraint, objects, 1)
+
+ expect(arcInput?.startAngle).toBeCloseTo(0)
+ expect(arcInput?.sweepAngle).toBeCloseTo(Math.PI / 3)
+ })
+
+ it('uses explicit label position for legacy angle rendering', () => {
+ const { objects } = createSectorTestObjects(1)
+ const angleConstraint = createAngleConstraintApiObject({
+ id: 20,
+ lines: [10, 11],
+ angle: 60,
+ labelPosition: [21, 31],
+ })
+ objects[20] = angleConstraint
+
+ const arcInput = calculateArcInput(angleConstraint, objects, 1)
+
+ expect(arcInput?.labelPosition).toEqual([21, 31])
+ expect(arcInput?.labelAngle).toBeCloseTo(Math.atan2(31, 21))
+ expect(arcInput?.radius).toBeCloseTo(Math.hypot(21, 31))
+ expect(arcInput?.startAngle).toBeCloseTo(0)
+ expect(arcInput?.sweepAngle).toBeCloseTo(Math.PI / 3)
+ })
+})
diff --git a/src/machines/sketchSolve/constraints/AngleConstraintBuilder.ts b/src/machines/sketchSolve/constraints/AngleConstraintBuilder.ts
index bb430becbd1..9f29d6e40ec 100644
--- a/src/machines/sketchSolve/constraints/AngleConstraintBuilder.ts
+++ b/src/machines/sketchSolve/constraints/AngleConstraintBuilder.ts
@@ -6,11 +6,15 @@ import { DISTANCE_CONSTRAINT_BODY } from '@src/clientSideScene/sceneConstants'
import type { SceneInfra } from '@src/clientSideScene/sceneInfra'
import type { Coords2d } from '@src/lang/util'
import {
- TAU,
addVec,
+ distance2d,
dot2d,
+ getCcwSweep,
+ getLineIntersection,
+ getPolarAngle2d,
intersectRanges,
lerp,
+ normalizeAngle,
normalizeVec,
rotateVec2d,
scaleVec,
@@ -124,6 +128,19 @@ export function calculateArcRenderInput(
const line1Dir = normalizeVec(subVec(line1[1], line1[0]))
const line2Dir = normalizeVec(subVec(line2[1], line2[0]))
+ const explicitRenderInput = calculateExplicitArcRenderInput(
+ obj,
+ line1,
+ line2,
+ line1Dir,
+ line2Dir,
+ center,
+ scale
+ )
+ if (explicitRenderInput) {
+ return explicitRenderInput
+ }
+
// The distances of the line segment end points from the intersection (center)
const line1SignedDistances = [
dot2d(subVec(line1[0], center), line1Dir),
@@ -155,22 +172,26 @@ export function calculateArcRenderInput(
)
: radiusSigned
- //const signedAngle = getSignedAngleBetweenVec(line1Dir, line2Dir)
const signedAngle = normalizeAngleRad(obj.kind.constraint.angle)
+ const explicitLabelPosition = getAngleLabelPosition(obj)
+ const defaultRadius = Math.abs(adjustedRadiusSigned)
+ const radius = getAngleArcRadius(explicitLabelPosition, center, defaultRadius)
const startVector = scaleVec(normalizeVec(line1Dir), adjustedRadiusSigned)
const startAngle = Math.atan2(startVector[1], startVector[0])
- const labelPosition = addVec(
+ const defaultLabelPosition = addVec(
center,
rotateVec2d(startVector, signedAngle / 2)
)
+ const labelPosition = explicitLabelPosition ?? defaultLabelPosition
return {
line1,
line2,
labelPosition,
+ labelAngle: getAngleLabelAngle(explicitLabelPosition, center),
center,
- radius: Math.abs(adjustedRadiusSigned),
+ radius,
startAngle,
sweepAngle: signedAngle,
}
@@ -179,8 +200,7 @@ export function calculateArcRenderInput(
export function normalizeAngleRad(angle: ApiNumber) {
const angleRadians =
angle.units === 'Rad' ? angle.value : (angle.value * Math.PI) / 180
- const normalized = ((angleRadians % TAU) + TAU) % TAU
- return normalized
+ return normalizeAngle(angleRadians)
}
// Major angles are ones > 180deg
@@ -188,6 +208,170 @@ export function isMajorConstraintAngle(angle: ApiNumber) {
return normalizeAngleRad(angle) > Math.PI
}
+function calculateExplicitArcRenderInput(
+ obj: AngleConstraint,
+ line1: LineSegment,
+ line2: LineSegment,
+ line1Dir: Coords2d,
+ line2Dir: Coords2d,
+ center: Coords2d,
+ scale: number
+): ArcLineInfo | null {
+ const sector = explicitAngleSector(obj.kind.constraint.sector)
+ if (!sector) {
+ return null
+ }
+
+ const sectorBoundaries = angleSectorBoundaries(
+ sector,
+ line1,
+ line2,
+ line1Dir,
+ line2Dir
+ )
+ const [start, end] =
+ obj.kind.constraint.inverse === true
+ ? [sectorBoundaries[1], sectorBoundaries[0]]
+ : sectorBoundaries
+ const explicitLabelPosition = getAngleLabelPosition(obj)
+ const defaultRadius = calculateExplicitArcRadius(
+ start.line,
+ end.line,
+ start.dir,
+ end.dir,
+ center,
+ scale
+ )
+ const radius = getAngleArcRadius(explicitLabelPosition, center, defaultRadius)
+ const startVector = scaleVec(start.dir, radius)
+ const startAngle = Math.atan2(startVector[1], startVector[0])
+ const sweepAngle = getCcwSweep(start.dir, end.dir)
+ const defaultLabelPosition = addVec(
+ center,
+ rotateVec2d(startVector, sweepAngle / 2)
+ )
+ const labelPosition = explicitLabelPosition ?? defaultLabelPosition
+
+ return {
+ line1: start.line,
+ line2: end.line,
+ labelPosition,
+ labelAngle: getAngleLabelAngle(explicitLabelPosition, center),
+ center,
+ radius,
+ startAngle,
+ sweepAngle,
+ }
+}
+
+function getAngleLabelPosition(obj: AngleConstraint): Coords2d | null {
+ const labelPosition = obj.kind.constraint.labelPosition
+ return labelPosition ? [labelPosition.x.value, labelPosition.y.value] : null
+}
+
+function getAngleArcRadius(
+ labelPosition: Coords2d | null,
+ center: Coords2d,
+ fallbackRadius: number
+) {
+ if (!labelPosition) {
+ return fallbackRadius
+ }
+
+ const labelRadius = distance2d(labelPosition, center)
+ return labelRadius > OVERLAP_EPSILON ? labelRadius : fallbackRadius
+}
+
+function getAngleLabelAngle(labelPosition: Coords2d | null, center: Coords2d) {
+ return labelPosition ? getPolarAngle2d(center, labelPosition) : undefined
+}
+
+function explicitAngleSector(sector: unknown) {
+ return sector === 1 || sector === 2 || sector === 3 || sector === 4
+ ? sector
+ : null
+}
+
+function angleSectorBoundaries(
+ sector: 1 | 2 | 3 | 4,
+ line1: LineSegment,
+ line2: LineSegment,
+ line1Dir: Coords2d,
+ line2Dir: Coords2d
+) {
+ switch (sector) {
+ case 1:
+ return [
+ { line: line1, dir: line1Dir },
+ { line: line2, dir: line2Dir },
+ ] as const
+ case 2:
+ return [
+ { line: line2, dir: line2Dir },
+ { line: line1, dir: scaleVec(line1Dir, -1) },
+ ] as const
+ case 3:
+ return [
+ { line: line1, dir: scaleVec(line1Dir, -1) },
+ { line: line2, dir: scaleVec(line2Dir, -1) },
+ ] as const
+ case 4:
+ return [
+ { line: line2, dir: scaleVec(line2Dir, -1) },
+ { line: line1, dir: line1Dir },
+ ] as const
+ }
+}
+
+function calculateExplicitArcRadius(
+ line1: LineSegment,
+ line2: LineSegment,
+ line1Dir: Coords2d,
+ line2Dir: Coords2d,
+ center: Coords2d,
+ scale: number
+) {
+ const line1Range = projectionRange(line1, center, line1Dir)
+ const line2Range = projectionRange(line2, center, line2Dir)
+ const commonLineRange = intersectRanges(line1Range, line2Range)
+ const commonRayRange = commonLineRange
+ ? intersectRanges(commonLineRange, [0, Number.POSITIVE_INFINITY])
+ : null
+ const radius = commonRayRange
+ ? findShortestRadiusFromRange(commonRayRange)
+ : findFallbackRayRadius([line1Range, line2Range])
+ const shouldApplyNonOverlapFallback =
+ !commonRayRange ||
+ Math.abs(commonRayRange[1] - commonRayRange[0]) < OVERLAP_EPSILON
+ return shouldApplyNonOverlapFallback
+ ? withMinimumMagnitude(
+ radius,
+ MIN_NON_OVERLAP_ANGLE_CONSTRAINT_RADIUS_PX * scale
+ )
+ : radius
+}
+
+function projectionRange(
+ line: LineSegment,
+ center: Coords2d,
+ direction: Coords2d
+): [number, number] {
+ const distances = [
+ dot2d(subVec(line[0], center), direction),
+ dot2d(subVec(line[1], center), direction),
+ ]
+ return [Math.min(...distances), Math.max(...distances)]
+}
+
+function findFallbackRayRadius(ranges: [number, number][]) {
+ return (
+ ranges
+ .flat()
+ .filter((distance) => distance > OVERLAP_EPSILON)
+ .sort((a, b) => a - b)[1] ?? 0
+ )
+}
+
// finds the shortest radius on the range of projected distances of the 2 lines.
function findShortestRadiusFromRange(range: [number, number]) {
// Try the point at 15% and 85% of the interval, see which one is closer to center.
@@ -203,24 +387,3 @@ function withMinimumMagnitude(value: number, minMagnitude: number) {
}
return Math.sign(value) * Math.max(Math.abs(value), minMagnitude)
}
-
-// Returns the intersection of 2 infinite lines that lie on the given line segments.
-// Returns a valid point even if the line segments themselves don't intersect.
-// Returns null if the lines are parallel,
-export function getLineIntersection(
- line1: LineSegment,
- line2: LineSegment
-): Coords2d | null {
- const p = line1[0]
- const q = line2[0]
- const r = subVec(line1[1], line1[0])
- const s = subVec(line2[1], line2[0])
- const denominator = r[0] * s[1] - r[1] * s[0]
- if (Math.abs(denominator) < 1e-8) {
- return null
- }
-
- const qp = subVec(q, p)
- const t = (qp[0] * s[1] - qp[1] * s[0]) / denominator
- return [p[0] + r[0] * t, p[1] + r[1] * t]
-}
diff --git a/src/machines/sketchSolve/constraints/ArcDimensionLine.test.ts b/src/machines/sketchSolve/constraints/ArcDimensionLine.test.ts
new file mode 100644
index 00000000000..60a5bda4ca5
--- /dev/null
+++ b/src/machines/sketchSolve/constraints/ArcDimensionLine.test.ts
@@ -0,0 +1,48 @@
+import {
+ getArcBodySections,
+ getArcLabelOffset,
+} from '@src/machines/sketchSolve/constraints/ArcDimensionLine'
+import { describe, expect, it } from 'vitest'
+
+describe('angle arc body sections', () => {
+ it('splits the measured arc around an in-angle label', () => {
+ const sweep = Math.PI / 3
+ const halfGap = 0.1
+ const labelOffset = getArcLabelOffset(0, sweep, Math.PI / 6)
+
+ const sections = getArcBodySections(0, sweep, labelOffset, halfGap)
+
+ expect(labelOffset).toBeCloseTo(Math.PI / 6)
+ expect(sections).toHaveLength(2)
+ expect(sections[0][0]).toBeCloseTo(0)
+ expect(sections[0][1]).toBeCloseTo(Math.PI / 6 - halfGap)
+ expect(sections[1][0]).toBeCloseTo(Math.PI / 6 + halfGap)
+ expect(sections[1][1]).toBeCloseTo(sweep)
+ })
+
+ it('overdraws before the measured start when the label is before the arc', () => {
+ const sweep = Math.PI / 3
+ const halfGap = 0.1
+ const labelOffset = getArcLabelOffset(0, sweep, -Math.PI / 6)
+
+ const sections = getArcBodySections(0, sweep, labelOffset, halfGap)
+
+ expect(labelOffset).toBeCloseTo(-Math.PI / 6)
+ expect(sections).toHaveLength(1)
+ expect(sections[0][0]).toBeCloseTo(-Math.PI / 6 + halfGap)
+ expect(sections[0][1]).toBeCloseTo(sweep)
+ })
+
+ it('overdraws after the measured end when the label is after the arc', () => {
+ const sweep = Math.PI / 3
+ const halfGap = 0.1
+ const labelOffset = getArcLabelOffset(0, sweep, Math.PI / 2)
+
+ const sections = getArcBodySections(0, sweep, labelOffset, halfGap)
+
+ expect(labelOffset).toBeCloseTo(Math.PI / 2)
+ expect(sections).toHaveLength(1)
+ expect(sections[0][0]).toBeCloseTo(0)
+ expect(sections[0][1]).toBeCloseTo(Math.PI / 2 - halfGap)
+ })
+})
diff --git a/src/machines/sketchSolve/constraints/ArcDimensionLine.ts b/src/machines/sketchSolve/constraints/ArcDimensionLine.ts
index 745cb6d61af..797daa6964e 100644
--- a/src/machines/sketchSolve/constraints/ArcDimensionLine.ts
+++ b/src/machines/sketchSolve/constraints/ArcDimensionLine.ts
@@ -10,7 +10,7 @@ import {
import type { SceneInfra } from '@src/clientSideScene/sceneInfra'
import type { Coords2d } from '@src/lang/util'
import { getResolvedTheme } from '@src/lib/theme'
-import { dot2d, polar2d, subVec } from '@src/lib/utils2d'
+import { TAU, dot2d, normalizeAngle, polar2d, subVec } from '@src/lib/utils2d'
import { createArcPositions } from '@src/machines/sketchSolve/arcPositions'
import {
CONSTRAINT_COLOR,
@@ -24,6 +24,7 @@ import type { Line2 } from 'three/examples/jsm/lines/Line2'
export const ANGLE_CONSTRAINT_ARC_BODY_ROLE = 'angle-constraint-arc-body'
export const ANGLE_CONSTRAINT_GUIDE_BODY_ROLE = 'angle-constraint-guide-body'
const ARROW_LENGTH_PX = 10
+const ANGLE_EPSILON = 1e-8
export type LineSegment = readonly [Coords2d, Coords2d]
@@ -31,6 +32,7 @@ export type ArcLineInfo = {
line1: LineSegment
line2: LineSegment
labelPosition: Coords2d
+ labelAngle?: number
center: Coords2d
radius: number
startAngle: number
@@ -46,8 +48,6 @@ export function updateArcDimensionLine(
angleValue: ApiNumber
) {
const { center, radius, startAngle, sweepAngle: sweep } = renderInput
- const arcLengthPx = (radius * sweep) / scale
-
const label = group.children.find(isSpriteLabel)
if (!label) {
return
@@ -69,8 +69,17 @@ export function updateArcDimensionLine(
)
const arrowSpanPx = ARROW_LENGTH_PX * 2
const gapWidthPx = labelTextWidthPx
+ const labelOffset = getArcLabelOffset(
+ startAngle,
+ sweep,
+ renderInput.labelAngle
+ )
+ const renderedSweep = Math.max(sweep, labelOffset) - Math.min(0, labelOffset)
+ const arcLengthPx = (radius * renderedSweep) / scale
+ const hasExplicitLabelPosition = renderInput.labelAngle !== undefined
const showArrows = arcLengthPx >= arrowSpanPx
- const showGap = arcLengthPx >= gapWidthPx + arrowSpanPx
+ const showGap =
+ hasExplicitLabelPosition || arcLengthPx >= gapWidthPx + arrowSpanPx
// Set visibility
for (const child of group.children) {
@@ -84,10 +93,6 @@ export function updateArcDimensionLine(
}
const halfGapAngle = showGap ? (gapWidthPx * 0.5 * scale) / radius : 0
-
- const labelOffset = sweep * 0.5
- const section1EndAngle = startAngle + labelOffset - halfGapAngle
- const section2StartAngle = startAngle + labelOffset + halfGapAngle
const endAngle = startAngle + sweep
const arcLines = group.children.filter(
@@ -95,8 +100,22 @@ export function updateArcDimensionLine(
child.userData.type === DISTANCE_CONSTRAINT_BODY &&
child.userData.role === ANGLE_CONSTRAINT_ARC_BODY_ROLE
) as Line2[]
- updateArc(arcLines[0], center, radius, startAngle, section1EndAngle)
- updateArc(arcLines[1], center, radius, section2StartAngle, endAngle)
+ const bodySections = getArcBodySections(
+ startAngle,
+ sweep,
+ labelOffset,
+ halfGapAngle
+ )
+ for (let i = 0; i < arcLines.length; i++) {
+ const section = bodySections[i]
+ if (!section) {
+ arcLines[i].visible = false
+ continue
+ }
+
+ arcLines[i].visible = true
+ updateArc(arcLines[i], center, radius, section[0], section[1])
+ }
const startPoint = polar2d(center, radius, startAngle)
const endPoint = polar2d(center, radius, endAngle)
@@ -137,6 +156,53 @@ export function updateArcDimensionLine(
updateGuideLine(guideLines[1], renderInput.line2, endPoint)
}
+export function getArcLabelOffset(
+ startAngle: number,
+ sweep: number,
+ labelAngle?: number
+) {
+ if (labelAngle === undefined) {
+ return sweep * 0.5
+ }
+
+ const offset = normalizeAngle(labelAngle - startAngle)
+ if (offset <= sweep + ANGLE_EPSILON) {
+ return Math.min(offset, sweep)
+ }
+
+ const overdrawAfterEnd = offset - sweep
+ const overdrawBeforeStart = TAU - offset
+ return overdrawBeforeStart < overdrawAfterEnd ? offset - TAU : offset
+}
+
+export function getArcBodySections(
+ startAngle: number,
+ sweep: number,
+ labelOffset: number,
+ halfGapAngle: number
+): [number, number][] {
+ const endAngle = startAngle + sweep
+ const gapStartAngle = startAngle + labelOffset - halfGapAngle
+ const gapEndAngle = startAngle + labelOffset + halfGapAngle
+
+ if (labelOffset < 0) {
+ return compactArcSections([[gapEndAngle, endAngle]])
+ }
+
+ if (labelOffset > sweep) {
+ return compactArcSections([[startAngle, gapStartAngle]])
+ }
+
+ return compactArcSections([
+ [startAngle, Math.max(startAngle, gapStartAngle)],
+ [Math.min(endAngle, gapEndAngle), endAngle],
+ ])
+}
+
+function compactArcSections(sections: [number, number][]) {
+ return sections.filter(([start, end]) => end - start > ANGLE_EPSILON)
+}
+
function updateArc(
arc: Line2,
center: Coords2d,
diff --git a/src/machines/sketchSolve/constraints/RadiusConstraintBuilder.ts b/src/machines/sketchSolve/constraints/RadiusConstraintBuilder.ts
index 1f619e13b84..0adc3ac848d 100644
--- a/src/machines/sketchSolve/constraints/RadiusConstraintBuilder.ts
+++ b/src/machines/sketchSolve/constraints/RadiusConstraintBuilder.ts
@@ -1,8 +1,7 @@
import type { ApiObject } from '@rust/kcl-lib/bindings/FrontendApi'
import { DISTANCE_CONSTRAINT_BODY } from '@src/clientSideScene/sceneConstants'
import type { SceneInfra } from '@src/clientSideScene/sceneInfra'
-import { getAngleDiff } from '@src/lib/utils'
-import { getPolarAngle2d } from '@src/lib/utils2d'
+import { getAngleDiff, getPolarAngle2d } from '@src/lib/utils2d'
import { createArcPositions } from '@src/machines/sketchSolve/arcPositions'
import type { ConstraintResources } from '@src/machines/sketchSolve/constraints/ConstraintResources'
import {
diff --git a/src/machines/sketchSolve/constraints/invisibleConstraintSpriteUtils.ts b/src/machines/sketchSolve/constraints/invisibleConstraintSpriteUtils.ts
index 8b6111ad26e..73ae37261fc 100644
--- a/src/machines/sketchSolve/constraints/invisibleConstraintSpriteUtils.ts
+++ b/src/machines/sketchSolve/constraints/invisibleConstraintSpriteUtils.ts
@@ -3,8 +3,7 @@ import type {
ApiObject,
} from '@rust/kcl-lib/bindings/FrontendApi'
import type { Coords2d } from '@src/lang/util'
-import { getAngleDiff } from '@src/lib/utils'
-import { lerp2d } from '@src/lib/utils2d'
+import { getAngleDiff, lerp2d } from '@src/lib/utils2d'
import { Vector3 } from 'three'
import { SKETCH_HIGHLIGHT_SECONDARY_COLOR } from '@src/lib/constants'
diff --git a/src/machines/sketchSolve/interaction/interactionHelpers.ts b/src/machines/sketchSolve/interaction/interactionHelpers.ts
index a3bfd0e4dfb..530c3922bc9 100644
--- a/src/machines/sketchSolve/interaction/interactionHelpers.ts
+++ b/src/machines/sketchSolve/interaction/interactionHelpers.ts
@@ -3,8 +3,14 @@ import { DISTANCE_CONSTRAINT_LABEL } from '@src/clientSideScene/sceneConstants'
import type { SceneInfra } from '@src/clientSideScene/sceneInfra'
import { SKETCH_SOLVE_GROUP } from '@src/clientSideScene/sceneUtils'
import type { Coords2d } from '@src/lang/util'
-import { getAngleDiff, isArray } from '@src/lib/utils'
-import { distance2d, dot2d, polar2d, subVec } from '@src/lib/utils2d'
+import { isArray } from '@src/lib/utils'
+import {
+ distance2d,
+ dot2d,
+ getAngleDiff,
+ polar2d,
+ subVec,
+} from '@src/lib/utils2d'
import {
getArcPoints,
getControlPointSplinePoints,
diff --git a/src/machines/sketchSolve/sketchSolveDiagram.ts b/src/machines/sketchSolve/sketchSolveDiagram.ts
index d1513cb2a69..844c4249fd2 100644
--- a/src/machines/sketchSolve/sketchSolveDiagram.ts
+++ b/src/machines/sketchSolve/sketchSolveDiagram.ts
@@ -5,6 +5,7 @@ import type {
} from '@rust/kcl-lib/bindings/FrontendApi'
import { toggleSketchExtension } from '@src/editor/plugins/sketch'
import type { KclManager } from '@src/lang/KclManager'
+import type { Coords2d } from '@src/lang/util'
import {
baseUnitToNumericSuffix,
distanceBetweenPoint2DExpr,
@@ -12,20 +13,22 @@ import {
import { SKETCH_FILE_VERSION } from '@src/lib/constants'
import { jsAppSettings } from '@src/lib/settings/settingsUtils'
import { roundOff } from '@src/lib/utils'
+import { type LineCoords, distance2d, linesAreParallel } from '@src/lib/utils2d'
import type {
DefaultPlane,
ExtrudeFacePlane,
OffsetPlane,
} from '@src/machines/modelingSharedTypes'
import {
- buildAngleConstraintInput,
buildCircularSizeDimensionConstraintInput,
+ getLinePoints,
isArcSegment,
isCircleSegment,
isControlPointSplineSegment,
isLineSegment,
isOwnedLineSegment,
isPointSegment,
+ pointToCoords2d,
} from '@src/machines/sketchSolve/constraints/constraintUtils'
import { toastSketchSolveError } from '@src/machines/sketchSolve/sketchSolveErrors'
import {
@@ -116,19 +119,14 @@ async function runSketchSolveToolbarAction(
function getSelectionPointCoords(
selection: ApiObject | typeof ORIGIN_TARGET | undefined
-) {
+): Coords2d | null {
if (selection === ORIGIN_TARGET) {
- return { x: 0, y: 0 }
+ return [0, 0]
}
-
if (!isPointSegment(selection)) {
return null
}
-
- return {
- x: selection.kind.segment.position.x.value,
- y: selection.kind.segment.position.y.value,
- }
+ return pointToCoords2d(selection)
}
function getPointCoordsById(objects: ApiObject[], pointId: number) {
@@ -136,24 +134,10 @@ function getPointCoordsById(objects: ApiObject[], pointId: number) {
return getSelectionPointCoords(point)
}
-function getLineCoords(objects: ApiObject[], line: ApiObject | undefined) {
- if (!isLineSegment(line)) {
- return null
- }
-
- const start = getPointCoordsById(objects, line.kind.segment.start)
- const end = getPointCoordsById(objects, line.kind.segment.end)
- if (!start || !end) {
- return null
- }
-
- return { start, end }
-}
-
function getCircularCoords(
objects: ApiObject[],
circular: ApiObject | undefined
-) {
+): { center: Coords2d; radius: number } | null {
if (!isArcSegment(circular) && !isCircleSegment(circular)) {
return null
}
@@ -166,47 +150,28 @@ function getCircularCoords(
return {
center,
- radius: Math.hypot(start.x - center.x, start.y - center.y),
+ radius: distance2d(start, center),
}
}
-function pointToLineDistance(
- point: { x: number; y: number },
- line: { start: { x: number; y: number }; end: { x: number; y: number } }
-) {
- const dx = line.end.x - line.start.x
- const dy = line.end.y - line.start.y
+function pointToLineDistance(point: Coords2d, line: LineCoords) {
+ const dx = line[1][0] - line[0][0]
+ const dy = line[1][1] - line[0][1]
const length = Math.hypot(dx, dy)
if (length === 0) {
return null
}
return Math.abs(
- ((point.x - line.start.x) * dy - (point.y - line.start.y) * dx) / length
+ ((point[0] - line[0][0]) * dy - (point[1] - line[0][1]) * dx) / length
)
}
function pointToCircularDistance(
- point: { x: number; y: number },
- circular: { center: { x: number; y: number }; radius: number }
-) {
- return Math.abs(
- Math.hypot(point.x - circular.center.x, point.y - circular.center.y) -
- circular.radius
- )
-}
-
-function linesAreParallel(
- line1: { start: { x: number; y: number }; end: { x: number; y: number } },
- line2: { start: { x: number; y: number }; end: { x: number; y: number } }
+ point: Coords2d,
+ circular: { center: Coords2d; radius: number }
) {
- const dx1 = line1.end.x - line1.start.x
- const dy1 = line1.end.y - line1.start.y
- const dx2 = line2.end.x - line2.start.x
- const dy2 = line2.end.y - line2.start.y
- const scale = Math.hypot(dx1, dy1) * Math.hypot(dx2, dy2)
-
- return scale !== 0 && Math.abs(dx1 * dy2 - dy1 * dx2) <= 1e-9 * scale
+ return Math.abs(distance2d(point, circular.center) - circular.radius)
}
function getCurrentDistanceBetweenSelections(
@@ -217,13 +182,13 @@ function getCurrentDistanceBetweenSelections(
const point1 = getSelectionPointCoords(first)
const point2 = getSelectionPointCoords(second)
if (point1 && point2) {
- return Math.hypot(point2.x - point1.x, point2.y - point1.y)
+ return distance2d(point2, point1)
}
const firstObject = first === ORIGIN_TARGET ? undefined : first
const secondObject = second === ORIGIN_TARGET ? undefined : second
- const firstLine = getLineCoords(objects, firstObject)
- const secondLine = getLineCoords(objects, secondObject)
+ const firstLine = getLinePoints(firstObject, objects)
+ const secondLine = getLinePoints(secondObject, objects)
const firstCircular = getCircularCoords(objects, firstObject)
const secondCircular = getCircularCoords(objects, secondObject)
@@ -259,17 +224,14 @@ function getCurrentDistanceBetweenSelections(
if (firstCircular && secondCircular) {
return Math.abs(
- Math.hypot(
- firstCircular.center.x - secondCircular.center.x,
- firstCircular.center.y - secondCircular.center.y
- ) -
+ distance2d(firstCircular.center, secondCircular.center) -
firstCircular.radius -
secondCircular.radius
)
}
if (firstLine && secondLine && linesAreParallel(firstLine, secondLine)) {
- return pointToLineDistance(firstLine.start, secondLine)
+ return pointToLineDistance(firstLine[0], secondLine)
}
return null
@@ -318,8 +280,8 @@ async function addAxisDistanceConstraint(
if (point1 && point2) {
const signedDistance =
axis === 'horizontal'
- ? roundOff(point2.x - point1.x)
- : roundOff(point2.y - point1.y)
+ ? roundOff(point2[0] - point1[0])
+ : roundOff(point2[1] - point1[1])
if (signedDistance < 0) {
segmentsToConstrain = [segmentsToConstrain[1], segmentsToConstrain[0]]
@@ -445,6 +407,7 @@ export const sketchSolveMachine = setup({
}),
'clear selection': assign({
selectedIds: [],
+ selectionCoordinates: {},
duringAreaSelectIds: [],
}),
'toggle non-visual constraints': assign(({ context }) => ({
@@ -495,6 +458,7 @@ export const sketchSolveMachine = setup({
return {
sketchSolveToolName: null,
selectedIds: [],
+ selectionCoordinates: {},
duringAreaSelectIds: [],
hoveredId: null,
constraintHoverPopups: [],
@@ -579,6 +543,15 @@ export const sketchSolveMachine = setup({
context.kclManager.fileSettings.defaultLengthUnit
)
+ if (currentSelections.length === 0) {
+ sendToActorIfActive(self, {
+ type: 'equip tool',
+ data: { tool: 'dimensionTool' },
+ keepSelection,
+ })
+ return
+ }
+
if (currentSelections.length === 2) {
const first = currentSelections[0]
const second = currentSelections[1]
@@ -595,22 +568,12 @@ export const sketchSolveMachine = setup({
isLineSegment(firstObject) &&
isLineSegment(secondObject)
) {
- const angleConstraint = buildAngleConstraintInput(
- firstObject,
- secondObject,
- objects
- )
- if (angleConstraint) {
- const result = await context.rustContext.addConstraint(
- 0,
- context.sketchId,
- angleConstraint,
- jsAppSettings(context.kclManager.systemDeps.settings),
- true
- )
- sendToolbarConstraintOutcome(self, result, keepSelection)
- return
- }
+ sendToActorIfActive(self, {
+ type: 'equip tool',
+ data: { tool: 'dimensionTool' },
+ keepSelection,
+ })
+ return
} else if (
isPointSegment(firstObject) &&
isPointSegment(secondObject)
@@ -637,9 +600,7 @@ export const sketchSolveMachine = setup({
const point1 = getSelectionPointCoords(first)
const point2 = getSelectionPointCoords(second)
if (point1 && point2) {
- distance = roundOff(
- Math.hypot(point2.x - point1.x, point2.y - point1.y)
- )
+ distance = roundOff(distance2d(point2, point1))
}
}
} else if (currentSelections.length === 1) {
@@ -1027,6 +988,14 @@ export const sketchSolveMachine = setup({
},
actions: 'apply current selection with equipped constraint tool',
},
+ {
+ guard: ({ event }) => {
+ assertEvent(event, 'equip tool')
+ return event.data.tool === 'dimensionTool'
+ },
+ target: 'using tool',
+ actions: 'store pending tool',
+ },
{
guard: ({ event }) => {
assertEvent(event, 'equip tool')
diff --git a/src/machines/sketchSolve/sketchSolveImpl.ts b/src/machines/sketchSolve/sketchSolveImpl.ts
index 5480742578c..7a631836036 100644
--- a/src/machines/sketchSolve/sketchSolveImpl.ts
+++ b/src/machines/sketchSolve/sketchSolveImpl.ts
@@ -24,6 +24,7 @@ import {
} from '@src/machines/sketchSolve/segments'
import {
ORIGIN_TARGET,
+ type SelectionCoordinates,
type SketchSolveSelectionId,
getObjectSelectionIds,
isObjectSelectionId,
@@ -91,6 +92,7 @@ export {
getObjectSelectionIds,
isObjectSelectionId,
ORIGIN_TARGET,
+ type SelectionCoordinates,
type SketchSolveSelectionId,
type SketchSpecialTarget,
} from '@src/machines/sketchSolve/sketchSolveSelection'
@@ -132,6 +134,7 @@ export type SketchSolveMachineEvent =
selectedIds?: Array
duringAreaSelectIds?: Array
replaceExistingSelection?: boolean
+ selectionCoordinates?: SelectionCoordinates
}
}
| {
@@ -227,6 +230,7 @@ export type SketchSolveContext = {
childTool?: ToolActorRef
pendingToolName?: EquipTool
selectedIds: Array
+ selectionCoordinates: SelectionCoordinates
duringAreaSelectIds: Array
hoveredId: SketchSolveSelectionId | null
constraintHoverPopups: ConstraintHoverPopup[]
@@ -776,6 +780,25 @@ export function cleanupSketchSolveGroup(sceneInfra: SceneInfra) {
disposeGroupChildren(sketchSegments)
}
+function getSelectionCoordinatesForIds(
+ selectedIds: readonly SketchSolveSelectionId[],
+ selectionCoordinates: SelectionCoordinates
+): SelectionCoordinates {
+ const nextSelectionCoordinates: SelectionCoordinates = {}
+ for (const selectedId of selectedIds) {
+ if (typeof selectedId !== 'number') {
+ continue
+ }
+
+ const clickPoint = selectionCoordinates[selectedId]
+ if (clickPoint) {
+ nextSelectionCoordinates[selectedId] = clickPoint
+ }
+ }
+
+ return nextSelectionCoordinates
+}
+
export function updateSelectedIds({ event, context }: SolveAssignArgs) {
assertEvent(event, 'update selected ids')
@@ -788,11 +811,17 @@ export function updateSelectedIds({ event, context }: SolveAssignArgs) {
// Handle regular selectedIds update (for click selection, etc.)
if (event.data.selectedIds !== undefined) {
+ const selectionCoordinates = {
+ ...context.selectionCoordinates,
+ ...(event.data.selectionCoordinates ?? {}),
+ }
+ let nextSelectedIds: SketchSolveSelectionId[]
+
// If empty array is provided, clear the selection
if (event.data.selectedIds.length === 0) {
- updates.selectedIds = []
+ nextSelectedIds = []
} else if (event.data.replaceExistingSelection) {
- updates.selectedIds = event.data.selectedIds
+ nextSelectedIds = event.data.selectedIds
} else {
const first = event.data.selectedIds[0]
if (
@@ -801,15 +830,20 @@ export function updateSelectedIds({ event, context }: SolveAssignArgs) {
context.selectedIds.includes(first)
) {
// If only one ID is selected and it's already in the selection, remove only it from the selection
- updates.selectedIds = context.selectedIds.filter((id) => id !== first)
+ nextSelectedIds = context.selectedIds.filter((id) => id !== first)
} else {
// Merge new IDs with existing selection
- const result = Array.from(
+ nextSelectedIds = Array.from(
new Set([...context.selectedIds, ...event.data.selectedIds])
)
- updates.selectedIds = result
}
}
+
+ updates.selectedIds = nextSelectedIds
+ updates.selectionCoordinates = getSelectionCoordinatesForIds(
+ nextSelectedIds,
+ selectionCoordinates
+ )
}
return updates
@@ -825,6 +859,7 @@ export function updateSelectedIdsFromCodeSelection({
if (!objects) {
return {
selectedIds: [],
+ selectionCoordinates: {},
duringAreaSelectIds: [],
}
}
@@ -834,6 +869,7 @@ export function updateSelectedIdsFromCodeSelection({
getCurrentSketchObjectsById(objects, context.sketchId),
event.data.ranges
),
+ selectionCoordinates: {},
duringAreaSelectIds: [],
}
}
@@ -1577,6 +1613,7 @@ export function spawnTool(
kclManager: context.kclManager,
sketchId: context.sketchId,
initialSelectionIds: context.selectedIds,
+ initialSelectionCoordinates: context.selectionCoordinates,
initialObjects:
context.sketchExecOutcome?.sceneGraphDelta.new_graph.objects || [],
toolVariant: toolVariants[nameOfToolToSpawn],
@@ -1613,6 +1650,7 @@ export type ToolInput = {
kclManager: KclManager
sketchId: number
initialSelectionIds?: SketchSolveSelectionId[]
+ initialSelectionCoordinates?: SelectionCoordinates
initialObjects?: ApiObject[]
toolVariant?: string // eg. 'corner' | 'center' | 'angled' for rectTool
}
diff --git a/src/machines/sketchSolve/sketchSolveSelection.ts b/src/machines/sketchSolve/sketchSolveSelection.ts
index 65615a2910b..77938b3301d 100644
--- a/src/machines/sketchSolve/sketchSolveSelection.ts
+++ b/src/machines/sketchSolve/sketchSolveSelection.ts
@@ -1,7 +1,10 @@
+import type { Coords2d } from '@src/lang/util'
+
export const ORIGIN_TARGET = 'origin'
export type SketchSpecialTarget = typeof ORIGIN_TARGET
export type SketchSolveSelectionId = number | SketchSpecialTarget
+export type SelectionCoordinates = Partial>
export function isObjectSelectionId(
id: SketchSolveSelectionId | null | undefined
diff --git a/src/machines/sketchSolve/tools/centerArcToolImpl.spec.ts b/src/machines/sketchSolve/tools/centerArcToolImpl.spec.ts
index 1f7af4dc134..049f1dfe4f2 100644
--- a/src/machines/sketchSolve/tools/centerArcToolImpl.spec.ts
+++ b/src/machines/sketchSolve/tools/centerArcToolImpl.spec.ts
@@ -66,6 +66,7 @@ function createSceneGraphDelta(
exec_outcome: {
issues: [],
variables: {},
+ refactorMetadata: [],
operations: emptyOperationsByModule(),
artifactGraph: { map: {}, itemCount: 0 },
filenames: {},
diff --git a/src/machines/sketchSolve/tools/dimensionTool.spec.ts b/src/machines/sketchSolve/tools/dimensionTool.spec.ts
new file mode 100644
index 00000000000..a90469e9cf9
--- /dev/null
+++ b/src/machines/sketchSolve/tools/dimensionTool.spec.ts
@@ -0,0 +1,527 @@
+import type {
+ ApiConstraint,
+ ApiObject,
+} from '@rust/kcl-lib/bindings/FrontendApi'
+import type { Coords2d } from '@src/lang/util'
+import type {
+ SelectionCoordinates,
+ SketchSolveSelectionId,
+} from '@src/machines/sketchSolve/sketchSolveSelection'
+import {
+ type DimensionAngleDraftContext,
+ buildDimensionAngleConstraint,
+ machine as dimensionTool,
+ getDimensionAngleSelection,
+} from '@src/machines/sketchSolve/tools/dimensionTool'
+import {
+ createLineApiObject,
+ createMockKclManager,
+ createMockRustContext,
+ createMockSceneInfra,
+ createPointApiObject,
+ createSceneGraphDelta,
+} from '@src/machines/sketchSolve/tools/sketchToolTestUtils'
+import { describe, expect, it, vi } from 'vitest'
+import { assign, createActor, setup, waitFor } from 'xstate'
+
+function createSketchApiObject({ id }: { id: number }): ApiObject {
+ return {
+ id,
+ kind: {
+ type: 'Sketch',
+ args: { on: { default: 'xy' } },
+ plane: 0,
+ segments: [],
+ constraints: [],
+ },
+ label: '',
+ comments: '',
+ artifact_id: '0',
+ source: { type: 'Simple', range: [0, 0, 0], node_path: null },
+ }
+}
+
+function createAngleConstraintObject({
+ id,
+ constraint,
+}: {
+ id: number
+ constraint: ApiConstraint
+}): ApiObject {
+ return {
+ id,
+ kind: {
+ type: 'Constraint',
+ constraint,
+ },
+ label: '',
+ comments: '',
+ artifact_id: '0',
+ source: { type: 'Simple', range: [0, 0, 0], node_path: null },
+ }
+}
+
+function createMouseEvent(point: Coords2d) {
+ return {
+ mouseEvent: {
+ which: 1,
+ detail: 1,
+ },
+ intersectionPoint: {
+ twoD: {
+ x: point[0],
+ y: point[1],
+ },
+ },
+ }
+}
+
+function createParentHarness(
+ objects: ApiObject[],
+ options: {
+ initialSelectionIds?: SketchSolveSelectionId[]
+ initialSelectionCoordinates?: SelectionCoordinates
+ } = {}
+) {
+ const sceneInfra = createMockSceneInfra()
+ const rustContext = createMockRustContext()
+ const kclManager = createMockKclManager()
+ const events: Array<{ type: string; data?: unknown }> = []
+ let nextConstraintId = 30
+ let currentObjects = [...objects]
+
+ rustContext.addConstraint = vi.fn(async (_version, _sketchId, constraint) => {
+ const constraintId = nextConstraintId++
+ currentObjects = [
+ ...currentObjects,
+ createAngleConstraintObject({ id: constraintId, constraint }),
+ ]
+
+ return {
+ kclSource: { text: '' },
+ sceneGraphDelta: createSceneGraphDelta(currentObjects, [constraintId]),
+ checkpointId: null,
+ }
+ }) as typeof rustContext.addConstraint
+ rustContext.editAngleConstraint = vi.fn(
+ async (_version, _sketchId, constraintId, constraint) => {
+ currentObjects = currentObjects.map((object) =>
+ object.id === constraintId
+ ? createAngleConstraintObject({ id: constraintId, constraint })
+ : object
+ )
+
+ return {
+ kclSource: { text: '' },
+ sceneGraphDelta: createSceneGraphDelta(currentObjects),
+ checkpointId: null,
+ }
+ }
+ ) as typeof rustContext.editAngleConstraint
+ rustContext.deleteObjects = vi.fn(
+ async (_version, _sketchId, constraintIds) => {
+ currentObjects = currentObjects.filter(
+ (object) => !constraintIds.includes(object.id)
+ )
+
+ return {
+ kclSource: { text: '' },
+ sceneGraphDelta: createSceneGraphDelta(currentObjects),
+ checkpointId: null,
+ }
+ }
+ ) as typeof rustContext.deleteObjects
+
+ const sceneGraphDelta = createSceneGraphDelta(objects)
+ const parentMachine = setup({
+ types: {
+ context: {} as {
+ sceneGraphDelta: typeof sceneGraphDelta
+ },
+ events: {} as
+ | { type: 'update selected ids'; data: unknown }
+ | { type: 'update hovered id'; data: unknown }
+ | {
+ type: 'update sketch outcome'
+ data: { sceneGraphDelta: typeof sceneGraphDelta }
+ }
+ | { type: 'set draft entities'; data: unknown }
+ | { type: 'clear draft entities' }
+ | { type: 'delete draft entities' },
+ input: {},
+ },
+ actors: {
+ childTool: dimensionTool,
+ },
+ actions: {
+ 'record event': assign(({ context, event }) => {
+ events.push(event)
+ if (event.type !== 'update sketch outcome') {
+ return {}
+ }
+
+ return {
+ sceneGraphDelta: event.data.sceneGraphDelta,
+ }
+ }),
+ },
+ }).createMachine({
+ context: {
+ sceneGraphDelta,
+ },
+ initial: 'running',
+ on: {
+ 'update selected ids': { actions: 'record event' },
+ 'update hovered id': { actions: 'record event' },
+ 'update sketch outcome': { actions: 'record event' },
+ 'set draft entities': { actions: 'record event' },
+ 'clear draft entities': { actions: 'record event' },
+ 'delete draft entities': { actions: 'record event' },
+ },
+ states: {
+ running: {
+ invoke: {
+ id: 'childTool',
+ src: 'childTool',
+ input: {
+ sceneInfra,
+ rustContext,
+ kclManager,
+ sketchId: 0,
+ initialSelectionIds: options.initialSelectionIds,
+ initialSelectionCoordinates: options.initialSelectionCoordinates,
+ initialObjects: sceneGraphDelta.new_graph.objects,
+ },
+ },
+ },
+ },
+ })
+
+ const actor = createActor(parentMachine, { input: {} }).start()
+ return {
+ actor,
+ sceneInfra,
+ rustContext,
+ events,
+ }
+}
+
+describe('dimensionTool angle selection', () => {
+ const lineDirections = {
+ line0Direction: [1, 0] as Coords2d,
+ line1Direction: [Math.cos(Math.PI / 3), Math.sin(Math.PI / 3)] as Coords2d,
+ }
+ const angleContext: DimensionAngleDraftContext = {
+ line0Id: 10,
+ line1Id: 11,
+ ...lineDirections,
+ vertex: [0, 0],
+ baseSelection: {
+ sector: 1,
+ inverse: false,
+ },
+ }
+
+ it('maps cursor sectors relative to the clicked rays', () => {
+ expect(getDimensionAngleSelection([1, 0.25], angleContext)).toEqual({
+ sector: 1,
+ inverse: false,
+ })
+ expect(getDimensionAngleSelection([0, 10], angleContext)).toEqual({
+ sector: 2,
+ inverse: false,
+ })
+ expect(getDimensionAngleSelection([1, -1], angleContext)).toEqual({
+ sector: 4,
+ inverse: false,
+ })
+ expect(getDimensionAngleSelection([-1, -0.6], angleContext)).toEqual({
+ sector: 1,
+ inverse: true,
+ })
+ })
+
+ it('uses inverse when the visible region is opposite the directed KCL sector', () => {
+ const clockwiseContext: DimensionAngleDraftContext = {
+ line0Id: 10,
+ line1Id: 11,
+ line0Direction: [
+ Math.cos(Math.PI / 3),
+ Math.sin(Math.PI / 3),
+ ] as Coords2d,
+ line1Direction: [1, 0],
+ vertex: [0, 0],
+ baseSelection: {
+ sector: 1,
+ inverse: true,
+ },
+ }
+
+ expect(getDimensionAngleSelection([1, 0.25], clockwiseContext)).toEqual({
+ sector: 1,
+ inverse: true,
+ })
+ expect(getDimensionAngleSelection([0, 10], clockwiseContext)).toEqual({
+ sector: 4,
+ inverse: true,
+ })
+ expect(getDimensionAngleSelection([1, -1], clockwiseContext)).toEqual({
+ sector: 2,
+ inverse: true,
+ })
+ expect(getDimensionAngleSelection([-1, -0.3], clockwiseContext)).toEqual({
+ sector: 1,
+ inverse: false,
+ })
+ })
+
+ it('keeps the clicked sector and flips inverse when hovering the opposite side', () => {
+ const southLineContext: DimensionAngleDraftContext = {
+ line0Id: 10,
+ line1Id: 11,
+ line0Direction: [1, 0],
+ line1Direction: [0, -1],
+ vertex: [0, 0],
+ baseSelection: {
+ sector: 1,
+ inverse: true,
+ },
+ }
+
+ expect(getDimensionAngleSelection([-1, 1], southLineContext)).toEqual({
+ sector: 1,
+ inverse: false,
+ })
+ })
+
+ it('builds the labelled angle constraint with sector, inverse, and label position', () => {
+ const constraint = buildDimensionAngleConstraint(
+ angleContext,
+ [-1, -0.6],
+ 'Mm'
+ )
+
+ expect(constraint).toEqual({
+ type: 'Angle',
+ lines: [10, 11],
+ angle: { value: 300, units: 'Deg' },
+ sector: 1,
+ inverse: true,
+ labelPosition: {
+ x: { value: -1, units: 'Mm' },
+ y: { value: -0.6, units: 'Mm' },
+ },
+ source: {
+ expr: '300deg',
+ is_literal: true,
+ },
+ })
+ })
+})
+
+describe('dimensionTool', () => {
+ it('creates a draft labelled angle constraint after selecting two lines', async () => {
+ const sketch = createSketchApiObject({ id: 0 })
+ const origin = createPointApiObject({ id: 1, x: 0, y: 0 })
+ const line0End = createPointApiObject({ id: 2, x: 10, y: 0 })
+ const line1End = createPointApiObject({
+ id: 3,
+ x: 5,
+ y: 8.660254037844386,
+ })
+ const line0 = createLineApiObject({ id: 10, start: 1, end: 2 })
+ const line1 = createLineApiObject({ id: 11, start: 1, end: 3 })
+ const objects = [sketch, origin, line0End, line1End, line0, line1]
+ const { actor, sceneInfra, rustContext, events } =
+ createParentHarness(objects)
+ const callbacks = (sceneInfra.setCallbacks as any).mock.calls[0][0]
+
+ callbacks.onClick(createMouseEvent([8, 0]))
+ callbacks.onClick(createMouseEvent([5, 8.660254037844386]))
+
+ await waitFor(
+ actor,
+ () => (rustContext.addConstraint as any).mock.calls.length === 1
+ )
+
+ expect((rustContext.addConstraint as any).mock.calls[0][2]).toEqual({
+ type: 'Angle',
+ lines: [10, 11],
+ angle: { value: 60, units: 'Deg' },
+ sector: 1,
+ inverse: false,
+ labelPosition: {
+ x: { value: 5, units: 'Mm' },
+ y: { value: 8.66, units: 'Mm' },
+ },
+ source: {
+ expr: '60deg',
+ is_literal: true,
+ },
+ })
+ expect(events).toContainEqual({
+ type: 'set draft entities',
+ data: {
+ segmentIds: [],
+ constraintIds: [30],
+ },
+ })
+ callbacks.onClick(createMouseEvent([4, 3]))
+
+ await waitFor(
+ actor,
+ () => (rustContext.editAngleConstraint as any).mock.calls.length === 1
+ )
+
+ expect((rustContext.addConstraint as any).mock.calls).toHaveLength(1)
+ expect((rustContext.editAngleConstraint as any).mock.calls[0]).toEqual([
+ 0,
+ 0,
+ 30,
+ {
+ type: 'Angle',
+ lines: [10, 11],
+ angle: { value: 60, units: 'Deg' },
+ sector: 1,
+ inverse: false,
+ labelPosition: {
+ x: { value: 4, units: 'Mm' },
+ y: { value: 3, units: 'Mm' },
+ },
+ source: {
+ expr: '60deg',
+ is_literal: true,
+ },
+ },
+ expect.any(Object),
+ true,
+ true,
+ ])
+ expect(events).toContainEqual({
+ type: 'update selected ids',
+ data: {
+ selectedIds: [],
+ duringAreaSelectIds: [],
+ },
+ })
+ expect(events).toContainEqual({
+ type: 'update hovered id',
+ data: { hoveredId: 30 },
+ })
+ })
+
+ it('edits the existing draft angle constraint while moving the cursor', async () => {
+ const sketch = createSketchApiObject({ id: 0 })
+ const origin = createPointApiObject({ id: 1, x: 0, y: 0 })
+ const line0End = createPointApiObject({ id: 2, x: 10, y: 0 })
+ const line1End = createPointApiObject({
+ id: 3,
+ x: 5,
+ y: 8.660254037844386,
+ })
+ const line0 = createLineApiObject({ id: 10, start: 1, end: 2 })
+ const line1 = createLineApiObject({ id: 11, start: 1, end: 3 })
+ const objects = [sketch, origin, line0End, line1End, line0, line1]
+ const { actor, sceneInfra, rustContext } = createParentHarness(objects)
+ const callbacks = (sceneInfra.setCallbacks as any).mock.calls[0][0]
+
+ callbacks.onClick(createMouseEvent([8, 0]))
+ callbacks.onClick(createMouseEvent([5, 8.660254037844386]))
+
+ await waitFor(
+ actor,
+ () => (rustContext.addConstraint as any).mock.calls.length === 1
+ )
+
+ callbacks.onMove(createMouseEvent([0, 10]))
+
+ await waitFor(
+ actor,
+ () => (rustContext.editAngleConstraint as any).mock.calls.length === 1
+ )
+
+ expect((rustContext.addConstraint as any).mock.calls).toHaveLength(1)
+ expect((rustContext.deleteObjects as any).mock.calls).toHaveLength(0)
+ const editCall = (rustContext.editAngleConstraint as any).mock.calls[0]
+ expect(editCall[2]).toBe(30)
+ expect(editCall[3]).toEqual({
+ type: 'Angle',
+ lines: [10, 11],
+ angle: { value: 120, units: 'Deg' },
+ sector: 2,
+ inverse: false,
+ labelPosition: {
+ x: { value: 0, units: 'Mm' },
+ y: { value: 10, units: 'Mm' },
+ },
+ source: {
+ expr: '120deg',
+ is_literal: true,
+ },
+ })
+ expect(editCall[5]).toBe(false)
+ expect(editCall[6]).toBe(false)
+ })
+
+ it('starts sector selection when initialized with two selected lines', async () => {
+ const sketch = createSketchApiObject({ id: 0 })
+ const origin = createPointApiObject({ id: 1, x: 0, y: 0 })
+ const line0End = createPointApiObject({ id: 2, x: 10, y: 0 })
+ const line1End = createPointApiObject({
+ id: 3,
+ x: 5,
+ y: 8.660254037844386,
+ })
+ const line0 = createLineApiObject({ id: 10, start: 1, end: 2 })
+ const line1 = createLineApiObject({ id: 11, start: 1, end: 3 })
+ const objects = [sketch, origin, line0End, line1End, line0, line1]
+ const { actor, sceneInfra, rustContext, events } = createParentHarness(
+ objects,
+ {
+ initialSelectionIds: [10, 11],
+ }
+ )
+ const callbacks = (sceneInfra.setCallbacks as any).mock.calls[0][0]
+
+ callbacks.onMove(createMouseEvent([-1, -0.6]))
+
+ await waitFor(
+ actor,
+ () => (rustContext.addConstraint as any).mock.calls.length === 1
+ )
+
+ expect((rustContext.addConstraint as any).mock.calls[0][2]).toEqual({
+ type: 'Angle',
+ lines: [10, 11],
+ angle: { value: 300, units: 'Deg' },
+ sector: 1,
+ inverse: true,
+ labelPosition: {
+ x: { value: -1, units: 'Mm' },
+ y: { value: -0.6, units: 'Mm' },
+ },
+ source: {
+ expr: '300deg',
+ is_literal: true,
+ },
+ })
+ expect(events).toContainEqual({
+ type: 'update selected ids',
+ data: {
+ selectedIds: [10, 11],
+ replaceExistingSelection: true,
+ selectionCoordinates: {
+ 10: [10, 0],
+ 11: [5, 8.660254037844386],
+ },
+ },
+ })
+ expect(events).toContainEqual({
+ type: 'set draft entities',
+ data: {
+ segmentIds: [],
+ constraintIds: [30],
+ },
+ })
+ })
+})
diff --git a/src/machines/sketchSolve/tools/dimensionTool.ts b/src/machines/sketchSolve/tools/dimensionTool.ts
index 4514d1fa195..c444e5cbe9b 100644
--- a/src/machines/sketchSolve/tools/dimensionTool.ts
+++ b/src/machines/sketchSolve/tools/dimensionTool.ts
@@ -1,101 +1,869 @@
-import type { SceneGraphDelta } from '@rust/kcl-lib/bindings/FrontendApi'
+import type {
+ ApiConstraint,
+ ApiObject,
+ SceneGraphDelta,
+ SourceDelta,
+} from '@rust/kcl-lib/bindings/FrontendApi'
+import type { NumericSuffix } from '@rust/kcl-lib/bindings/NumericSuffix'
import type { SceneInfra } from '@src/clientSideScene/sceneInfra'
import type { KclManager } from '@src/lang/KclManager'
+import type { Coords2d } from '@src/lang/util'
+import { baseUnitToNumericSuffix } from '@src/lang/wasm'
+import { SKETCH_FILE_VERSION } from '@src/lib/constants'
import type RustContext from '@src/lib/rustContext'
+import { jsAppSettings } from '@src/lib/settings/settingsUtils'
+import { toastToolbar } from '@src/lib/toolbarToast'
+import { roundOff } from '@src/lib/utils'
+import {
+ dot2d,
+ getCcwSweep,
+ getLineIntersection,
+ length2d,
+ normalizeVec,
+ scaleVec,
+ subVec,
+} from '@src/lib/utils2d'
+import {
+ getLinePoints,
+ isLineSegment,
+} from '@src/machines/sketchSolve/constraints/constraintUtils'
+import { findClosestApiObjects } from '@src/machines/sketchSolve/interaction/interactionHelpers'
+import { getCurrentSketchObjectsById } from '@src/machines/sketchSolve/sceneGraphUtils'
import { toastSketchSolveError } from '@src/machines/sketchSolve/sketchSolveErrors'
+import type { SketchSolveMachineEvent } from '@src/machines/sketchSolve/sketchSolveImpl'
+import type {
+ SelectionCoordinates,
+ SketchSolveSelectionId,
+} from '@src/machines/sketchSolve/sketchSolveSelection'
import type { BaseToolEvent } from '@src/machines/sketchSolve/tools/sharedToolTypes'
-import { createMachine, setup } from 'xstate'
+import { setup } from 'xstate'
-type DimensionToolEvent = BaseToolEvent
+type DimensionToolContext = {
+ sceneInfra: SceneInfra
+ rustContext: RustContext
+ kclManager: KclManager
+ sketchId: number
+ initialSelectionIds: SketchSolveSelectionId[]
+ initialSelectionCoordinates: SelectionCoordinates
+ initialObjects: ApiObject[]
+ runtime: DraftRuntime
+}
+
+type DimensionToolInput = {
+ sceneInfra: SceneInfra
+ rustContext: RustContext
+ kclManager: KclManager
+ sketchId: number
+ initialSelectionIds?: SketchSolveSelectionId[]
+ initialSelectionCoordinates?: SelectionCoordinates
+ initialObjects?: ApiObject[]
+ sceneGraphDelta?: SceneGraphDelta
+}
+
+type DimensionToolEvent =
+ | BaseToolEvent
+ | {
+ type: 'done'
+ }
+
+type ParentSketchSolveSender = {
+ _parent?: { send: (event: SketchSolveMachineEvent) => void }
+}
+
+type DimensionToolSelf = ParentSketchSolveSender & {
+ send: (event: DimensionToolEvent) => void
+}
+
+type LineSelection = {
+ id: number
+ clickPoint: Coords2d
+}
+
+export type AngleSector = 1 | 2 | 3 | 4
+
+export type DimensionAngleDraftContext = {
+ line0Id: number
+ line1Id: number
+ line0Direction: Coords2d
+ line1Direction: Coords2d
+ vertex: Coords2d
+ baseSelection: DimensionAngleSelection
+}
+
+type DimensionAngleDirections = {
+ line0Direction: Coords2d
+ line1Direction: Coords2d
+}
+
+type DimensionAngleSelection = {
+ sector: AngleSector
+ inverse: boolean
+}
+
+type DraftRuntime = {
+ firstSelection: LineSelection | null
+ angleContext: DimensionAngleDraftContext | null
+ draftConstraintId: number | null
+ lastDraftKey: string | null
+ previewInFlight: boolean
+ queuedMousePoint: Coords2d | null
+ // Used by async api calls in case tool got deactivated since
+ active: boolean
+}
+
+type ApiAngleConstraint = Extract
+
+const ANGLE_SECTORS = [1, 2, 3, 4] as const satisfies ReadonlyArray
+const ANGLE_SECTOR_PROMPT_TOAST_ID = 'dimension-tool-angle-sector-prompt'
+
+function getDefaultLengthUnit(kclManager: KclManager): NumericSuffix {
+ return baseUnitToNumericSuffix(
+ kclManager.fileSettings.defaultLengthUnit ?? 'mm'
+ )
+}
+
+function sendParent(
+ self: ParentSketchSolveSender,
+ event: SketchSolveMachineEvent
+) {
+ self._parent?.send(event)
+}
+
+function createRuntime(): DraftRuntime {
+ return {
+ firstSelection: null,
+ angleContext: null,
+ draftConstraintId: null,
+ lastDraftKey: null,
+ previewInFlight: false,
+ queuedMousePoint: null,
+ active: true,
+ }
+}
+
+function deactivateRuntime(runtime: DraftRuntime) {
+ runtime.active = false
+ runtime.queuedMousePoint = null
+}
+
+function isClickedRayDirectionForward(
+ linePoints: readonly [Coords2d, Coords2d],
+ vertex: Coords2d,
+ clickPoint: Coords2d
+): boolean {
+ const lineDirection = normalizeVec(subVec(linePoints[1], linePoints[0]))
+ const clickDirection = subVec(clickPoint, vertex)
+ return dot2d(clickDirection, lineDirection) >= 0
+}
+
+// Given which side of line0 and line1 the user clicked, which semantic sector is that?
+export function getBaseAngleSector(
+ line0RayDirectionIsForward: boolean,
+ line1RayDirectionIsForward: boolean
+): AngleSector {
+ if (line0RayDirectionIsForward && line1RayDirectionIsForward) {
+ return 1
+ }
+ if (!line0RayDirectionIsForward && line1RayDirectionIsForward) {
+ return 2
+ }
+ if (!line0RayDirectionIsForward && !line1RayDirectionIsForward) {
+ return 3
+ }
+ return 4
+}
+
+// Given a sector number, what are the ordered start/end rays used to measure the angle?
+export function getAngleSectorRays(
+ angleContext: DimensionAngleDirections,
+ sector: AngleSector
+): [Coords2d, Coords2d] {
+ switch (sector) {
+ case 1:
+ return [angleContext.line0Direction, angleContext.line1Direction]
+ case 2:
+ return [
+ angleContext.line1Direction,
+ scaleVec(angleContext.line0Direction, -1),
+ ]
+ case 3:
+ return [
+ scaleVec(angleContext.line0Direction, -1),
+ scaleVec(angleContext.line1Direction, -1),
+ ]
+ case 4:
+ return [
+ scaleVec(angleContext.line1Direction, -1),
+ angleContext.line0Direction,
+ ]
+ }
+}
+
+function getDimensionAngleContext(
+ firstSelection: LineSelection,
+ secondSelection: LineSelection,
+ objects: ApiObject[]
+): DimensionAngleDraftContext | null {
+ const line0 = objects[firstSelection.id]
+ const line1 = objects[secondSelection.id]
+ const line0Points = getLinePoints(line0, objects)
+ const line1Points = getLinePoints(line1, objects)
+ if (!line0Points || !line1Points) {
+ return null
+ }
+
+ const line0Vector = subVec(line0Points[1], line0Points[0])
+ const line1Vector = subVec(line1Points[1], line1Points[0])
+ if (length2d(line0Vector) === 0 || length2d(line1Vector) === 0) {
+ return null
+ }
+
+ const vertex = getLineIntersection(line0Points, line1Points)
+ if (!vertex) {
+ return null
+ }
+
+ const line0RayDirectionIsForward = isClickedRayDirectionForward(
+ line0Points,
+ vertex,
+ firstSelection.clickPoint
+ )
+ const line1RayDirectionIsForward = isClickedRayDirectionForward(
+ line1Points,
+ vertex,
+ secondSelection.clickPoint
+ )
+
+ const angleContextBase = {
+ line0Id: firstSelection.id,
+ line1Id: secondSelection.id,
+ line0Direction: normalizeVec(line0Vector),
+ line1Direction: normalizeVec(line1Vector),
+ vertex,
+ }
+ return {
+ ...angleContextBase,
+ baseSelection: getVisibleAngleSelection(
+ angleContextBase,
+ getBaseAngleSector(line0RayDirectionIsForward, line1RayDirectionIsForward)
+ ),
+ }
+}
+
+function getFarthestLinePointFromVertex(
+ linePoints: readonly [Coords2d, Coords2d],
+ vertex: Coords2d
+): Coords2d {
+ return length2d(subVec(linePoints[1], vertex)) >=
+ length2d(subVec(linePoints[0], vertex))
+ ? linePoints[1]
+ : linePoints[0]
+}
+
+function getInitialAngleLineSelections(
+ selectionIds: readonly SketchSolveSelectionId[],
+ selectionCoordinates: SelectionCoordinates,
+ objects: ApiObject[]
+): [LineSelection, LineSelection] | null {
+ const lineIds = selectionIds.filter(
+ (id): id is number => typeof id === 'number'
+ )
+ if (lineIds.length !== 2) {
+ return null
+ }
+
+ const line0Points = getLinePoints(objects[lineIds[0]], objects)
+ const line1Points = getLinePoints(objects[lineIds[1]], objects)
+ if (!line0Points || !line1Points) {
+ return null
+ }
+
+ const vertex = getLineIntersection(line0Points, line1Points)
+ if (!vertex) {
+ return null
+ }
+
+ return [
+ {
+ id: lineIds[0],
+ clickPoint:
+ selectionCoordinates[lineIds[0]] ??
+ getFarthestLinePointFromVertex(line0Points, vertex),
+ },
+ {
+ id: lineIds[1],
+ clickPoint:
+ selectionCoordinates[lineIds[1]] ??
+ getFarthestLinePointFromVertex(line1Points, vertex),
+ },
+ ]
+}
+
+function getVisibleAngleSelection(
+ angleContext: DimensionAngleDirections,
+ sector: AngleSector
+): DimensionAngleSelection {
+ const [start, end] = getAngleSectorRays(angleContext, sector)
+ return {
+ sector,
+ inverse: getCcwSweep(start, end) > Math.PI,
+ }
+}
+
+function getVisibleAngleSectorRays(
+ angleContext: DimensionAngleDirections,
+ selection: DimensionAngleSelection
+): [Coords2d, Coords2d] {
+ const [start, end] = getAngleSectorRays(angleContext, selection.sector)
+ return selection.inverse ? [end, start] : [start, end]
+}
+
+function getHoveredAngleSelection(
+ mousePoint: Coords2d,
+ angleContext: DimensionAngleDraftContext
+): DimensionAngleSelection {
+ const mouseDirection = subVec(mousePoint, angleContext.vertex)
+ if (length2d(mouseDirection) === 0) {
+ return angleContext.baseSelection
+ }
+
+ return (
+ ANGLE_SECTORS.map((sector) =>
+ getVisibleAngleSelection(angleContext, sector)
+ ).find((selection) => {
+ const [start, end] = getVisibleAngleSectorRays(angleContext, selection)
+ const isDirectionInSector =
+ getCcwSweep(start, mouseDirection) <= getCcwSweep(start, end) + 1e-9
+
+ return isDirectionInSector
+ }) ?? angleContext.baseSelection
+ )
+}
+
+function invertAngleSelection(
+ selection: DimensionAngleSelection
+): DimensionAngleSelection {
+ return {
+ sector: selection.sector,
+ inverse: !selection.inverse,
+ }
+}
+
+export function getDimensionAngleSelection(
+ mousePoint: Coords2d,
+ angleContext: DimensionAngleDraftContext
+): DimensionAngleSelection {
+ const hoveredSelection = getHoveredAngleSelection(mousePoint, angleContext)
+ const oppositeBaseSector = ((angleContext.baseSelection.sector + 1) % 4) + 1
+
+ if (hoveredSelection.sector === oppositeBaseSector) {
+ return invertAngleSelection(angleContext.baseSelection)
+ }
+
+ return hoveredSelection
+}
+
+function getDimensionAngleDegrees(
+ angleContext: DimensionAngleDraftContext,
+ selection: DimensionAngleSelection
+) {
+ let [start, end] = getAngleSectorRays(angleContext, selection.sector)
+ if (selection.inverse) {
+ ;[start, end] = [end, start]
+ }
+
+ return roundOff((getCcwSweep(start, end) * 180) / Math.PI)
+}
+
+function toNumber(value: number, units: NumericSuffix) {
+ return {
+ value: roundOff(value),
+ units,
+ }
+}
+
+export function buildDimensionAngleConstraint(
+ angleContext: DimensionAngleDraftContext,
+ mousePoint: Coords2d,
+ units: NumericSuffix
+): ApiAngleConstraint {
+ const selection = getDimensionAngleSelection(mousePoint, angleContext)
+ const angle = getDimensionAngleDegrees(angleContext, selection)
+
+ return {
+ type: 'Angle',
+ lines: [angleContext.line0Id, angleContext.line1Id],
+ angle: { value: angle, units: 'Deg' },
+ sector: selection.sector,
+ inverse: selection.inverse,
+ labelPosition: {
+ x: toNumber(mousePoint[0], units),
+ y: toNumber(mousePoint[1], units),
+ },
+ source: {
+ expr: `${angle}deg`,
+ is_literal: true,
+ },
+ }
+}
+
+function getConstraintIdFromResult(result: {
+ sceneGraphDelta: SceneGraphDelta
+}): number | null {
+ return (
+ [...result.sceneGraphDelta.new_objects].reverse().find((objectId) => {
+ const object = result.sceneGraphDelta.new_graph.objects[objectId]
+ return (
+ object?.kind.type === 'Constraint' &&
+ object.kind.constraint.type === 'Angle'
+ )
+ }) ?? null
+ )
+}
+
+function getDraftKey(constraint: ApiAngleConstraint) {
+ return [
+ constraint.lines.join(','),
+ constraint.angle.value,
+ constraint.sector ?? '',
+ constraint.inverse === true ? 'inverse' : 'direct',
+ constraint.labelPosition?.x.value ?? '',
+ constraint.labelPosition?.y.value ?? '',
+ ].join(':')
+}
+
+async function deleteInactivePreviewConstraint(
+ context: DimensionToolContext,
+ constraintId: number
+) {
+ await context.rustContext.deleteObjects(
+ SKETCH_FILE_VERSION,
+ context.sketchId,
+ [constraintId],
+ [],
+ jsAppSettings(context.rustContext.settingsActor),
+ false
+ )
+}
+
+function sendPreviewResultToParent(
+ self: ParentSketchSolveSender,
+ result: {
+ kclSource: SourceDelta
+ sceneGraphDelta: SceneGraphDelta
+ checkpointId?: number | null
+ }
+) {
+ sendParent(self, {
+ type: 'update sketch outcome',
+ data: {
+ sourceDelta: result.kclSource,
+ sceneGraphDelta: result.sceneGraphDelta,
+ checkpointId: result.checkpointId ?? null,
+ writeToDisk: false,
+ addToHistory: false,
+ suppressExecOutcomeIssues: true,
+ },
+ })
+}
+
+async function updateDraftAngleConstraint(
+ runtime: DraftRuntime,
+ context: DimensionToolContext,
+ self: ParentSketchSolveSender,
+ mousePoint: Coords2d
+) {
+ if (!runtime.active || !runtime.angleContext) {
+ return
+ }
+
+ const constraint = buildDimensionAngleConstraint(
+ runtime.angleContext,
+ mousePoint,
+ getDefaultLengthUnit(context.kclManager)
+ )
+ const draftKey = getDraftKey(constraint)
+ // Skip constraint edits when the mouse moved too little to change the draft.
+ if (draftKey === runtime.lastDraftKey) {
+ return
+ }
+
+ const settings = jsAppSettings(context.rustContext.settingsActor)
+ const existingConstraintId = runtime.draftConstraintId
+ const result =
+ existingConstraintId === null
+ ? await context.rustContext.addConstraint(
+ SKETCH_FILE_VERSION,
+ context.sketchId,
+ constraint,
+ settings,
+ false
+ )
+ : await context.rustContext.editAngleConstraint(
+ SKETCH_FILE_VERSION,
+ context.sketchId,
+ existingConstraintId,
+ constraint,
+ settings,
+ false,
+ false
+ )
+
+ const constraintId = existingConstraintId ?? getConstraintIdFromResult(result)
+ if (constraintId === null) {
+ return
+ }
+ if (!runtime.active) {
+ if (existingConstraintId === null) {
+ await deleteInactivePreviewConstraint(context, constraintId)
+ }
+ return
+ }
+
+ runtime.draftConstraintId = constraintId
+ runtime.lastDraftKey = draftKey
+
+ sendPreviewResultToParent(self, result)
+ if (existingConstraintId === null) {
+ sendParent(self, {
+ type: 'set draft entities',
+ data: {
+ segmentIds: [],
+ constraintIds: [constraintId],
+ },
+ })
+ }
+}
+
+function requestDraftPreview(
+ runtime: DraftRuntime,
+ context: DimensionToolContext,
+ self: ParentSketchSolveSender,
+ mousePoint: Coords2d
+) {
+ if (!runtime.active) {
+ return
+ }
+
+ runtime.queuedMousePoint = mousePoint
+ if (runtime.previewInFlight) {
+ return
+ }
+
+ runtime.previewInFlight = true
+ void (async () => {
+ try {
+ while (runtime.active && runtime.queuedMousePoint) {
+ const nextMousePoint = runtime.queuedMousePoint
+ runtime.queuedMousePoint = null
+ await updateDraftAngleConstraint(runtime, context, self, nextMousePoint)
+ }
+ } catch (error) {
+ toastSketchSolveError(error)
+ } finally {
+ runtime.previewInFlight = false
+ }
+ })()
+}
+
+async function commitDraftAngleConstraint(
+ runtime: DraftRuntime,
+ context: DimensionToolContext,
+ self: DimensionToolSelf,
+ mousePoint: Coords2d
+) {
+ if (!runtime.active || !runtime.angleContext) {
+ return
+ }
+
+ const constraint = buildDimensionAngleConstraint(
+ runtime.angleContext,
+ mousePoint,
+ getDefaultLengthUnit(context.kclManager)
+ )
+
+ try {
+ deactivateRuntime(runtime)
+ const settings = jsAppSettings(context.rustContext.settingsActor)
+ // This is normally never null, except for edge cases:
+ // - click is faster than draft preview creation
+ // - draft preview creation failed
+ const existingConstraintId = runtime.draftConstraintId
+ const result =
+ existingConstraintId === null
+ ? await context.rustContext.addConstraint(
+ SKETCH_FILE_VERSION,
+ context.sketchId,
+ constraint,
+ settings,
+ true
+ )
+ : await context.rustContext.editAngleConstraint(
+ SKETCH_FILE_VERSION,
+ context.sketchId,
+ existingConstraintId,
+ constraint,
+ settings,
+ true,
+ true
+ )
+
+ const constraintId =
+ existingConstraintId ?? getConstraintIdFromResult(result)
+ runtime.draftConstraintId = null
+ sendParent(self, {
+ type: 'update sketch outcome',
+ data: {
+ sourceDelta: result.kclSource,
+ sceneGraphDelta: result.sceneGraphDelta,
+ checkpointId: result.checkpointId ?? null,
+ },
+ })
+ sendParent(self, { type: 'clear draft entities' })
+ sendParent(self, {
+ type: 'update selected ids',
+ data: { selectedIds: [], duringAreaSelectIds: [] },
+ })
+ sendParent(self, {
+ type: 'update hovered id',
+ data: { hoveredId: constraintId },
+ })
+ toastToolbar.dismiss(ANGLE_SECTOR_PROMPT_TOAST_ID)
+ self.send({ type: 'done' })
+ } catch (error) {
+ runtime.active = true
+ toastSketchSolveError(error)
+ }
+}
+
+function getClosestLineSelection(
+ mousePoint: Coords2d,
+ context: DimensionToolContext
+): LineSelection | null {
+ const currentSketchObjects = getCurrentSketchObjectsById(
+ context.initialObjects,
+ context.sketchId
+ )
+ const closestLine = findClosestApiObjects(
+ mousePoint,
+ currentSketchObjects,
+ context.sceneInfra
+ ).find(({ apiObject }) => isLineSegment(apiObject))
+
+ if (!closestLine) {
+ return null
+ }
+
+ return {
+ id: closestLine.apiObject.id,
+ clickPoint: mousePoint,
+ }
+}
+
+function addDimensionListener({
+ context,
+ self,
+}: {
+ context: DimensionToolContext
+ self: DimensionToolSelf
+}) {
+ const runtime = context.runtime
+ runtime.active = true
+ const initialObjects = context.initialObjects
+ const initialLineSelections = getInitialAngleLineSelections(
+ context.initialSelectionIds,
+ context.initialSelectionCoordinates,
+ initialObjects
+ )
+ if (initialLineSelections) {
+ const [firstSelection, secondSelection] = initialLineSelections
+ const angleContext = getDimensionAngleContext(
+ firstSelection,
+ secondSelection,
+ initialObjects
+ )
+ if (angleContext) {
+ runtime.firstSelection = firstSelection
+ runtime.angleContext = angleContext
+ toastToolbar('Move mouse to choose sector, then click to place label.', {
+ id: ANGLE_SECTOR_PROMPT_TOAST_ID,
+ duration: Number.POSITIVE_INFINITY,
+ })
+ sendParent(self, {
+ type: 'update hovered id',
+ data: { hoveredId: null },
+ })
+ sendParent(self, {
+ type: 'update selected ids',
+ data: {
+ selectedIds: [firstSelection.id, secondSelection.id],
+ replaceExistingSelection: true,
+ selectionCoordinates: {
+ [firstSelection.id]: firstSelection.clickPoint,
+ [secondSelection.id]: secondSelection.clickPoint,
+ },
+ },
+ })
+ }
+ }
+
+ context.sceneInfra.setCallbacks({
+ onClick: (args) => {
+ if (!args || args.mouseEvent.which !== 1) {
+ return
+ }
+
+ const twoD = args.intersectionPoint?.twoD
+ if (!twoD) {
+ return
+ }
+
+ const mousePoint: Coords2d = [twoD.x, twoD.y]
+ if (!runtime.firstSelection) {
+ // First click: choose the first line and remember which side was clicked.
+ const lineSelection = getClosestLineSelection(mousePoint, context)
+ if (lineSelection) {
+ runtime.firstSelection = lineSelection
+ sendParent(self, {
+ type: 'update selected ids',
+ data: {
+ selectedIds: [lineSelection.id],
+ replaceExistingSelection: true,
+ selectionCoordinates: {
+ [lineSelection.id]: lineSelection.clickPoint,
+ },
+ },
+ })
+ }
+ } else if (!runtime.angleContext) {
+ // Second click: choose the second line and enter sector/label placement.
+ const lineSelection = getClosestLineSelection(mousePoint, context)
+ if (lineSelection && lineSelection.id !== runtime.firstSelection.id) {
+ const angleContext = getDimensionAngleContext(
+ runtime.firstSelection,
+ lineSelection,
+ initialObjects
+ )
+
+ if (angleContext) {
+ runtime.angleContext = angleContext
+ sendParent(self, {
+ type: 'update hovered id',
+ data: { hoveredId: null },
+ })
+ sendParent(self, {
+ type: 'update selected ids',
+ data: {
+ selectedIds: [runtime.firstSelection.id, lineSelection.id],
+ replaceExistingSelection: true,
+ selectionCoordinates: {
+ [runtime.firstSelection.id]:
+ runtime.firstSelection.clickPoint,
+ [lineSelection.id]: lineSelection.clickPoint,
+ },
+ },
+ })
+ requestDraftPreview(runtime, context, self, mousePoint)
+ }
+ }
+ } else {
+ // Third click: place the label and commit the draft angle constraint.
+ // If 2 lines were already selected when equipping, this is the first click.
+ void commitDraftAngleConstraint(runtime, context, self, mousePoint)
+ }
+ },
+ onMove: (args) => {
+ const twoD = args?.intersectionPoint?.twoD
+ if (!twoD) {
+ sendParent(self, {
+ type: 'update hovered id',
+ data: { hoveredId: null },
+ })
+ return
+ }
+
+ const mousePoint: Coords2d = [twoD.x, twoD.y]
+ if (runtime.angleContext) {
+ // After both lines are selected, mouse movement updates the angle label draft.
+ requestDraftPreview(runtime, context, self, mousePoint)
+ } else {
+ // Before the second line is selected, mouse movement only updates line hover.
+ const lineSelection = getClosestLineSelection(mousePoint, context)
+ sendParent(self, {
+ type: 'update hovered id',
+ data: { hoveredId: lineSelection?.id ?? null },
+ })
+ }
+ },
+ })
+}
+
+function removeDimensionListener({
+ context,
+}: {
+ context: DimensionToolContext
+}) {
+ deactivateRuntime(context.runtime)
+ toastToolbar.dismiss(ANGLE_SECTOR_PROMPT_TOAST_ID)
+ context.sceneInfra.setCallbacks({
+ onClick: () => {},
+ onMove: () => {},
+ })
+}
export const machine = setup({
types: {
- context: {},
+ context: {} as DimensionToolContext,
events: {} as DimensionToolEvent,
- input: {} as {
- sceneInfra: SceneInfra
- rustContext: RustContext
- kclManager: KclManager
- sketchId: number
- sceneGraphDelta?: SceneGraphDelta
- },
+ input: {} as DimensionToolInput,
},
actions: {
- 'add point listener': () => {
- console.log('tool successfully equipped')
- // Add your action code here
- // ...
- },
- 'show draft geometry': () => {
- // Add your action code here
- // ...
- },
- 'remove point listener': () => {
- // Add your action code here
- // ...
- },
+ 'add dimension listener': addDimensionListener,
+ 'remove dimension listener': removeDimensionListener,
+ 'delete draft entities': ({ self }) =>
+ sendParent(self, { type: 'delete draft entities' }),
'toast sketch solve error': ({ event }) => {
toastSketchSolveError(event)
},
},
- actors: {
- askUserForDimensionValues: createMachine({
- /* ... */
- }),
- },
}).createMachine({
/** @xstate-layout N4IgpgJg5mDOIC5QBECWBbMA7WqD2WABAC554A2AxAK5ZgCO1qADgNoAMAuoqM3rsXxYeIAB6J2AGhABPCQF950tJhxCSZcgDoAhgHcdqQViiEAZqgBOsYoT6osxSjogQ7eB8Q7ckIPgKERcQQANgAWMK0AZgB2AFYYgE4ADhD2AEYQqIzpOQQo9PStGOT00rCQxMqAJijkuMVlDGxcAg0KXQMjQlgwAGMCN3tHZ1d3T28RfyNA32DwyNiElLTM7PTcxDC45K1qkLKQ5OqYqLj09iiwxpAVFvVSDoBhAgtLdAdTCGa1AlhKCAEMBaBwANzwAGtgXdfkRHtoXlg3h8TIRvqpWjgEGC8H0dIICN5Jr5pgThHNEHF2DE9ok4md9tUIuFkmFNghkjFqtFUiztmV9jcYZj2gjXlYUV8fpj-mBLJY8JYtMxyPizIr0FphQ9NFpEcjPmjpUJYNisOC8WSiVwpvwZgQglt2OwtCF6TFnYkomd0tU4uzStyyhF0lFEtVEuxqqzFEoQFg8BA4CJtW14baAg6KQgALRhZLsnOhorpL0xLKJeLh-NC41p3X6QzGUxvGzjRwZ+3k0DBKIHLThj0FCJxMIxMKxdm1bkxX1pEpxJnjhpx1NwhtdWy9AZYIYeDsku1kx35EI0kfsKkT9Ie8MhdkRGnHN3bdiRirR66ruvr57i96Gui9x-J2x7Zt6NJemEzp+icfr3rIiAHJEYTVDeo6ltUfqJIktYYjqHS0AwTDMMwnygbMPYSAGZwDr6VLjuElaLrG8hAA */
- context: {},
+ context: ({ input }): DimensionToolContext => ({
+ sceneInfra: input.sceneInfra,
+ rustContext: input.rustContext,
+ kclManager: input.kclManager,
+ sketchId: input.sketchId,
+ initialSelectionIds: input.initialSelectionIds ?? [],
+ initialSelectionCoordinates: input.initialSelectionCoordinates ?? {},
+ initialObjects:
+ input.initialObjects ?? input.sceneGraphDelta?.new_graph.objects ?? [],
+ runtime: createRuntime(),
+ }),
id: 'Dimension tool',
- initial: 'awaiting first point',
+ initial: 'selecting lines',
on: {
unequip: {
target: '#Dimension tool.unequipping',
- description:
- "can be requested from the outside, but we want this tool to have the final say on when it's done.",
+ actions: 'delete draft entities',
},
escape: {
target: '#Dimension tool.unequipping',
- description: 'ESC unequips the tool',
+ actions: 'delete draft entities',
},
},
- description:
- 'Creates dimension constraints based on two points from the user.',
+ description: 'Creates dimension constraints from sketch selections.',
states: {
- 'awaiting first point': {
- on: {
- 'add point': {
- target: 'await second point',
- },
- },
- entry: 'add point listener',
- },
- 'await second point': {
+ 'selecting lines': {
+ entry: 'add dimension listener',
on: {
- 'add point': {
- target: 'Confirming dimensions',
- },
- },
- entry: 'show draft geometry',
- },
- 'Confirming dimensions': {
- invoke: {
- input: {},
- onDone: {
- target: 'unequipping',
- },
- onError: {
+ done: {
target: 'unequipping',
- actions: 'toast sketch solve error',
},
- src: 'askUserForDimensionValues',
},
- description:
- 'Show the user form fields for dimension values, allowing them to input values directly. This will add dimension-type constraints.',
+ exit: 'remove dimension listener',
},
unequipping: {
type: 'final',
- entry: 'remove point listener',
description: 'Any teardown logic should go here.',
},
},
diff --git a/src/machines/sketchSolve/tools/moveTool/areaSelectUtils.ts b/src/machines/sketchSolve/tools/moveTool/areaSelectUtils.ts
index 9356c4ab29f..c7e146728b4 100644
--- a/src/machines/sketchSolve/tools/moveTool/areaSelectUtils.ts
+++ b/src/machines/sketchSolve/tools/moveTool/areaSelectUtils.ts
@@ -5,8 +5,7 @@ import {
SKETCH_SOLVE_GROUP,
} from '@src/clientSideScene/sceneUtils'
import type { Coords2d } from '@src/lang/util'
-import { getAngleDiff } from '@src/lib/utils'
-import { TAU } from '@src/lib/utils2d'
+import { TAU, getAngleDiff } from '@src/lib/utils2d'
import {
getArcPoints,
getLinePoints,
diff --git a/src/machines/sketchSolve/tools/moveTool/moveTool.spec.ts b/src/machines/sketchSolve/tools/moveTool/moveTool.spec.ts
index 54309ea8985..de9f7c0fdd7 100644
--- a/src/machines/sketchSolve/tools/moveTool/moveTool.spec.ts
+++ b/src/machines/sketchSolve/tools/moveTool/moveTool.spec.ts
@@ -76,6 +76,7 @@ function createConstraintApiObject({
| 'Vertical'
| 'Radius'
| 'Diameter'
+ | 'Angle'
line?: number
arc?: number
points?: Array
@@ -122,6 +123,17 @@ function createConstraintApiObject({
is_literal: true,
},
}
+ } else if (type === 'Angle') {
+ constraint = {
+ type,
+ lines: [3, 4],
+ angle: { value: 60, units: 'Deg' },
+ ...(labelPosition ? { labelPosition } : {}),
+ source: {
+ expr: '60',
+ is_literal: true,
+ },
+ }
} else if (points) {
constraint = {
type,
@@ -165,6 +177,7 @@ function createDragSnappingDeps() {
getLastGoodPreview: vi.fn(() => null),
setLastGoodPreview: vi.fn(),
getDragStartOutcome: vi.fn(() => null),
+ onClearDragSnapping: vi.fn(),
}
}
@@ -486,6 +499,8 @@ describe('createOnDragStartCallback', () => {
}))
const getCurrentCommittedCheckpointId = vi.fn(() => 12)
const dismissConstraintHoverPopup = vi.fn()
+ const getDraggedEntityId = vi.fn(() => null)
+ const onUpdateHoveredId = vi.fn()
const callback = createOnDragStartCallback({
setLastSuccessfulDragFromPoint,
@@ -496,6 +511,8 @@ describe('createOnDragStartCallback', () => {
getCurrentSketchOutcome,
getCurrentCommittedCheckpointId,
dismissConstraintHoverPopup,
+ getDraggedEntityId,
+ onUpdateHoveredId,
})
const intersectionPoint = {
@@ -783,6 +800,7 @@ function createSceneGraphDelta(objects: Array): SceneGraphDelta {
exec_outcome: {
issues: [],
variables: {},
+ refactorMetadata: [],
operations: emptyOperationsByModule(),
artifactGraph: { map: {}, itemCount: 0 },
filenames: {},
@@ -798,6 +816,7 @@ describe('createOnDragCallback', () => {
'VerticalDistance',
'Radius',
'Diameter',
+ 'Angle',
] as const)(
'should edit a dragged %s constraint label instead of editing segments',
async (constraintType) => {
@@ -3657,6 +3676,9 @@ describe('createOnClickCallback', () => {
expect(onUpdateSelectedIds).toHaveBeenCalledWith({
selectedIds: [5],
duringAreaSelectIds: [],
+ selectionCoordinates: {
+ 5: [20, 0],
+ },
})
})
@@ -3691,6 +3713,9 @@ describe('createOnClickCallback', () => {
expect(onUpdateSelectedIds).toHaveBeenCalledWith({
selectedIds: [11],
duringAreaSelectIds: [],
+ selectionCoordinates: {
+ 11: [5, 10],
+ },
})
})
@@ -3878,98 +3903,112 @@ describe('createOnClickCallback', () => {
})
describe('setUpOnDragAndSelectionClickCallbacks constraint label dragging', () => {
- it('keeps dragging a radius label when drag start fires after the cursor leaves the label hit area', async () => {
- const radiusConstraint = createConstraintApiObject({
- id: 8,
- type: 'Radius',
- })
- const constraintGroup = new Group()
- constraintGroup.name = String(radiusConstraint.id)
- const labelChild = new Group()
- labelChild.userData.type = DISTANCE_CONSTRAINT_LABEL
- labelChild.userData.hitObjects = [
- {
- type: 'line',
- line: [
- [-8, -8],
- [8, -8],
- ],
- },
- {
- type: 'line',
- line: [
- [8, -8],
- [8, 8],
- ],
- },
- {
- type: 'line',
- line: [
- [8, 8],
- [-8, 8],
- ],
- },
- {
- type: 'line',
- line: [
- [-8, 8],
- [-8, -8],
- ],
- },
- ]
- constraintGroup.add(labelChild)
-
- const {
- onMouseDownSelection,
- setPlaneIntersectPoint,
- onDragStart,
- onDrag,
- rustContext,
- } = setUpMoveToolCallbacks({
- apiObjects: [radiusConstraint],
- hoveredId: radiusConstraint.id,
- getSceneObjectByName: (name) =>
- name === String(radiusConstraint.id) ? constraintGroup : null,
- })
-
- setPlaneIntersectPoint(new Vector2(0, 0))
- expect(onMouseDownSelection()).toBe(true)
+ it.each(['Radius', 'Angle'] as const)(
+ 'keeps dragging a %s label when drag start fires after the cursor leaves the label hit area',
+ async (constraintType) => {
+ const constraint = createConstraintApiObject({
+ id: 8,
+ type: constraintType,
+ })
+ const constraintGroup = new Group()
+ constraintGroup.name = String(constraint.id)
+ const labelChild = new Group()
+ labelChild.userData.type = DISTANCE_CONSTRAINT_LABEL
+ labelChild.userData.hitObjects = [
+ {
+ type: 'line',
+ line: [
+ [-8, -8],
+ [8, -8],
+ ],
+ },
+ {
+ type: 'line',
+ line: [
+ [8, -8],
+ [8, 8],
+ ],
+ },
+ {
+ type: 'line',
+ line: [
+ [8, 8],
+ [-8, 8],
+ ],
+ },
+ {
+ type: 'line',
+ line: [
+ [-8, 8],
+ [-8, -8],
+ ],
+ },
+ ]
+ constraintGroup.add(labelChild)
+
+ const {
+ onMouseDownSelection,
+ setPlaneIntersectPoint,
+ onDragStart,
+ onDrag,
+ rustContext,
+ send,
+ } = setUpMoveToolCallbacks({
+ apiObjects: [constraint],
+ hoveredId: constraint.id,
+ getSceneObjectByName: (name) =>
+ name === String(constraint.id) ? constraintGroup : null,
+ })
- setPlaneIntersectPoint(new Vector2(0, 30))
- onDragStart({
- intersectionPoint: {
- twoD: new Vector2(0, 30),
- threeD: new Vector3(0, 30, 0),
- },
- mouseEvent: createTestMouseEvent(),
- intersects: [],
- })
+ setPlaneIntersectPoint(new Vector2(0, 0))
+ expect(onMouseDownSelection()).toBe(true)
- await onDrag({
- intersectionPoint: {
- twoD: new Vector2(1, 31),
- threeD: new Vector3(1, 31, 0),
- },
- mouseEvent: createTestMouseEvent(),
- intersects: [],
- })
+ setPlaneIntersectPoint(new Vector2(0, 30))
+ onDragStart({
+ intersectionPoint: {
+ twoD: new Vector2(0, 30),
+ threeD: new Vector3(0, 30, 0),
+ },
+ mouseEvent: createTestMouseEvent(),
+ intersects: [],
+ })
- expect(
- rustContext.editDistanceConstraintLabelPosition
- ).toHaveBeenCalledWith(
- 0,
- 0,
- radiusConstraint.id,
- {
- x: { value: 1, units: 'Mm' },
- y: { value: 31, units: 'Mm' },
- },
- expect.any(Object),
- false,
- undefined,
- false
- )
- })
+ expect(send).toHaveBeenCalledWith({
+ type: 'update hovered id',
+ data: { hoveredId: constraint.id },
+ })
+
+ await onDrag({
+ intersectionPoint: {
+ twoD: new Vector2(1, 31),
+ threeD: new Vector3(1, 31, 0),
+ },
+ mouseEvent: createTestMouseEvent(),
+ intersects: [],
+ })
+
+ expect(
+ rustContext.editDistanceConstraintLabelPosition
+ ).toHaveBeenCalledWith(
+ 0,
+ 0,
+ constraint.id,
+ {
+ x: { value: 1, units: 'Mm' },
+ y: { value: 31, units: 'Mm' },
+ },
+ expect.any(Object),
+ false,
+ undefined,
+ false
+ )
+
+ expect(send).not.toHaveBeenCalledWith({
+ type: 'update hovered id',
+ data: { hoveredId: null },
+ })
+ }
+ )
})
describe('setUpOnDragAndSelectionClickCallbacks onMove', () => {
diff --git a/src/machines/sketchSolve/tools/moveTool/moveTool.ts b/src/machines/sketchSolve/tools/moveTool/moveTool.ts
index a0f20b22eb7..f4957c0e3d9 100644
--- a/src/machines/sketchSolve/tools/moveTool/moveTool.ts
+++ b/src/machines/sketchSolve/tools/moveTool/moveTool.ts
@@ -22,17 +22,21 @@ import { applyVectorToPoint2D } from '@src/lib/kclHelpers'
import { jsAppSettings } from '@src/lib/settings/settingsUtils'
import type { DeepPartial } from '@src/lib/types'
import { isArray, roundOff } from '@src/lib/utils'
-import { distance2d } from '@src/lib/utils2d'
+import { distance2d, polar2d } from '@src/lib/utils2d'
+import { calculateArcRenderInput } from '@src/machines/sketchSolve/constraints/AngleConstraintBuilder'
+import { getArcLabelOffset } from '@src/machines/sketchSolve/constraints/ArcDimensionLine'
import { isConstraintHoverPopup } from '@src/machines/sketchSolve/constraints/InvisibleConstraintSpriteBuilder'
import {
axisConstraintIncludesOrigin,
getAxisConstraintPointIds,
getCoincidentCluster,
+ isAngleConstraint,
isArcLikeSegment,
isConstraint,
isControlPointSplineSegment,
isDiameterConstraint,
isDistanceConstraint,
+ isLineSegment,
isOwnedLineSegment,
isPointSegment,
isRadiusConstraint,
@@ -52,6 +56,7 @@ import { getCurrentSketchObjectsById } from '@src/machines/sketchSolve/sceneGrap
import { toastSketchSolveError } from '@src/machines/sketchSolve/sketchSolveErrors'
import {
ORIGIN_TARGET,
+ type SelectionCoordinates,
type SketchSolveSelectionId,
type SolveActionArgs,
buildSegmentCtorFromObject,
@@ -360,7 +365,8 @@ function isConstraintWithDraggableLabel(obj: ApiObject | undefined) {
obj !== undefined &&
(isDistanceConstraint(obj) ||
isRadiusConstraint(obj) ||
- isDiameterConstraint(obj))
+ isDiameterConstraint(obj) ||
+ isAngleConstraint(obj))
)
}
@@ -404,6 +410,15 @@ function buildConstraintLabelEditsForMovedSegments({
})
}
+ if (isAngleConstraint(obj)) {
+ return buildAngleLabelEditsForMovedSegments({
+ obj,
+ objectsBeforeDrag,
+ objectsAfterDrag,
+ units,
+ })
+ }
+
return []
})
}
@@ -518,6 +533,76 @@ function buildCircularLabelEditsForMovedSegments({
]
}
+function buildAngleLabelEditsForMovedSegments({
+ obj,
+ objectsBeforeDrag,
+ objectsAfterDrag,
+ units,
+}: {
+ obj: ApiObject
+ objectsBeforeDrag: ApiObject[]
+ objectsAfterDrag: ApiObject[]
+ units: NumericSuffix
+}): ConstraintLabelEdit[] {
+ if (!isAngleConstraint(obj)) {
+ return []
+ }
+
+ const { labelPosition } = obj.kind.constraint
+ if (!labelPosition) {
+ return []
+ }
+
+ const afterObj = objectsAfterDrag[obj.id]
+ if (!isAngleConstraint(afterObj)) {
+ return []
+ }
+
+ const beforeArc = calculateArcRenderInput(obj, objectsBeforeDrag, 1)
+ const afterArc = calculateArcRenderInput(afterObj, objectsAfterDrag, 1)
+ if (!beforeArc || !afterArc || beforeArc.labelAngle === undefined) {
+ return []
+ }
+
+ if (
+ !lineSegmentMoved(beforeArc.line1, afterArc.line1) &&
+ !lineSegmentMoved(beforeArc.line2, afterArc.line2)
+ ) {
+ return []
+ }
+
+ const labelRadius = new Vector2(
+ labelPosition.x.value,
+ labelPosition.y.value
+ ).distanceTo(new Vector2(beforeArc.center[0], beforeArc.center[1]))
+ const labelOffset = getArcLabelOffset(
+ beforeArc.startAngle,
+ beforeArc.sweepAngle,
+ beforeArc.labelAngle
+ )
+ const labelAngle = afterArc.startAngle + labelOffset
+ const transformedLabel = new Vector2(
+ ...polar2d(afterArc.center, labelRadius, labelAngle)
+ )
+
+ return [
+ {
+ constraintId: obj.id,
+ labelPosition: buildConstraintLabelPosition(transformedLabel, units),
+ },
+ ]
+}
+
+function lineSegmentMoved(
+ before: readonly [Coords2d, Coords2d],
+ after: readonly [Coords2d, Coords2d]
+) {
+ return (
+ distance2d(before[0], after[0]) > 1e-4 ||
+ distance2d(before[1], after[1]) > 1e-4
+ )
+}
+
function getDistanceConstraintPointPosition(
point: number | 'ORIGIN',
objects: ApiObject[]
@@ -881,6 +966,8 @@ type CreateOnDragStartCallbackArgs = {
getCurrentCommittedCheckpointId: () => number | null
// Clears transient hover UI that should not remain visible during drag.
dismissConstraintHoverPopup: () => void
+ getDraggedEntityId: () => number | null
+ onUpdateHoveredId: (hoveredId: number | null) => void
}
/**
@@ -899,6 +986,8 @@ export function createOnDragStartCallback({
getCurrentSketchOutcome,
getCurrentCommittedCheckpointId,
dismissConstraintHoverPopup,
+ getDraggedEntityId,
+ onUpdateHoveredId,
}: CreateOnDragStartCallbackArgs): (arg: {
intersectionPoint: { twoD: Vector2; threeD: Vector3 }
selected?: Object3D
@@ -908,9 +997,17 @@ export function createOnDragStartCallback({
return ({ intersectionPoint }) => {
dismissConstraintHoverPopup()
beginDragSession()
+ const currentSketchOutcome = getCurrentSketchOutcome()
+ const draggedConstraintLabelId = getConstraintLabelId(
+ getDraggedEntityId(),
+ currentSketchOutcome?.sceneGraphDelta
+ )
+ if (draggedConstraintLabelId !== null) {
+ onUpdateHoveredId(draggedConstraintLabelId)
+ }
setLastSuccessfulDragFromPoint(intersectionPoint.twoD.clone())
setLastGoodPreview(null)
- setDragStartOutcome(getCurrentSketchOutcome())
+ setDragStartOutcome(currentSketchOutcome)
setPreDragCheckpointId(getCurrentCommittedCheckpointId())
}
}
@@ -976,6 +1073,7 @@ export function createOnClickCallback({
selectedIds: Array
duringAreaSelectIds: Array
replaceExistingSelection?: boolean
+ selectionCoordinates?: SelectionCoordinates
}) => void
onEditConstraint: (constraintId: number) => void
}): (arg: {
@@ -1024,10 +1122,18 @@ export function createOnClickCallback({
sceneInfra
)
}
+ const selectionCoordinates =
+ closestSelection &&
+ typeof closestSelection.selectionId === 'number' &&
+ isLineSegment(selectedApiObject) &&
+ mousePosition
+ ? { [closestSelection.selectionId]: mousePosition }
+ : undefined
onUpdateSelectedIds({
selectedIds: closestSelection ? [closestSelection.selectionId] : [],
duringAreaSelectIds: [],
...(shouldReplaceSelection ? { replaceExistingSelection: true } : {}),
+ ...(selectionCoordinates ? { selectionCoordinates } : {}),
})
}
}
@@ -1224,6 +1330,7 @@ export function createOnDragCallback({
getDefaultLengthUnit,
getJsAppSettings,
sceneInfra,
+ onClearDragSnapping,
onUpdateDragSnapping,
onPreviewSolveStarted,
onPreviewSolveSettled,
@@ -1277,6 +1384,7 @@ export function createOnDragCallback({
getDefaultLengthUnit: () => UnitLength | undefined
getJsAppSettings: () => Promise>
sceneInfra: SceneInfra
+ onClearDragSnapping: () => void
onUpdateDragSnapping: (candidate: SnappingCandidate | null) => void
onPreviewSolveStarted?: () => void
onPreviewSolveSettled?: () => void
@@ -1342,7 +1450,7 @@ export function createOnDragCallback({
})
if (result && isActiveDragSession()) {
- onUpdateDragSnapping(null)
+ onClearDragSnapping()
onNewSketchOutcome({
...result,
writeToDisk: false,
@@ -1486,9 +1594,11 @@ export function createOnDragCallback({
if (result && isActiveDragSession()) {
if (!hasSketchSolveIssues(result.sceneGraphDelta)) {
let appliedConstraintLabelEdits: ConstraintLabelEdit[] = []
+ const labelEditBaselineObjects =
+ getDragStartOutcome()?.sceneGraphDelta.new_graph.objects ?? objects
const constraintLabelEdits =
buildConstraintLabelEditsForMovedSegments({
- objectsBeforeDrag: objects,
+ objectsBeforeDrag: labelEditBaselineObjects,
objectsAfterDrag: result.sceneGraphDelta.new_graph.objects,
units,
}).filter(({ constraintId }) => {
@@ -1496,7 +1606,7 @@ export function createOnDragCallback({
return true
}
- const constraint = objects[constraintId]
+ const constraint = labelEditBaselineObjects[constraintId]
return (
!isRadiusConstraint(constraint) &&
!isDiameterConstraint(constraint)
@@ -1839,6 +1949,8 @@ export function setUpOnDragAndSelectionClickCallbacks({
getCurrentCommittedCheckpointId: () =>
context.kclManager.currentSketchCheckpointId,
dismissConstraintHoverPopup: dismissConstraintHoverPopupOnDragStart,
+ getDraggedEntityId,
+ onUpdateHoveredId: sendHoveredState,
}),
onDragEnd: createOnDragEndCallback({
getDraggedEntityId,
@@ -2332,6 +2444,7 @@ export function setUpOnDragAndSelectionClickCallbacks({
getJsAppSettings: async () =>
jsAppSettings(context.rustContext.settingsActor),
sceneInfra: context.sceneInfra,
+ onClearDragSnapping: clearDragSnappingState,
onUpdateDragSnapping: updateDragSnappingState,
onPreviewSolveStarted: markPreviewSolveStarted,
onPreviewSolveSettled: markPreviewSolveSettled,
@@ -2391,6 +2504,7 @@ export function setUpOnDragAndSelectionClickCallbacks({
selectedIds: Array
duringAreaSelectIds: Array
replaceExistingSelection?: boolean
+ selectionCoordinates?: SelectionCoordinates
}) => self.send({ type: 'update selected ids', data }),
onEditConstraint: (constraintId: number) => {
self.send({
diff --git a/src/machines/sketchSolve/tools/sketchToolTestUtils.ts b/src/machines/sketchSolve/tools/sketchToolTestUtils.ts
index fcba3ea8e49..ab5aeaaea79 100644
--- a/src/machines/sketchSolve/tools/sketchToolTestUtils.ts
+++ b/src/machines/sketchSolve/tools/sketchToolTestUtils.ts
@@ -44,6 +44,7 @@ export function createSceneGraphDelta(
exec_outcome: {
issues: [],
variables: {},
+ refactorMetadata: [],
operations: emptyOperationsByModule(),
artifactGraph: { map: {}, itemCount: 0 },
filenames: {},
@@ -284,6 +285,7 @@ export function createMockRustContext(): RustContext {
addConstraint: vi.fn(),
chainSegment: vi.fn(),
editSegments: vi.fn(),
+ editAngleConstraint: vi.fn(),
editDistanceConstraintLabelPosition: vi.fn(),
deleteObjects: vi.fn(),
settingsActor: {
diff --git a/src/machines/sketchSolve/tools/threePointArcToolImpl.ts b/src/machines/sketchSolve/tools/threePointArcToolImpl.ts
index bdfade46fea..c7d43794381 100644
--- a/src/machines/sketchSolve/tools/threePointArcToolImpl.ts
+++ b/src/machines/sketchSolve/tools/threePointArcToolImpl.ts
@@ -10,8 +10,8 @@ import type { Coords2d } from '@src/lang/util'
import { baseUnitToNumericSuffix } from '@src/lang/wasm'
import type RustContext from '@src/lib/rustContext'
import { jsAppSettings } from '@src/lib/settings/settingsUtils'
-import { getAngleDiff, roundOff } from '@src/lib/utils'
-import { lerp2d, subVec } from '@src/lib/utils2d'
+import { roundOff } from '@src/lib/utils'
+import { getAngleDiff, lerp2d, subVec } from '@src/lib/utils2d'
import {
isArcSegment,
isPointSegment,