Compare commits

...

13 Commits

12 changed files with 1461 additions and 38 deletions

View File

@ -1105,6 +1105,270 @@ part002 = startSketchOn(XZ)
// checking the count of the overlays is a good proxy check that the client sketch scene is in a good state
await expect(page.getByTestId('segment-overlay')).toHaveCount(3)
})
test.describe('Sketch scaling on first constraint', () => {
test('Should scale entire sketch when constraining first dimension with scale checkbox enabled', async ({
page,
homePage,
scene,
cmdBar,
}) => {
await page.addInitScript(async () => {
localStorage.setItem(
'persistCode',
`sketch001 = startSketchOn(XZ)
profile001 = startProfile(sketch001, at = [100, 100])
|> line(end = [200, 0])
|> line(end = [0, 200])
|> line(end = [-200, 0])
|> close()
profile002 = startProfile(sketch001, at = [400, 400])
|> circle(center = [0, 0], radius = 50)`
)
})
const u = await getUtils(page)
await page.setBodyDimensions({ width: 1200, height: 500 })
await homePage.goToModelingScene()
await scene.settled(cmdBar)
// Click on the first line segment to select it
await page.getByText('line(end = [200, 0])').click()
await page.getByRole('button', { name: 'Edit Sketch' }).click()
// Wait for overlays to populate
await page.waitForTimeout(1000)
// Click on the first line segment in the sketch
const line1 = await u.getSegmentBodyCoords(`[data-overlay-index="1"]`)
await page.mouse.click(line1.x, line1.y)
await page.waitForTimeout(100)
// Open constraints menu and click length constraint
await page
.getByRole('button', {
name: 'constraints: open menu',
})
.click()
await page.getByTestId('constraint-length').click()
// Verify the scale sketch checkbox is present and enabled
const scaleCheckbox = page.getByTestId('scale-sketch-checkbox')
await expect(scaleCheckbox).toBeVisible()
await expect(scaleCheckbox).toBeEnabled()
await expect(scaleCheckbox).toBeChecked() // Should be checked by default since no constraints exist
// Enter new value (100, which is half of original 200)
await page
.getByTestId('cmd-bar-arg-value')
.getByRole('textbox')
.fill('100')
// Ensure scale checkbox is still checked
await expect(scaleCheckbox).toBeChecked()
// Submit the constraint
await page
.getByRole('button', {
name: 'arrow right Continue',
})
.click()
// Wait for the changes to be applied
await page.waitForTimeout(1000)
// Verify the constraint was applied with a variable
await expect(page.locator('.cm-content')).toContainText('length001 = 100')
await expect(page.locator('.cm-content')).toContainText(
'line(end = [length001, 0])'
)
// Verify scaling occurred - all dimensions should be scaled by 0.5 (100/200)
// Original: line(end = [0, 200]) -> Scaled: line(end = [0, 100])
await expect(page.locator('.cm-content')).toContainText(
'line(end = [0, 100])'
)
// Original: line(end = [-200, 0]) -> Scaled: line(end = [-100, 0])
await expect(page.locator('.cm-content')).toContainText(
'line(end = [-100, 0])'
)
// Original: startProfile(at = [100, 100]) -> Scaled: startProfile(at = [50, 50])
await expect(page.locator('.cm-content')).toContainText(
'startProfile(sketch001, at = [50, 50])'
)
// Original: startProfile(at = [400, 400]) -> Scaled: startProfile(at = [200, 200])
await expect(page.locator('.cm-content')).toContainText(
'startProfile(sketch001, at = [200, 200])'
)
// Original: radius = 50 -> Scaled: radius = 25
await expect(page.locator('.cm-content')).toContainText('radius = 25')
})
test('Should not scale sketch when constraining with scale checkbox disabled', async ({
page,
homePage,
scene,
cmdBar,
}) => {
await page.addInitScript(async () => {
localStorage.setItem(
'persistCode',
`sketch001 = startSketchOn(XZ)
profile001 = startProfile(sketch001, at = [100, 100])
|> line(end = [200, 0])
|> line(end = [0, 200])
|> close()`
)
})
const u = await getUtils(page)
await page.setBodyDimensions({ width: 1200, height: 500 })
await homePage.goToModelingScene()
await scene.settled(cmdBar)
// Click on the first line segment to select it
await page.getByText('line(end = [200, 0])').click()
await page.getByRole('button', { name: 'Edit Sketch' }).click()
// Wait for overlays to populate
await page.waitForTimeout(1000)
// Click on the first line segment in the sketch
const line1 = await u.getSegmentBodyCoords(`[data-overlay-index="1"]`)
await page.mouse.click(line1.x, line1.y)
await page.waitForTimeout(100)
// Open constraints menu and click length constraint
await page
.getByRole('button', {
name: 'constraints: open menu',
})
.click()
await page.getByTestId('constraint-length').click()
// Verify the scale sketch checkbox is present and enabled
const scaleCheckbox = page.getByTestId('scale-sketch-checkbox')
await expect(scaleCheckbox).toBeVisible()
await expect(scaleCheckbox).toBeEnabled()
await expect(scaleCheckbox).toBeChecked()
// Uncheck the scale checkbox
await scaleCheckbox.click()
await expect(scaleCheckbox).not.toBeChecked()
// Enter new value (100, which is half of original 200)
await page
.getByTestId('cmd-bar-arg-value')
.getByRole('textbox')
.fill('100')
// Submit the constraint
await page
.getByRole('button', {
name: 'arrow right Continue',
})
.click()
// Wait for the changes to be applied
await page.waitForTimeout(1000)
// Verify the constraint was applied with a variable
await expect(page.locator('.cm-content')).toContainText('length001 = 100')
await expect(page.locator('.cm-content')).toContainText(
'line(end = [length001, 0])'
)
// Verify NO scaling occurred - other dimensions should remain unchanged
await expect(page.locator('.cm-content')).toContainText(
'line(end = [0, 200])'
) // Should remain 200, not 100
await expect(page.locator('.cm-content')).toContainText(
'startProfile(sketch001, at = [100, 100])'
) // Should remain [100, 100]
})
test('Should disable scale checkbox when sketch already has constraints', async ({
page,
homePage,
scene,
cmdBar,
}) => {
await page.addInitScript(async () => {
localStorage.setItem(
'persistCode',
`length_var = 150
sketch001 = startSketchOn(XZ)
profile001 = startProfile(sketch001, at = [100, 100])
|> line(end = [length_var, 0])
|> line(end = [0, 200])
|> close()`
)
})
const u = await getUtils(page)
await page.setBodyDimensions({ width: 1200, height: 500 })
await homePage.goToModelingScene()
await scene.settled(cmdBar)
// Click on the second line segment (the one without constraints)
await page.getByText('line(end = [0, 200])').click()
await page.getByRole('button', { name: 'Edit Sketch' }).click()
// Wait for overlays to populate
await page.waitForTimeout(1000)
// Click on the second line segment in the sketch
const line2 = await u.getSegmentBodyCoords(`[data-overlay-index="2"]`)
await page.mouse.click(line2.x, line2.y)
await page.waitForTimeout(100)
// Open constraints menu and click length constraint
await page
.getByRole('button', {
name: 'constraints: open menu',
})
.click()
await page.getByTestId('constraint-length').click()
// Verify the scale sketch checkbox is present but disabled/unchecked
// because the sketch already has constraints (length_var)
const scaleCheckbox = page.getByTestId('scale-sketch-checkbox')
await expect(scaleCheckbox).toBeVisible()
await expect(scaleCheckbox).not.toBeChecked() // Should be unchecked because constraints exist
// Enter new value
await page
.getByTestId('cmd-bar-arg-value')
.getByRole('textbox')
.fill('100')
// Submit the constraint
await page
.getByRole('button', {
name: 'arrow right Continue',
})
.click()
// Wait for the changes to be applied
await page.waitForTimeout(1000)
// Verify the constraint was applied
await expect(page.locator('.cm-content')).toContainText('length002 = 100')
// Verify existing constraint and values remain unchanged
await expect(page.locator('.cm-content')).toContainText(
'length_var = 150'
)
await expect(page.locator('.cm-content')).toContainText(
'line(end = [length_var, 0])'
)
})
})
})
test.describe('Electron constraint tests', () => {
test(

View File

@ -1026,7 +1026,7 @@ export class SceneEntities {
const sketch = sketchFromPathToNode({
pathToNode: sketchEntryNodePath,
variables: this.kclManager.variables,
kclManager: this.kclManager,
ast: this.kclManager.ast,
})
if (err(sketch)) return Promise.reject(sketch)
if (!sketch) return Promise.reject(new Error('No sketch found'))
@ -1171,12 +1171,64 @@ export class SceneEntities {
codeManager: this.codeManager,
})
// Check if we need to update sketchNodePaths due to CallExpression -> PipeExpression transformation
let updatedSketchNodePaths = [...sketchNodePaths]
let updatedSketchEntryNodePath = sketchEntryNodePath
let needsSelectionUpdate = false
// Check if the sketch entry was converted from CallExpression to PipeExpression
const _nodeAfter = getNodeFromPath<VariableDeclaration>(
modifiedAst,
sketchEntryNodePath,
'VariableDeclaration'
)
if (!err(_nodeAfter)) {
const initAfter = _nodeAfter.node?.declaration?.init
if (initAfter?.type === 'PipeExpression') {
const INIT_PATH_STEP_INDEX = 3
const pathNeedingUpdatingIndex = sketchNodePaths.findIndex(
(pathToNode) =>
pathToNode.length === INIT_PATH_STEP_INDEX + 1 &&
pathToNode.every(
(step, index) =>
step?.[0] === sketchEntryNodePath?.[index]?.[0]
)
)
if (pathNeedingUpdatingIndex !== -1) {
const updatedPath: PathToNode = [
...sketchNodePaths[pathNeedingUpdatingIndex],
['body', 'PipeExpression'],
[0, 'CallExpressionKw'],
]
updatedSketchNodePaths[pathNeedingUpdatingIndex] = updatedPath
updatedSketchEntryNodePath = updatedPath
needsSelectionUpdate = true
}
}
}
if (intersectsProfileStart) {
this.sceneInfra.modelingSend({ type: 'Close sketch' })
} else {
// Send selection update if paths were modified
if (needsSelectionUpdate) {
this.sceneInfra.modelingSend({
type: 'Set selection',
data: {
selectionType: 'completeSelection',
selection: {
graphSelections: [],
otherSelections: [],
},
updatedSketchEntryNodePath,
updatedSketchNodePaths,
},
})
}
await this.setupDraftSegment(
sketchEntryNodePath,
sketchNodePaths,
updatedSketchNodePaths,
planeNodePath,
forward,
up,
@ -2530,7 +2582,7 @@ export class SceneEntities {
const sketch = sketchFromPathToNode({
pathToNode,
variables: this.kclManager.variables,
kclManager: this.kclManager,
ast: this.kclManager.ast,
})
if (trap(sketch)) return
if (!sketch) {
@ -3784,14 +3836,14 @@ function prepareTruncatedAst(
function sketchFromPathToNode({
pathToNode,
variables,
kclManager,
ast,
}: {
pathToNode: PathToNode
variables: VariableMap
kclManager: KclManager
ast: Node<Program>
}): Sketch | null | Error {
const _varDec = getNodeFromPath<VariableDeclarator>(
kclManager.ast,
ast,
pathToNode,
'VariableDeclarator'
)
@ -3808,6 +3860,141 @@ function sketchFromPathToNode({
return sg
}
export function scaleProfiles({
ast,
pathsToProfile,
factor,
variables,
}: {
ast: Node<Program>
pathsToProfile: PathToNode[]
factor: number
variables: VariableMap
}) {
let clonedAst = structuredClone(ast)
for (const pathToProfile of pathsToProfile) {
const profile = sketchFromPathToNode({
pathToNode: pathToProfile,
variables: variables,
ast,
})
if (err(profile)) return profile
if (!profile) return Error('Profile not found')
// Scale the startProfile 'at' parameter
const scaledStartAt: [number, number] = [
profile.start.from[0] * factor,
profile.start.from[1] * factor,
]
if (
profile.paths?.[0]?.type !== 'Circle' &&
profile.paths?.[0]?.type !== 'CircleThreePoint'
) {
const startProfileResult = changeSketchArguments(
clonedAst,
variables,
{
type: 'path',
pathToNode: pathToProfile,
},
{
type: 'straight-segment',
from: [0, 0], // not used for startProfile
to: scaledStartAt,
}
)
if (trap(startProfileResult)) return startProfileResult
clonedAst = startProfileResult.modifiedAst
}
// Scale the path segments
for (let pathIndex = 0; pathIndex < profile.paths.length; pathIndex++) {
const path = profile.paths[pathIndex]
let input: Parameters<typeof changeSketchArguments>[3] | undefined =
undefined
const pathToSegment = getNodePathFromSourceRange(
clonedAst,
sourceRangeFromRust(path.__geoMeta.sourceRange)
)
if (!pathToSegment) {
console.log(
'Could not find path for segment:',
path.type,
'sourceRange:',
path.__geoMeta.sourceRange
)
}
const scaleTuple = (tuple: [number, number]): [number, number] => [
tuple[0] * factor,
tuple[1] * factor,
]
const previous = profile.paths[pathIndex - 1]
if (
path.type === 'ToPoint' ||
path.type === 'TangentialArcTo' ||
path.type === 'TangentialArc'
) {
input = {
type: 'straight-segment',
from: scaleTuple(path.from),
to: scaleTuple(path.to),
previousEndTangent: previous
? findTangentDirectionPath(previous)
: undefined,
}
} else if (
path.type === 'ArcThreePoint' ||
path.type === 'CircleThreePoint'
) {
input = {
type: 'circle-three-point-segment',
p1: scaleTuple(path.p1),
p2: scaleTuple(path.p2),
p3: scaleTuple(path.p3),
}
} else if (path.type === 'Circle') {
input = {
type: 'arc-segment',
from: scaleTuple(path.from),
to: scaleTuple(path.to),
center: scaleTuple(path.center),
radius: path.radius * factor,
ccw: path.ccw,
}
} else {
// Skip close calls - they don't have dimensions to scale
console.log('Unhandled path type:', path.type)
continue
}
if (input && pathToSegment) {
try {
const changeSketchResult = changeSketchArguments(
clonedAst,
variables,
{
type: 'path',
pathToNode: pathToSegment,
},
input
)
if (!err(changeSketchResult)) {
clonedAst = changeSketchResult.modifiedAst
}
} catch (error) {
console.log('Error scaling segment:', path.type, 'error:', error)
}
}
}
}
const pResult = parse(recast(clonedAst))
if (trap(pResult) || !resultIsOk(pResult)) {
return Error('Unexpected compilation error')
}
return {
modifiedAst: pResult.program,
}
}
function colorSegment(object: any, color: number) {
const segmentHead = getParentGroup(object, [ARROWHEAD, PROFILE_START])
if (segmentHead) {
@ -3837,7 +4024,7 @@ export function getSketchQuaternion(
const sketch = sketchFromPathToNode({
pathToNode: sketchPathToNode,
variables: kclManager.variables,
kclManager,
ast: kclManager.ast,
})
if (err(sketch)) return sketch
const zAxis =
@ -3895,7 +4082,7 @@ function getSketchesInfo({
const sketch = sketchFromPathToNode({
pathToNode: path,
variables,
kclManager,
ast: kclManager.ast,
})
if (err(sketch)) continue
if (!sketch) continue
@ -3974,3 +4161,44 @@ function findTangentDirection(segmentGroup: Group) {
}
return tangentDirection
}
// implements the same as, but for a Path instead of segment Group
function findTangentDirectionPath(path: Path): Coords2d | undefined {
let tangentDirection: Coords2d | undefined
if (path.type === 'TangentialArcTo' || path.type === 'TangentialArc') {
// For tangential arcs with center, calculate tangent at the end point
const tangentAngle =
deg2Rad(getAngle(path.center, path.to)) +
(Math.PI / 2) * (path.ccw ? 1 : -1)
tangentDirection = [Math.cos(tangentAngle), Math.sin(tangentAngle)]
} else if (path.type === 'Arc') {
// For regular arcs, calculate tangent at the end point
const tangentAngle =
deg2Rad(getAngle(path.center, path.to)) +
(Math.PI / 2) * (path.ccw ? 1 : -1)
tangentDirection = [Math.cos(tangentAngle), Math.sin(tangentAngle)]
} else if (path.type === 'ArcThreePoint') {
// For three-point arcs, we need to calculate the center first
// This is more complex, so for now we'll skip tangent calculation
console.warn(
'ArcThreePoint tangent direction calculation not implemented yet'
)
} else if (path.type === 'ToPoint') {
// For straight lines, the tangent is the direction from start to end
const to = path.to as Coords2d
const from = path.from as Coords2d
tangentDirection = subVec(to, from)
const normalized = normalizeVec(tangentDirection)
if (normalized) {
tangentDirection = normalized
}
} else {
console.warn(
'Unsupported path type for tangent direction calculation: ',
path.type
)
}
return tangentDirection
}

View File

@ -31,6 +31,8 @@ import { roundOff, roundOffWithUnits } from '@src/lib/utils'
import { varMentions } from '@src/lib/varCompletionExtension'
import { useSettings } from '@src/lib/singletons'
import { commandBarActor, useCommandBarState } from '@src/lib/singletons'
import { doesProfileHaveAnyConstrainedDimension } from '@src/lang/queryAst'
import type { PathToNode } from '@src/lang/wasm'
import styles from './CommandBarKclInput.module.css'
@ -50,6 +52,7 @@ function CommandBarKclInput({
stepBack: () => void
onSubmit: (event: unknown) => void
}) {
// console.log('arg', arg)
const commandBarState = useCommandBarState()
const previouslySetValue = commandBarState.context.argumentsToSubmit[
arg.name
@ -109,6 +112,32 @@ function CommandBarKclInput({
arg.createVariable === 'force' ||
false
)
// Check if this is the "Constrain with named value" command
const isConstrainWithNamedValueCommand =
commandBarState.context.selectedCommand?.name ===
'Constrain with named value'
const sketchDetails = argMachineContext?.sketchDetails
// Checkbox should be enabled (clickable) when it's the right command
const shouldEnableScaleCheckbox = isConstrainWithNamedValueCommand
// Checkbox should be checked by default when ALL profiles have no constrained dimensions
const shouldCheckScaleByDefault = useMemo(() => {
if (!isConstrainWithNamedValueCommand || !sketchDetails?.sketchNodePaths) {
return false
}
// Check if ALL profiles have no constrained dimensions (meaning we can safely scale)
return sketchDetails.sketchNodePaths.every((pathToProfile: PathToNode) => {
const hasConstrainedDimension = doesProfileHaveAnyConstrainedDimension(
pathToProfile,
kclManager.ast
)
return !hasConstrainedDimension // We want profiles with NO constrained dimensions
})
}, [isConstrainWithNamedValueCommand, sketchDetails, kclManager.ast])
const [scaleSketch, setScaleSketch] = useState(shouldCheckScaleByDefault)
const [canSubmit, setCanSubmit] = useState(true)
useHotkeys('mod + k, mod + /', () => commandBarActor.send({ type: 'Close' }))
const editorRef = useRef<HTMLDivElement>(null)
@ -225,6 +254,23 @@ function CommandBarKclInput({
)
}, [calcResult, createNewVariable, isNewVariableNameUnique, isExecuting])
// Update scale checkbox when the condition changes
useEffect(() => {
setScaleSketch(shouldCheckScaleByDefault)
}, [shouldCheckScaleByDefault])
// Store scaleSketch value in command bar context for "Constrain with named value" command
useEffect(() => {
if (isConstrainWithNamedValueCommand) {
commandBarActor.send({
type: 'Set additional data',
data: {
scaleSketch,
},
})
}
}, [scaleSketch, isConstrainWithNamedValueCommand])
function handleSubmit(e?: React.FormEvent<HTMLFormElement>) {
e?.preventDefault()
if (!canSubmit || valueNode === null) {
@ -237,26 +283,26 @@ function CommandBarKclInput({
return
}
onSubmit(
createNewVariable
? ({
valueAst: valueNode,
valueText: value,
valueCalculated: calcResult,
variableName: newVariableName,
insertIndex: newVariableInsertIndex,
variableIdentifierAst: createLocalName(newVariableName),
variableDeclarationAst: createVariableDeclaration(
newVariableName,
valueNode
),
} satisfies KclCommandValue)
: ({
valueAst: valueNode,
valueText: value,
valueCalculated: calcResult,
} satisfies KclCommandValue)
)
const commandValue = createNewVariable
? ({
valueAst: valueNode,
valueText: value,
valueCalculated: calcResult,
variableName: newVariableName,
insertIndex: newVariableInsertIndex,
variableIdentifierAst: createLocalName(newVariableName),
variableDeclarationAst: createVariableDeclaration(
newVariableName,
valueNode
),
} satisfies KclCommandValue)
: ({
valueAst: valueNode,
valueText: value,
valueCalculated: calcResult,
} satisfies KclCommandValue)
onSubmit(commandValue)
}
return (
@ -359,6 +405,34 @@ function CommandBarKclInput({
)}
</div>
)}
{isConstrainWithNamedValueCommand && (
<div className="flex items-baseline gap-4 mx-4">
<input
type="checkbox"
id="scale-sketch-checkbox"
data-testid="scale-sketch-checkbox"
checked={scaleSketch}
disabled={!shouldEnableScaleCheckbox}
onChange={(e) => {
setScaleSketch(e.target.checked)
}}
className="bg-chalkboard-10 dark:bg-chalkboard-80"
/>
<label
htmlFor="scale-sketch-checkbox"
className={`text-blue border-none bg-transparent font-sm flex gap-1 items-center pl-0 pr-1 ${
!shouldEnableScaleCheckbox ? 'opacity-50 cursor-not-allowed' : ''
}`}
>
Scale sketch
</label>
{!shouldEnableScaleCheckbox && (
<span className="text-sm text-chalkboard-60 dark:text-chalkboard-50">
(disabled - sketch has constrained dimensions)
</span>
)}
</div>
)}
</form>
)
}

View File

@ -18,3 +18,7 @@ export const ARG_INTERIOR_ABSOLUTE = 'interiorAbsolute'
export const ARG_AT = 'at'
export const ARG_LEG = 'leg'
export const ARG_HYPOTENUSE = 'hypotenuse'
export const ARG_P1 = 'p1'
export const ARG_P2 = 'p2'
export const ARG_P3 = 'p3'
export const ARG_DIAMETER = 'diameter'

View File

@ -25,12 +25,13 @@ import type { Artifact } from '@src/lang/std/artifactGraph'
import { codeRefFromRange } from '@src/lang/std/artifactGraph'
import { topLevelRange } from '@src/lang/util'
import type { Identifier, Literal, LiteralValue } from '@src/lang/wasm'
import { assertParse, recast } from '@src/lang/wasm'
import { assertParse, recast, parse, resultIsOk } from '@src/lang/wasm'
import { initPromise } from '@src/lang/wasmUtils'
import { enginelessExecutor } from '@src/lib/testHelpers'
import { err } from '@src/lib/trap'
import { deleteFromSelection } from '@src/lang/modifyAst/deleteFromSelection'
import { assertNotErr } from '@src/unitTestUtils'
import { scaleProfiles } from '@src/clientSideScene/sceneEntities'
beforeAll(async () => {
await initPromise
@ -917,3 +918,223 @@ extrude001 = extrude(part001, length = 5)
expect(result instanceof Error).toBe(true)
})
})
describe('testing sketch scaling', () => {
it('can scale sketch by half simple case', async () => {
const basicSketch = `sketch002 = startSketchOn(XZ)
profile006 = startProfile(sketch002, at = [114.64, 124.18])
|> line(end = [173.02, 306.77])
|> line(end = [175.14, -282.35])
|> tangentialArc(end = [-52.01, -194.25])`
const ast = assertParse(basicSketch)
const result = await enginelessExecutor(ast)
const searchSnippet = 'startProfile(sketch002, at = [114.64, 124.18])'
const startIndex = basicSketch.indexOf(searchSnippet)
const range = topLevelRange(startIndex, startIndex + searchSnippet.length)
const pathToProfile = getNodePathFromSourceRange(ast, range)
if (err(result)) throw result
const scaledProfile = scaleProfiles({
ast,
factor: 0.5,
variables: result.variables,
pathsToProfile: [pathToProfile],
})
if (err(scaledProfile)) throw scaledProfile
const modifiedAst = scaledProfile.modifiedAst
const newCode = recast(modifiedAst)
if (err(newCode)) throw newCode
expect(newCode).toBe(`sketch002 = startSketchOn(XZ)
profile006 = startProfile(sketch002, at = [57.32, 62.09])
|> line(end = [86.51, 153.39])
|> line(end = [87.57, -141.18])
|> tangentialArc(end = [-26, -97.12])
`)
})
it('can scale sketch more complex', async () => {
let code = `sketch001 = startSketchOn(YZ)
profile001 = startProfile(sketch001, at = [100, 101])
|> line(end = [102, 103])
|> line(endAbsolute = [104, 105])
|> angledLine(angle = 206, length = 106)
|> angledLine(angle = -208, lengthX = 107)
|> angledLine(angle = 210, lengthY = 108)
|> angledLine(angle = 212, endAbsoluteX = 109)
|> angledLine(angle = 214, endAbsoluteY = 110)
|> arc(interiorAbsolute = [111, 112], endAbsolute = [113, 114])
|> tangentialArc(end = [115, -116])
|> tangentialArc(endAbsolute = [117, 118])
|> tangentialArc(angle = 224, radius = 119)
|> tangentialArc(angle = 226, diameter = 120)
profile002 = startProfile(sketch001, at = [-121, 122])
|> angledLine(angle = 130, length = 123, tag = $rectangleSegmentA001)
|> angledLine(angle = segAng(rectangleSegmentA001) - 232, length = 124)
|> angledLine(angle = segAng(rectangleSegmentA001), length = -segLen(rectangleSegmentA001))
|> line(endAbsolute = [profileStartX(%), profileStartY(%)])
|> close()
profile003 = circle(sketch001, center = [-125, -126], radius = 127)
profile004 = circleThreePoint(
sketch001,
p1 = [128, 129],
p2 = [130, 131],
p3 = [132, 133],
)
profile005 = circle(sketch001, center = [-134, -135], diameter = 136)`
let ast = assertParse(code)
const result = await enginelessExecutor(ast)
const searchSnippets = [
'startProfile(sketch001, at = [100, 101])',
'startProfile(sketch001, at = [-121, 122])',
'circle(sketch001, center = [-125, -126], radius = 127)',
'circleThreePoint(',
'circle(sketch001, center = [-134, -135], diameter = 136)',
]
const ranges = searchSnippets.map((searchSnippet) => {
const startIndex = code.indexOf(searchSnippet)
return topLevelRange(startIndex, startIndex + searchSnippet.length)
})
const pathsToProfiles = ranges.map((range) =>
getNodePathFromSourceRange(ast, range)
)
if (err(result)) throw result
const scaledProfile = scaleProfiles({
ast,
factor: 0.5,
variables: result.variables,
pathsToProfile: pathsToProfiles,
})
if (err(scaledProfile)) throw scaledProfile
const pResult = parse(recast(scaledProfile.modifiedAst))
if (err(pResult) || !resultIsOk(pResult)) return
ast = pResult.program
const newCode = recast(ast)
if (err(newCode)) throw newCode
expect(newCode).toBe(`sketch001 = startSketchOn(YZ)
profile001 = startProfile(sketch001, at = [50, 50.5])
|> line(end = [51, 51.5])
|> line(endAbsolute = [52, 52.5])
|> angledLine(angle = -154, length = 53)
|> angledLine(angle = 152, lengthX = 53.5)
|> angledLine(angle = -150, lengthY = 54)
|> angledLine(angle = 32, endAbsoluteX = 54.5)
|> angledLine(angle = -146, endAbsoluteY = 55)
|> arc(interiorAbsolute = [55.5, 56], endAbsolute = [56.5, 57])
|> tangentialArc(end = [57.5, -58])
|> tangentialArc(endAbsolute = [58.5, 59])
|> tangentialArc(angle = 224deg, radius = 59.5)
|> tangentialArc(angle = 226deg, diameter = 60)
profile002 = startProfile(sketch001, at = [-60.5, 61])
|> angledLine(angle = 130, length = 61.5, tag = $rectangleSegmentA001)
|> angledLine(angle = segAng(rectangleSegmentA001) - 232, length = 62)
|> angledLine(angle = segAng(rectangleSegmentA001), length = -segLen(rectangleSegmentA001))
|> line(endAbsolute = [profileStartX(%), profileStartY(%)])
|> close()
profile003 = circle(sketch001, center = [-62.5, -63], radius = 63.5)
profile004 = circleThreePoint(
sketch001,
p1 = [64, 64.5],
p2 = [65, 65.5],
p3 = [66, 66.5],
)
profile005 = circle(sketch001, center = [-67, -67.5], diameter = 68)
`)
})
it("make sure it doesn't stomp constraints", async () => {
let code = `sketch001 = startSketchOn(YZ)
profile001 = startProfile(sketch001, at = [100 + 0, 101])
|> line(end = [102, 103 + 0])
|> line(endAbsolute = [104 + 0, 105])
|> angledLine(angle = 206, length = 106 + 0)
|> angledLine(angle = -208 + 0, lengthX = 107)
|> angledLine(angle = 210, lengthY = 108 + 0)
|> angledLine(angle = 212 + 0, endAbsoluteX = 109)
|> angledLine(angle = 214, endAbsoluteY = 110 + 0)
|> arc(interiorAbsolute = [111 + 0, 112], endAbsolute = [113, 114 + 0])
|> tangentialArc(end = [115, -116 + 0])
|> tangentialArc(endAbsolute = [117 + 0, 118])
|> tangentialArc(angle = 224, radius = 119 + 0)
|> tangentialArc(angle = 226 + 0, diameter = 120)
profile002 = startProfile(sketch001, at = [-121 + 0, 122])
|> angledLine(angle = 130, length = 123 + 0, tag = $rectangleSegmentA001)
|> angledLine(angle = segAng(rectangleSegmentA001) - 232, length = 124)
|> angledLine(angle = segAng(rectangleSegmentA001), length = -segLen(rectangleSegmentA001))
|> line(endAbsolute = [profileStartX(%), profileStartY(%)])
|> close()
profile003 = circle(sketch001, center = [-125 + 0, -126], radius = 127 + 0)
profile004 = circleThreePoint(
sketch001,
p1 = [128, 129 + 0],
p2 = [130 + 0, 131],
p3 = [133, 132],
)
profile005 = circle(sketch001, center = [-134, -135 + 0], diameter = 136)
`
let ast = assertParse(code)
const result = await enginelessExecutor(ast)
const searchSnippets = [
'startProfile(sketch001, at = [100 + 0, 101])',
'startProfile(sketch001, at = [-121 + 0, 122])',
'circle(sketch001, center = [-125 + 0, -126], radius = 127 + 0)',
'circleThreePoint(',
'circle(sketch001, center = [-134, -135 + 0], diameter = 136)',
]
const ranges = searchSnippets.map((searchSnippet) => {
const startIndex = code.indexOf(searchSnippet)
return topLevelRange(startIndex, startIndex + searchSnippet.length)
})
const pathsToProfiles = ranges.map((range) =>
getNodePathFromSourceRange(ast, range)
)
if (err(result)) throw result
const scaledProfile = scaleProfiles({
ast,
factor: 0.5,
variables: result.variables,
pathsToProfile: pathsToProfiles,
})
if (err(scaledProfile)) throw scaledProfile
const pResult = parse(recast(scaledProfile.modifiedAst))
if (err(pResult) || !resultIsOk(pResult)) return
ast = pResult.program
const newCode = recast(ast)
if (err(newCode)) throw newCode
expect(newCode).toBe(`sketch001 = startSketchOn(YZ)
profile001 = startProfile(sketch001, at = [100 + 0, 50.5])
|> line(end = [51, 103 + 0])
|> line(endAbsolute = [104 + 0, 52.5])
|> angledLine(angle = -154, length = 106 + 0)
|> angledLine(angle = -208 + 0, lengthX = 53.5)
|> angledLine(angle = -150, lengthY = 108 + 0)
|> angledLine(angle = 212 + 0, endAbsoluteX = 54.5)
|> angledLine(angle = -146, endAbsoluteY = 110 + 0)
|> arc(interiorAbsolute = [111 + 0, 56], endAbsolute = [56.5, 114 + 0])
|> tangentialArc(end = [57.5, -116 + 0])
|> tangentialArc(endAbsolute = [117 + 0, 59])
|> tangentialArc(angle = 224deg, radius = 119 + 0)
|> tangentialArc(angle = 226 + 0, diameter = 60)
profile002 = startProfile(sketch001, at = [-121 + 0, 61])
|> angledLine(angle = 130, length = 123 + 0, tag = $rectangleSegmentA001)
|> angledLine(angle = segAng(rectangleSegmentA001) - 232, length = 62)
|> angledLine(angle = segAng(rectangleSegmentA001), length = -segLen(rectangleSegmentA001))
|> line(endAbsolute = [profileStartX(%), profileStartY(%)])
|> close()
profile003 = circle(sketch001, center = [-125 + 0, -63], radius = 127 + 0)
profile004 = circleThreePoint(
sketch001,
p1 = [64, 129 + 0],
p2 = [130 + 0, 65.5],
p3 = [66.5, 66],
)
profile005 = circle(sketch001, center = [-67, -135 + 0], diameter = 68)
`)
})
})

View File

@ -10,6 +10,7 @@ import {
createPipeSubstitution,
} from '@src/lang/create'
import {
doesProfileHaveAnyConstrainedDimension,
doesSceneHaveExtrudedSketch,
doesSceneHaveSweepableSketch,
findAllPreviousVariables,
@ -35,6 +36,95 @@ beforeAll(async () => {
await initPromise
})
describe('doesProfileHaveConstrainDimension', () => {
const code = `sketch001 = startSketchOn(YZ)
profile001 = startProfile(sketch001, at = [100, 101])
|> line(end = [102, 103])
|> line(endAbsolute = [104, 105])
|> angledLine(angle = 206, length = 106)
|> angledLine(angle = -208, lengthX = 107)
|> angledLine(angle = 210, lengthY = 108)
|> angledLine(angle = 212, endAbsoluteX = 109)
|> angledLine(angle = 214, endAbsoluteY = 110)
|> arc(interiorAbsolute = [111, 112], endAbsolute = [113, 114])
|> tangentialArc(end = [115, -116])
|> tangentialArc(endAbsolute = [117, 118])
|> tangentialArc(angle = 224, radius = 119)
|> tangentialArc(angle = 226, diameter = 120)
profile002 = startProfile(sketch001, at = [-121, 122])
|> angledLine(angle = 130, length = 123, tag = $rectangleSegmentA001)
|> angledLine(angle = segAng(rectangleSegmentA001) - 232, length = 124)
|> angledLine(angle = segAng(rectangleSegmentA001), length = -segLen(rectangleSegmentA001))
|> line(endAbsolute = [profileStartX(%), profileStartY(%)])
|> close()
profile003 = circle(sketch001, center = [-125, -126], radius = 127)
profile004 = circleThreePoint(
sketch001,
p1 = [128, 129],
p2 = [130, 131],
p3 = [132, 133],
)
profile005 = circle(sketch001, center = [-134, -135], diameter = 136)`
const profileSearchStrings = [
{
profileSearchString: 'profile001 = startProfile',
replaceCases: { start: 100, end: 120 },
},
{
profileSearchString: 'profile002 = startProfile',
replaceCases: { start: 121, end: 124 },
},
{
profileSearchString: 'profile003 = circle',
replaceCases: { start: 125, end: 127 },
},
{
profileSearchString: 'profile004 = circleThreePoint',
replaceCases: { start: 128, end: 133 },
},
{
profileSearchString: 'profile005 = circle',
replaceCases: { start: 134, end: 136 },
},
] as const
it('should return false for all profiles (no constrained dimensions detected)', () => {
const ast = assertParse(code)
profileSearchStrings.forEach((profile) => {
const profileStart = code.indexOf(profile.profileSearchString)
const profilePath = getNodePathFromSourceRange(
ast,
topLevelRange(profileStart, profileStart)
)
expect(
doesProfileHaveAnyConstrainedDimension(profilePath, ast)
).toBeFalsy()
})
})
it('should true false when adding constraints for each Profile all profiles (no constrained dimensions detected)', () => {
profileSearchStrings.forEach((profile) => {
for (
let i = profile.replaceCases.start;
i <= profile.replaceCases.end;
i++
) {
const modifiedCode = code.replaceAll(String(i), `${i} + 5`)
const ast = assertParse(modifiedCode)
const profileStart = modifiedCode.indexOf(profile.profileSearchString)
const profilePath = getNodePathFromSourceRange(
ast,
topLevelRange(profileStart, profileStart)
)
expect(
doesProfileHaveAnyConstrainedDimension(profilePath, ast)
).toBeTruthy()
}
// })
})
})
})
describe('findAllPreviousVariables', () => {
it('should find all previous variables', async () => {
const code = `baseThick = 1

View File

@ -55,6 +55,23 @@ import type { OpKclValue, Operation } from '@rust/kcl-lib/bindings/Operation'
import { ARG_INDEX_FIELD, LABELED_ARG_FIELD } from '@src/lang/queryAstConstants'
import type { KclCommandValue } from '@src/lib/commandTypes'
import type { UnaryExpression } from 'typescript'
import {
ARG_END_ABSOLUTE,
ARG_END,
ARG_LENGTH,
ARG_CIRCLE_CENTER,
ARG_RADIUS,
ARG_LENGTH_X,
ARG_LENGTH_Y,
ARG_END_ABSOLUTE_X,
ARG_END_ABSOLUTE_Y,
ARG_INTERIOR_ABSOLUTE,
ARG_AT,
ARG_P1,
ARG_P2,
ARG_P3,
ARG_DIAMETER,
} from '@src/lang/constants'
import type { NumericType } from '@rust/kcl-lib/bindings/NumericType'
/**
@ -1293,3 +1310,146 @@ export const getPathNormalisedForTruncatedAst = (
Number(nodePathWithCorrectedIndexForTruncatedAst[1][0]) - minIndex
return nodePathWithCorrectedIndexForTruncatedAst
}
export function doesProfileHaveAnyConstrainedDimension(
profilePath: PathToNode,
ast: Node<Program>
): boolean {
// Get the profile node from the path
const profileNodeResult = getNodeFromPath<Node<VariableDeclaration>>(
ast,
profilePath,
'VariableDeclaration'
)
if (err(profileNodeResult)) return false
const profileNode = profileNodeResult.node
// Single value dimension parameters to check (excluding angle as per requirements)
const singleValueLengthParams = new Set([
ARG_DIAMETER,
ARG_RADIUS,
ARG_LENGTH,
ARG_LENGTH_X,
ARG_LENGTH_Y,
ARG_END_ABSOLUTE_X,
ARG_END_ABSOLUTE_Y,
])
// Tuple value dimension parameters to check
const tupleValueParams = new Set([
ARG_CIRCLE_CENTER,
ARG_P1,
ARG_P2,
ARG_P3,
ARG_AT,
ARG_END,
ARG_END_ABSOLUTE,
ARG_INTERIOR_ABSOLUTE,
])
let hasConstrainedDimension = false
// Traverse the profile node to find all call expressions and their arguments
traverse(profileNode, {
enter: (node: any) => {
if (node.type === 'CallExpressionKw' && node.arguments) {
for (const arg of node.arguments) {
if (arg.type === 'LabeledArg' && arg.label?.name) {
const paramName = arg.label.name
// Check if this parameter is in our whitelist
if (
singleValueLengthParams.has(paramName) ||
tupleValueParams.has(paramName)
) {
// Special case: endAbsolute = [profileStartX(%), profileStartY(%)]
// This should NOT count as constrained
if (
paramName === ARG_END_ABSOLUTE &&
arg.arg.type === 'ArrayExpression' &&
arg.arg.elements.length === 2
) {
const [first, second] = arg.arg.elements
if (
first.type === 'CallExpressionKw' &&
second.type === 'CallExpressionKw' &&
first.callee.type === 'Name' &&
second.callee.type === 'Name' &&
first.callee.name.name === 'profileStartX' &&
second.callee.name.name === 'profileStartY'
) {
// This is the special case - don't count as constrained
continue
}
}
// Special case: angledLine length = -segLen(rectangleSegmentA001)
// This should NOT count as constrained
if (
node.callee?.type === 'Name' &&
node.callee.name.name === 'angledLine' &&
paramName === ARG_LENGTH
) {
let callExpr = null
// Check if it's a direct call expression or unary expression with call expression
if (arg.arg.type === 'CallExpressionKw') {
callExpr = arg.arg
} else if (
arg.arg.type === 'UnaryExpression' &&
arg.arg.argument?.type === 'CallExpressionKw'
) {
callExpr = arg.arg.argument
}
if (
callExpr &&
callExpr.callee?.type === 'Name' &&
callExpr.callee.name.name === 'segLen' &&
callExpr.unlabeled?.type === 'Name' &&
callExpr.unlabeled.name.name.startsWith('rectangleSegmentA')
) {
// This is the special case - don't count as constrained
continue
}
}
// Check if the argument value is non-static
if (!isStaticValue(arg.arg)) {
hasConstrainedDimension = true
break
}
}
}
}
// If we found a constrained dimension, we can break out of the outer loop too
if (hasConstrainedDimension) {
return false // This stops the traversal
}
}
},
})
return hasConstrainedDimension
}
// Helper function to check if a node represents a static/constant value (literal, array of literals, or negative literal)
function isStaticValue(node: any): boolean {
if (node.type === 'Literal') {
return true
}
if (node.type === 'ArrayExpression') {
// Array is literal if all elements are literals
return node.elements.every((element: any) => isStaticValue(element))
}
if (node.type === 'UnaryExpression' && node.operator === '-') {
// Negative literal numbers
return isStaticValue(node.argument)
}
// All other node types (Name, CallExpression, BinaryExpression, etc.) are non-literal
return false
}

View File

@ -24,6 +24,7 @@ import {
ARG_TAG,
DETERMINING_ARGS,
ARG_INTERIOR_ABSOLUTE,
ARG_DIAMETER,
} from '@src/lang/constants'
import {
createArrayExpression,
@ -1424,13 +1425,27 @@ export const circle: SketchLineHelperKw = {
const { node: callExpression } = nodeMeta
// All function arguments, except the tag
const functionArguments = callExpression.arguments
.map((arg) => arg.label?.name)
.filter((n) => n && n !== ARG_TAG)
const newCenter = createArrayExpression([
createLiteral(roundOff(center[0])),
createLiteral(roundOff(center[1])),
])
mutateKwArg(ARG_CIRCLE_CENTER, callExpression, newCenter)
const newRadius = createLiteral(roundOff(radius))
mutateKwArg(ARG_RADIUS, callExpression, newRadius)
// Check if the circle uses diameter or radius
const isDiameter = functionArguments.includes(ARG_DIAMETER)
if (isDiameter) {
const newDiameter = createLiteral(roundOff(radius * 2))
mutateKwArg(ARG_DIAMETER, callExpression, newDiameter)
} else {
const newRadius = createLiteral(roundOff(radius))
mutateKwArg(ARG_RADIUS, callExpression, newRadius)
}
return {
modifiedAst: _node,
pathToNode,
@ -4168,7 +4183,14 @@ const tangentialArcHelpers = {
.map((arg) => arg.label?.name)
.filter((n) => n && n !== ARG_TAG)
if (areArraysEqual(functionArguments, [ARG_ANGLE, ARG_RADIUS])) {
const isDiameter = areArraysEqual(functionArguments, [
ARG_ANGLE,
ARG_DIAMETER,
])
if (
areArraysEqual(functionArguments, [ARG_ANGLE, ARG_RADIUS]) ||
isDiameter
) {
// Using length and radius -> convert "from", "to" to the matching length and radius
const previousEndTangent = input.previousEndTangent
if (previousEndTangent) {
@ -4219,11 +4241,19 @@ const tangentialArcHelpers = {
const radius = distance2d(center, from)
mutateKwArg(
ARG_RADIUS,
callExpression,
createLiteral(roundOff(radius, 2))
)
if (!isDiameter) {
mutateKwArg(
ARG_RADIUS,
callExpression,
createLiteral(roundOff(radius, 2))
)
} else {
mutateKwArg(
ARG_DIAMETER,
callExpression,
createLiteral(roundOff(radius * 2, 2))
)
}
const angleValue = createLiteralMaybeSuffix({
value: roundOff(angle, 2),
suffix: 'Deg',

View File

@ -172,6 +172,7 @@ export type ModelingCommandSchema = {
variableName: string
}
namedValue: KclCommandValue
scaleSketch?: boolean
}
'Prompt-to-edit': {
prompt: string

View File

@ -15,6 +15,7 @@ export type CommandBarContext = {
currentArgument?: CommandArgument<unknown> & { name: string }
argumentsToSubmit: { [x: string]: unknown }
machineManager: MachineManager
additionalData: { [x: string]: unknown } // For storing extra parameters like scaleSketch
}
export type CommandBarMachineEvent =
@ -77,6 +78,7 @@ export type CommandBarMachineEvent =
data: { [x: string]: CommandArgumentWithName<unknown> }
}
| { type: 'Set machine manager'; data: MachineManager }
| { type: 'Set additional data'; data: { [x: string]: unknown } }
export const commandBarMachine = setup({
types: {
@ -117,6 +119,12 @@ export const commandBarMachine = setup({
resolvedArgs[argName] =
typeof argValue === 'function' ? argValue(context) : argValue
}
// Special handling for "Constrain with named value" command to include scaleSketch
if (selectedCommand.name === 'Constrain with named value') {
resolvedArgs.scaleSketch = context.additionalData.scaleSketch
}
selectedCommand?.onSubmit(resolvedArgs)
} else {
selectedCommand?.onSubmit({ context, event })
@ -224,6 +232,16 @@ export const commandBarMachine = setup({
selectedCommand: undefined,
currentArgument: undefined,
argumentsToSubmit: {},
additionalData: {},
}),
'Set additional data': assign({
additionalData: ({ context, event }) => {
if (event.type !== 'Set additional data') return context.additionalData
return {
...context.additionalData,
...event.data,
}
},
}),
'Set selected command': assign({
selectedCommand: ({ context, event }) =>
@ -468,6 +486,7 @@ export const commandBarMachine = setup({
codeBasedSelections: [],
},
argumentsToSubmit: {},
additionalData: {},
machineManager: {
machines: [],
machineApiIp: null,
@ -626,6 +645,11 @@ export const commandBarMachine = setup({
actions: 'Set machine manager',
},
'Set additional data': {
reenter: false,
actions: 'Set additional data',
},
Close: {
target: '.Closed',
actions: 'Clear selected command',

View File

@ -19,6 +19,7 @@ import { err } from '@src/lib/trap'
import {
createIdentifier,
createLiteral,
createLocalName,
createVariableDeclaration,
} from '@src/lang/create'
import { ARG_END_ABSOLUTE, ARG_INTERIOR_ABSOLUTE } from '@src/lang/constants'
@ -1157,7 +1158,7 @@ p3 = [342.51, 216.38],
filter
)
const constraint = constraintInfo[constraintIndex]
console.log('constraint', constraint)
if (!constraint.argPosition) {
throw new Error(
`Constraint at index ${constraintIndex} does not have argPosition`
@ -1292,3 +1293,279 @@ p3 = [342.51, 216.38],
)
})
})
describe('testing sketch scale on first length constraint', () => {
it('should scale sketch when constrain with named value is used with scale checkbox enabled', async () => {
// Create a sketch with multiple segments using only literal values (no constraints)
const code = `sketch001 = startSketchOn(XZ)
profile001 = startProfile(sketch001, at = [100, 100])
|> line(end = [200, 0])
|> line(end = [0, 200])
|> line(end = [-200, 0])
|> close()
profile002 = circle(sketch001, center = [400, 400], radius = 50)
`
const ast = assertParse(code)
await kclManager.executeAst({ ast })
expect(kclManager.errors).toEqual([])
// Find a segment to constrain (the first line segment)
const indexOfInterest = code.indexOf('line(end = [200, 0])')
const artifact = [...kclManager.artifactGraph].find(
([_, artifact]) =>
artifact?.type === 'segment' &&
artifact.codeRef.range[0] <= indexOfInterest &&
indexOfInterest <= artifact.codeRef.range[1]
)?.[1]
if (!artifact || !('codeRef' in artifact)) {
throw new Error('Artifact not found or invalid artifact structure')
}
// Create modeling machine actor
const modelingActor = createActor(modelingMachine, {
input: modelingMachineDefaultContext,
}).start()
// Enter sketch mode
modelingActor.send({
type: 'Set selection',
data: {
selectionType: 'mirrorCodeMirrorSelections',
selection: {
graphSelections: [
{
artifact: artifact,
codeRef: artifact.codeRef,
},
],
otherSelections: [],
},
},
})
modelingActor.send({ type: 'Enter sketch' })
// Wait for sketch mode
await waitForCondition(() => {
const snapshot = modelingActor.getSnapshot()
return snapshot.value !== 'animating to existing sketch'
}, 5000)
expect(modelingActor.getSnapshot().value).toEqual({
Sketch: { SketchIdle: 'scene drawn' },
})
// Get constraint info for the segment
const callExp = getNodeFromPath<Node<CallExpressionKw>>(
kclManager.ast,
artifact.codeRef.pathToNode,
'CallExpressionKw'
)
if (err(callExp)) {
throw new Error('Failed to get CallExpressionKw node')
}
const constraintInfo = getConstraintInfoKw(
callExp.node,
codeManager.code,
artifact.codeRef.pathToNode
)
const constraint = constraintInfo[0] // First constraint (x value)
// Store original code to compare scaling
const originalCode = codeManager.code
// No need for command bar setup, we're testing the modeling machine directly
// Simulate submitting the command with scaling enabled
// The new value will be 100 (half of original 200), so scale factor should be 0.5
modelingActor.send({
type: 'Constrain with named value',
data: {
currentValue: {
valueText: constraint.value,
pathToNode: constraint.pathToNode,
variableName: 'length_var',
},
namedValue: {
valueText: '100',
variableName: 'length_var',
insertIndex: 0,
valueCalculated: '100',
variableDeclarationAst: createVariableDeclaration(
'length_var',
createLiteral('100')
),
variableIdentifierAst: createLocalName('length_var'),
valueAst: createLiteral('100'),
},
scaleSketch: true, // This should trigger scaling
},
})
// Wait for the constraint to be applied and sketch to be scaled
await waitForCondition(() => {
const snapshot = modelingActor.getSnapshot()
return (
JSON.stringify(snapshot.value) !==
JSON.stringify({ Sketch: 'Converting to named value' })
)
}, 5000)
// Wait for code to be updated
const startTime = Date.now()
while (codeManager.code === originalCode && Date.now() - startTime < 5000) {
await new Promise((resolve) => setTimeout(resolve, 100))
}
await new Promise((resolve) => setTimeout(resolve, 1000))
console.log('code is', codeManager.code)
// Verify the constraint was applied
expect(codeManager.code).toContain('length_var')
expect(codeManager.code).toContain("length_var = '100'")
// Verify scaling occurred - all dimensions should be scaled by 0.5 (100/200)
// Original values: line(end = [200, 0]), line(end = [0, 200]), line(end = [-200, 0])
// Scaled values should be: line(end = [100, 0]), line(end = [0, 100]), line(end = [-100, 0])
expect(codeManager.code).toContain('line(end = [length_var, 0])') // First line uses variable
expect(codeManager.code).toContain('line(end = [0, 100])') // Second line scaled
expect(codeManager.code).toContain('line(end = [-100, 0])') // Third line scaled
// Circle should also be scaled: radius = 50 -> radius = 25
expect(codeManager.code).toContain('radius = 25')
// Start positions should be scaled: at = [100, 100] -> at = [50, 50], at = [400, 400] -> at = [200, 200]
expect(codeManager.code).toContain('at = [50, 50]')
expect(codeManager.code).toContain('center = [200, 200]')
}, 15_000)
it('should not scale sketch when constrain with named value is used with scale checkbox disabled', async () => {
// Create a sketch with multiple segments using only literal values (no constraints)
const code = `sketch001 = startSketchOn(XZ)
profile001 = startProfile(sketch001, at = [100, 100])
|> line(end = [200, 0])
|> line(end = [0, 200])
|> close()`
const ast = assertParse(code)
await kclManager.executeAst({ ast })
expect(kclManager.errors).toEqual([])
// Find a segment to constrain (the first line segment)
const indexOfInterest = code.indexOf('line(end = [200, 0])')
const artifact = [...kclManager.artifactGraph].find(
([_, artifact]) =>
artifact?.type === 'segment' &&
artifact.codeRef.range[0] <= indexOfInterest &&
indexOfInterest <= artifact.codeRef.range[1]
)?.[1]
if (!artifact || !('codeRef' in artifact)) {
throw new Error('Artifact not found or invalid artifact structure')
}
// Create modeling machine actor
const modelingActor = createActor(modelingMachine, {
input: modelingMachineDefaultContext,
}).start()
// Enter sketch mode
modelingActor.send({
type: 'Set selection',
data: {
selectionType: 'mirrorCodeMirrorSelections',
selection: {
graphSelections: [
{
artifact: artifact,
codeRef: artifact.codeRef,
},
],
otherSelections: [],
},
},
})
modelingActor.send({ type: 'Enter sketch' })
// Wait for sketch mode
await waitForCondition(() => {
const snapshot = modelingActor.getSnapshot()
return snapshot.value !== 'animating to existing sketch'
}, 5000)
// Get constraint info for the segment
const callExp = getNodeFromPath<Node<CallExpressionKw>>(
kclManager.ast,
artifact.codeRef.pathToNode,
'CallExpressionKw'
)
if (err(callExp)) {
throw new Error('Failed to get CallExpressionKw node')
}
const constraintInfo = getConstraintInfoKw(
callExp.node,
codeManager.code,
artifact.codeRef.pathToNode
)
const constraint = constraintInfo[0] // First constraint (x value)
// Store original code to compare
const originalCode = codeManager.code
// Submit command with scaling disabled directly to modeling machine
modelingActor.send({
type: 'Constrain with named value',
data: {
currentValue: {
valueText: constraint.value,
pathToNode: constraint.pathToNode,
variableName: 'length_var',
},
namedValue: {
valueText: '100',
variableName: 'length_var',
insertIndex: 0,
valueCalculated: '100',
variableDeclarationAst: createVariableDeclaration(
'length_var',
createLiteral('100')
),
variableIdentifierAst: createLocalName('length_var'),
valueAst: createLiteral('100'),
},
scaleSketch: false, // Scaling disabled
},
})
// Wait for the constraint to be applied
await waitForCondition(() => {
const snapshot = modelingActor.getSnapshot()
return (
JSON.stringify(snapshot.value) !==
JSON.stringify({ Sketch: 'Converting to named value' })
)
}, 5000)
// Wait for code to be updated
const startTime = Date.now()
while (codeManager.code === originalCode && Date.now() - startTime < 5000) {
await new Promise((resolve) => setTimeout(resolve, 100))
}
// Verify the constraint was applied but no scaling occurred
expect(codeManager.code).toContain('length_var')
expect(codeManager.code).toContain("length_var = '100'")
expect(codeManager.code).toContain('line(end = [length_var, 0])')
// Other dimensions should remain unchanged (no scaling)
expect(codeManager.code).toContain('line(end = [0, 200])') // Should remain 200, not scaled to 100
expect(codeManager.code).toContain('at = [100, 100]') // Should remain [100, 100], not scaled
}, 15_000)
// Note: The third test for checking if scale checkbox is disabled when sketch has existing constraints
// would be better tested in the UI layer (CommandBarKclInput.tsx) or as an e2e test
// since it's primarily a UI behavior test
})

View File

@ -9,6 +9,7 @@ import {
orthoScale,
quaternionFromUpNForward,
} from '@src/clientSideScene/helpers'
import { scaleProfiles } from '@src/clientSideScene/sceneEntities'
import type { Setting } from '@src/lib/settings/initialSettings'
import type { CameraProjectionType } from '@rust/kcl-lib/bindings/CameraProjectionType'
import { DRAFT_DASHED_LINE } from '@src/clientSideScene/sceneConstants'
@ -2248,6 +2249,55 @@ export const modelingMachine = setup({
return Promise.reject(new Error('Unexpected compilation error'))
let parsed = pResult.program
// Apply sketch scaling if enabled
if (data.scaleSketch && sketchDetails.sketchNodePaths) {
const originalValue = parseFloat(data.currentValue.valueText)
const newValue = parseFloat(
typeof data.namedValue === 'object' &&
'valueText' in data.namedValue
? data.namedValue.valueText
: String(data.namedValue)
)
if (
!Number.isNaN(originalValue) &&
!Number.isNaN(newValue) &&
originalValue !== 0
) {
const scaleFactor = newValue / originalValue
try {
const scaleResult = scaleProfiles({
ast: parsed,
pathsToProfile: sketchDetails.sketchNodePaths,
factor: scaleFactor,
variables: kclManager.variables,
})
if (!err(scaleResult)) {
parsed = scaleResult.modifiedAst
// Reparse and recast to get fresh source ranges after scaling
const reparseResult = parse(recast(parsed))
if (!trap(reparseResult) && resultIsOk(reparseResult)) {
parsed = reparseResult.program
}
} else {
// Continue with constraint application even if scaling fails
console.warn(
'Failed to scale sketch, continuing with constraint application'
)
}
} catch (error) {
// Continue with constraint application even if scaling fails
console.warn(
'Error scaling sketch, continuing with constraint application:',
error
)
}
}
}
let result: {
modifiedAst: Node<Program>
pathToReplaced: PathToNode | null