Merge branch 'nadro/gh-5157/center-rectangle-kcl-fix' into achalmers/kw-fn-sketches

This commit is contained in:
Adam Chalmers
2025-01-31 09:27:22 -06:00
12 changed files with 211 additions and 18 deletions

View File

@ -34,7 +34,7 @@ test.describe('Sketch tests', () => {
screwRadius = 3 screwRadius = 3
wireRadius = 2 wireRadius = 2
wireOffset = 0.5 wireOffset = 0.5
screwHole = startSketchOn('XY') screwHole = startSketchOn('XY')
${startProfileAt1} ${startProfileAt1}
|> arc({ |> arc({
@ -42,7 +42,7 @@ test.describe('Sketch tests', () => {
angleStart = 0, angleStart = 0,
angleEnd = 360 angleEnd = 360
}, %) }, %)
part001 = startSketchOn('XY') part001 = startSketchOn('XY')
${startProfileAt2} ${startProfileAt2}
|> xLine(width * .5, %) |> xLine(width * .5, %)
@ -51,7 +51,7 @@ test.describe('Sketch tests', () => {
|> close() |> close()
|> hole(screwHole, %) |> hole(screwHole, %)
|> extrude(length = thickness) |> extrude(length = thickness)
part002 = startSketchOn('-XZ') part002 = startSketchOn('-XZ')
${startProfileAt3} ${startProfileAt3}
|> xLine(width / 4, %) |> xLine(width / 4, %)
@ -99,6 +99,7 @@ test.describe('Sketch tests', () => {
test('Can delete most of a sketch and the line tool will still work', async ({ test('Can delete most of a sketch and the line tool will still work', async ({
page, page,
homePage, homePage,
scene,
}) => { }) => {
const u = await getUtils(page) const u = await getUtils(page)
await page.addInitScript(async () => { await page.addInitScript(async () => {
@ -112,12 +113,13 @@ test.describe('Sketch tests', () => {
}) })
await homePage.goToModelingScene() await homePage.goToModelingScene()
await scene.waitForExecutionDone()
await expect(async () => { await expect(async () => {
await page.getByText('tangentialArcTo([24.95, -5.38], %)').click() await page.getByText('tangentialArcTo([24.95, -5.38], %)').click()
await expect( await expect(
page.getByRole('button', { name: 'Edit Sketch' }) page.getByRole('button', { name: 'Edit Sketch' })
).toBeEnabled({ timeout: 1000 }) ).toBeEnabled({ timeout: 2000 })
await page.getByRole('button', { name: 'Edit Sketch' }).click() await page.getByRole('button', { name: 'Edit Sketch' }).click()
}).toPass({ timeout: 40_000, intervals: [1_000] }) }).toPass({ timeout: 40_000, intervals: [1_000] })
@ -1066,7 +1068,7 @@ test.describe('Sketch tests', () => {
`lugHeadLength = 0.25 `lugHeadLength = 0.25
lugDiameter = 0.5 lugDiameter = 0.5
lugLength = 2 lugLength = 2
fn lug = (origin, length, diameter, plane) => { fn lug = (origin, length, diameter, plane) => {
lugSketch = startSketchOn(plane) lugSketch = startSketchOn(plane)
|> startProfileAt([origin[0] + lugDiameter / 2, origin[1]], %) |> startProfileAt([origin[0] + lugDiameter / 2, origin[1]], %)
@ -1075,10 +1077,10 @@ test.describe('Sketch tests', () => {
|> yLineTo(0, %) |> yLineTo(0, %)
|> close() |> close()
|> revolve({ axis = "Y" }, %) |> revolve({ axis = "Y" }, %)
return lugSketch return lugSketch
} }
lug([0, 0], 10, .5, "XY")` lug([0, 0], 10, .5, "XY")`
) )
}) })
@ -1130,14 +1132,14 @@ test.describe('Sketch tests', () => {
`fn in2mm = (inches) => { `fn in2mm = (inches) => {
return inches * 25.4 return inches * 25.4
} }
const railTop = in2mm(.748) const railTop = in2mm(.748)
const railSide = in2mm(.024) const railSide = in2mm(.024)
const railBaseWidth = in2mm(.612) const railBaseWidth = in2mm(.612)
const railWideWidth = in2mm(.835) const railWideWidth = in2mm(.835)
const railBaseLength = in2mm(.200) const railBaseLength = in2mm(.200)
const railClampable = in2mm(.200) const railClampable = in2mm(.200)
const rail = startSketchOn('XZ') const rail = startSketchOn('XZ')
|> startProfileAt([ |> startProfileAt([
-railTop / 2, -railTop / 2,

View File

@ -5,6 +5,8 @@ import {
Identifier, Identifier,
SourceRange, SourceRange,
topLevelRange, topLevelRange,
LiteralValue,
Literal,
} from './wasm' } from './wasm'
import { import {
createLiteral, createLiteral,
@ -37,10 +39,26 @@ beforeAll(async () => {
}) })
describe('Testing createLiteral', () => { describe('Testing createLiteral', () => {
it('should create a literal', () => { it('should create a literal number without units', () => {
const result = createLiteral(5) const result = createLiteral(5)
expect(result.type).toBe('Literal') expect(result.type).toBe('Literal')
expect((result as any).value.value).toBe(5) expect((result as any).value.value).toBe(5)
expect((result as any).value.suffix).toBe('None')
expect((result as Literal).raw).toBe('5')
})
it('should create a literal number with units', () => {
const lit: LiteralValue = { value: 5, suffix: 'Mm' }
const result = createLiteral(lit)
expect(result.type).toBe('Literal')
expect((result as any).value.value).toBe(5)
expect((result as any).value.suffix).toBe('Mm')
expect((result as Literal).raw).toBe('5mm')
})
it('should create a literal boolean', () => {
const result = createLiteral(false)
expect(result.type).toBe('Literal')
expect((result as Literal).value).toBe(false)
expect((result as Literal).raw).toBe('false')
}) })
}) })
describe('Testing createIdentifier', () => { describe('Testing createIdentifier', () => {

View File

@ -22,6 +22,7 @@ import {
SourceRange, SourceRange,
sketchFromKclValue, sketchFromKclValue,
isPathToNodeNumber, isPathToNodeNumber,
formatNumber,
} from './wasm' } from './wasm'
import { import {
isNodeSafeToReplacePath, isNodeSafeToReplacePath,
@ -780,11 +781,26 @@ export function splitPathAtPipeExpression(pathToNode: PathToNode): {
return splitPathAtPipeExpression(pathToNode.slice(0, -1)) return splitPathAtPipeExpression(pathToNode.slice(0, -1))
} }
/**
* Note: This depends on WASM, but it's not async. Callers are responsible for
* awaiting init of the WASM module.
*/
export function createLiteral(value: LiteralValue | number): Node<Literal> { export function createLiteral(value: LiteralValue | number): Node<Literal> {
const raw = `${value}`
if (typeof value === 'number') { if (typeof value === 'number') {
value = { value, suffix: 'None' } value = { value, suffix: 'None' }
} }
let raw: string
if (typeof value === 'string') {
// TODO: Should we handle escape sequences?
raw = `${value}`
} else if (typeof value === 'boolean') {
raw = `${value}`
} else if (typeof value.value === 'number' && value.suffix === 'None') {
// Fast path for numbers when there are no units.
raw = `${value.value}`
} else {
raw = formatNumber(value.value, value.suffix)
}
return { return {
type: 'Literal', type: 'Literal',
start: 0, start: 0,

View File

@ -8,6 +8,8 @@ import {
ArtifactGraph, ArtifactGraph,
CallExpressionKw, CallExpressionKw,
Expr, Expr,
LiteralValue,
NumericSuffix,
} from './wasm' } from './wasm'
import { filterArtifacts } from 'lang/std/artifactGraph' import { filterArtifacts } from 'lang/std/artifactGraph'
import { isOverlap } from 'lib/utils' import { isOverlap } from 'lib/utils'
@ -111,3 +113,15 @@ export function findKwArgAnyIndex(
export function isAbsolute(call: CallExpressionKw): boolean { export function isAbsolute(call: CallExpressionKw): boolean {
return findKwArgAny(['endAbsolute'], call) !== undefined return findKwArgAny(['endAbsolute'], call) !== undefined
} }
export function isLiteralValueNumber(
e: LiteralValue
): e is { value: number; suffix: NumericSuffix } {
return (
typeof e === 'object' &&
'value' in e &&
typeof e.value === 'number' &&
'suffix' in e &&
typeof e.suffix === 'string'
)
}

View File

@ -1,5 +1,5 @@
import { err } from 'lib/trap' import { err } from 'lib/trap'
import { initPromise, parse, ParseResult } from './wasm' import { formatNumber, initPromise, parse, ParseResult } from './wasm'
import { enginelessExecutor } from 'lib/testHelpers' import { enginelessExecutor } from 'lib/testHelpers'
import { Node } from 'wasm-lib/kcl/bindings/Node' import { Node } from 'wasm-lib/kcl/bindings/Node'
import { Program } from '../wasm-lib/kcl/bindings/Program' import { Program } from '../wasm-lib/kcl/bindings/Program'
@ -20,3 +20,12 @@ it('can execute parsed AST', async () => {
expect(err(execState)).toEqual(false) expect(err(execState)).toEqual(false)
expect(execState.memory.get('x')?.value).toEqual(1) expect(execState.memory.get('x')?.value).toEqual(1)
}) })
it('formats numbers with units', () => {
expect(formatNumber(1, 'None')).toEqual('1')
expect(formatNumber(1, 'Count')).toEqual('1_')
expect(formatNumber(1, 'Mm')).toEqual('1mm')
expect(formatNumber(1, 'Inch')).toEqual('1in')
expect(formatNumber(0.5, 'Mm')).toEqual('0.5mm')
expect(formatNumber(-0.5, 'Mm')).toEqual('-0.5mm')
})

View File

@ -2,6 +2,7 @@ import {
init, init,
parse_wasm, parse_wasm,
recast_wasm, recast_wasm,
format_number,
execute, execute,
kcl_lint, kcl_lint,
modify_ast_for_sketch_wasm, modify_ast_for_sketch_wasm,
@ -54,6 +55,7 @@ import { ArtifactCommand } from 'wasm-lib/kcl/bindings/Artifact'
import { ArtifactGraph as RustArtifactGraph } from 'wasm-lib/kcl/bindings/Artifact' import { ArtifactGraph as RustArtifactGraph } from 'wasm-lib/kcl/bindings/Artifact'
import { Artifact } from './std/artifactGraph' import { Artifact } from './std/artifactGraph'
import { getNodePathFromSourceRange } from 'lang/queryAstNodePathUtils' import { getNodePathFromSourceRange } from 'lang/queryAstNodePathUtils'
import { NumericSuffix } from 'wasm-lib/kcl/bindings/NumericSuffix'
export type { Artifact } from 'wasm-lib/kcl/bindings/Artifact' export type { Artifact } from 'wasm-lib/kcl/bindings/Artifact'
export type { ArtifactCommand } from 'wasm-lib/kcl/bindings/Artifact' export type { ArtifactCommand } from 'wasm-lib/kcl/bindings/Artifact'
@ -92,6 +94,7 @@ export type { Literal } from '../wasm-lib/kcl/bindings/Literal'
export type { LiteralValue } from '../wasm-lib/kcl/bindings/LiteralValue' export type { LiteralValue } from '../wasm-lib/kcl/bindings/LiteralValue'
export type { ArrayExpression } from '../wasm-lib/kcl/bindings/ArrayExpression' export type { ArrayExpression } from '../wasm-lib/kcl/bindings/ArrayExpression'
export type { SourceRange } from 'wasm-lib/kcl/bindings/SourceRange' export type { SourceRange } from 'wasm-lib/kcl/bindings/SourceRange'
export type { NumericSuffix } from 'wasm-lib/kcl/bindings/NumericSuffix'
export type SyntaxType = export type SyntaxType =
| 'Program' | 'Program'
@ -642,6 +645,13 @@ export const recast = (ast: Program): string | Error => {
return recast_wasm(JSON.stringify(ast)) return recast_wasm(JSON.stringify(ast))
} }
/**
* Format a number with suffix as KCL.
*/
export function formatNumber(value: number, suffix: NumericSuffix): string {
return format_number(value, JSON.stringify(suffix))
}
export const makeDefaultPlanes = async ( export const makeDefaultPlanes = async (
engineCommandManager: EngineCommandManager engineCommandManager: EngineCommandManager
): Promise<DefaultPlanes> => { ): Promise<DefaultPlanes> => {

View File

@ -0,0 +1,92 @@
import { expect } from 'vitest'
import {
recast,
assertParse,
topLevelRange,
VariableDeclaration,
initPromise,
} from 'lang/wasm'
import { updateCenterRectangleSketch } from './rectangleTool'
import { getNodePathFromSourceRange } from 'lang/queryAstNodePathUtils'
import { getNodeFromPath } from 'lang/queryAst'
import { findUniqueName } from 'lang/modifyAst'
import { err, trap } from './trap'
beforeAll(async () => {
await initPromise
})
describe('library rectangleTool helper functions', () => {
describe('updateCenterRectangleSketch', () => {
// regression test for https://github.com/KittyCAD/modeling-app/issues/5157
test('should update AST and source code', async () => {
// Base source code that will be edited in place
const sourceCode = `sketch001 = startSketchOn('XZ')
|> startProfileAt([120.37, 162.76], %)
|> angledLine([0, 0], %, $rectangleSegmentA001)
|> angledLine([segAng(rectangleSegmentA001) + 90, 0], %, $rectangleSegmentB001)
|> angledLine([
segAng(rectangleSegmentA001),
-segLen(rectangleSegmentA001)
], %, $rectangleSegmentC001)
|> lineTo([profileStartX(%), profileStartY(%)], %)
|> close(%)
`
// Create ast
const _ast = assertParse(sourceCode)
let ast = structuredClone(_ast)
// Find some nodes and paths to reference
const sketchSnippet = `startProfileAt([120.37, 162.76], %)`
const sketchRange = topLevelRange(
sourceCode.indexOf(sketchSnippet),
sourceCode.indexOf(sketchSnippet) + sketchSnippet.length
)
const sketchPathToNode = getNodePathFromSourceRange(ast, sketchRange)
const _node = getNodeFromPath<VariableDeclaration>(
ast,
sketchPathToNode || [],
'VariableDeclaration'
)
if (trap(_node)) return
const sketchInit = _node.node?.declaration.init
// Hard code inputs that a user would have taken with their mouse
const x = 40
const y = 60
const rectangleOrigin = [120, 180]
const tags: [string, string, string] = [
'rectangleSegmentA001',
'rectangleSegmentB001',
'rectangleSegmentC001',
]
// Update the ast
if (sketchInit.type === 'PipeExpression') {
updateCenterRectangleSketch(
sketchInit,
x,
y,
tags[0],
rectangleOrigin[0],
rectangleOrigin[1]
)
}
// ast is edited in place from the updateCenterRectangleSketch
const expectedSourceCode = `sketch001 = startSketchOn('XZ')
|> startProfileAt([80, 120], %)
|> angledLine([0, 80], %, $rectangleSegmentA001)
|> angledLine([segAng(rectangleSegmentA001) + 90, 120], %, $rectangleSegmentB001)
|> angledLine([
segAng(rectangleSegmentA001),
-segLen(rectangleSegmentA001)
], %, $rectangleSegmentC001)
|> lineTo([profileStartX(%), profileStartY(%)], %)
|> close(%)
`
const recasted = recast(ast)
expect(recasted).toEqual(expectedSourceCode)
})
})
})

View File

@ -10,13 +10,19 @@ import {
createTagDeclarator, createTagDeclarator,
createUnaryExpression, createUnaryExpression,
} from 'lang/modifyAst' } from 'lang/modifyAst'
import { ArrayExpression, CallExpression, PipeExpression } from 'lang/wasm' import {
ArrayExpression,
CallExpression,
PipeExpression,
recast,
} from 'lang/wasm'
import { roundOff } from 'lib/utils' import { roundOff } from 'lib/utils'
import { import {
isCallExpression, isCallExpression,
isArrayExpression, isArrayExpression,
isLiteral, isLiteral,
isBinaryExpression, isBinaryExpression,
isLiteralValueNumber,
} from 'lang/util' } from 'lang/util'
/** /**
@ -144,10 +150,12 @@ export function updateCenterRectangleSketch(
if (isArrayExpression(arrayExpression)) { if (isArrayExpression(arrayExpression)) {
const literal = arrayExpression.elements[0] const literal = arrayExpression.elements[0]
if (isLiteral(literal)) { if (isLiteral(literal)) {
callExpression.arguments[0] = createArrayExpression([ if (isLiteralValueNumber(literal.value)) {
createLiteral(literal.value), callExpression.arguments[0] = createArrayExpression([
createLiteral(Math.abs(twoX)), createLiteral(literal.value),
]) createLiteral(Math.abs(twoX)),
])
}
} }
} }
} }

View File

@ -10,6 +10,7 @@
import { import {
parse_wasm as ParseWasm, parse_wasm as ParseWasm,
recast_wasm as RecastWasm, recast_wasm as RecastWasm,
format_number as FormatNumber,
execute as Execute, execute as Execute,
kcl_lint as KclLint, kcl_lint as KclLint,
modify_ast_for_sketch_wasm as ModifyAstForSketch, modify_ast_for_sketch_wasm as ModifyAstForSketch,
@ -51,6 +52,9 @@ export const parse_wasm: typeof ParseWasm = (...args) => {
export const recast_wasm: typeof RecastWasm = (...args) => { export const recast_wasm: typeof RecastWasm = (...args) => {
return getModule().recast_wasm(...args) return getModule().recast_wasm(...args)
} }
export const format_number: typeof FormatNumber = (...args) => {
return getModule().format_number(...args)
}
export const execute: typeof Execute = (...args) => { export const execute: typeof Execute = (...args) => {
return getModule().execute(...args) return getModule().execute(...args)
} }

View File

@ -120,6 +120,11 @@ pub mod std_utils {
pub use crate::std::utils::{get_tangential_arc_to_info, is_points_ccw_wasm, TangentialArcInfoInput}; pub use crate::std::utils::{get_tangential_arc_to_info, is_points_ccw_wasm, TangentialArcInfoInput};
} }
pub mod pretty {
pub use crate::parsing::token::NumericSuffix;
pub use crate::unparser::format_number;
}
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
#[allow(unused_imports)] #[allow(unused_imports)]

View File

@ -8,6 +8,7 @@ use crate::parsing::{
LiteralValue, MemberExpression, MemberObject, Node, NonCodeNode, NonCodeValue, ObjectExpression, Parameter, LiteralValue, MemberExpression, MemberObject, Node, NonCodeNode, NonCodeValue, ObjectExpression, Parameter,
PipeExpression, Program, TagDeclarator, UnaryExpression, VariableDeclaration, VariableKind, PipeExpression, Program, TagDeclarator, UnaryExpression, VariableDeclaration, VariableKind,
}, },
token::NumericSuffix,
PIPE_OPERATOR, PIPE_OPERATOR,
}; };
@ -370,6 +371,11 @@ impl VariableDeclaration {
} }
} }
// Used by TS.
pub fn format_number(value: f64, suffix: NumericSuffix) -> String {
format!("{value}{suffix}")
}
impl Literal { impl Literal {
fn recast(&self) -> String { fn recast(&self) -> String {
match self.value { match self.value {

View File

@ -5,7 +5,8 @@ use std::sync::Arc;
use futures::stream::TryStreamExt; use futures::stream::TryStreamExt;
use gloo_utils::format::JsValueSerdeExt; use gloo_utils::format::JsValueSerdeExt;
use kcl_lib::{ use kcl_lib::{
exec::IdGenerator, CacheInformation, CoreDump, EngineManager, ExecState, ModuleId, OldAstState, Point2d, Program, exec::IdGenerator, pretty::NumericSuffix, CacheInformation, CoreDump, EngineManager, ExecState, ModuleId,
OldAstState, Point2d, Program,
}; };
use tokio::sync::RwLock; use tokio::sync::RwLock;
use tower_lsp::{LspService, Server}; use tower_lsp::{LspService, Server};
@ -251,6 +252,14 @@ pub fn recast_wasm(json_str: &str) -> Result<JsValue, JsError> {
Ok(JsValue::from_serde(&program.recast())?) Ok(JsValue::from_serde(&program.recast())?)
} }
#[wasm_bindgen]
pub fn format_number(value: f64, suffix_json: &str) -> Result<String, JsError> {
console_error_panic_hook::set_once();
let suffix: NumericSuffix = serde_json::from_str(suffix_json).map_err(JsError::from)?;
Ok(kcl_lib::pretty::format_number(value, suffix))
}
#[wasm_bindgen] #[wasm_bindgen]
pub struct ServerConfig { pub struct ServerConfig {
into_server: js_sys::AsyncIterator, into_server: js_sys::AsyncIterator,