Skip to content

Commit 85c721f

Browse files
authored
Add display of units for calculated KCL values (#7619)
* Add display of units in UI modals with calculated KCL values * Fix command bar display to handle units * Add display of units in the command bar * Fix more cases of NaN from units * Fix to support explicit plus for exponent in scientific notation * Fix display in autocomplete * Change to parseFloat to be more resilient * Add e2e test for command bar * Change an existing test to use explicit inline units * Fix case when input string can't be parsed
1 parent 27af2d0 commit 85c721f

File tree

17 files changed

+360
-28
lines changed

17 files changed

+360
-28
lines changed

e2e/playwright/command-bar-tests.spec.ts

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -525,7 +525,9 @@ test.describe('Command bar tests', () => {
525525
const projectName = 'test'
526526
const beforeKclCode = `a = 5
527527
b = a * a
528-
c = 3 + a`
528+
c = 3 + a
529+
theta = 45deg
530+
`
529531
await context.folderSetupFn(async (dir) => {
530532
const testProject = join(dir, projectName)
531533
await fsp.mkdir(testProject, { recursive: true })
@@ -615,9 +617,45 @@ c = 3 + a`
615617
stage: 'commandBarClosed',
616618
})
617619
})
620+
await test.step(`Edit a parameter with explicit units via command bar`, async () => {
621+
await cmdBar.cmdBarOpenBtn.click()
622+
await cmdBar.chooseCommand('edit parameter')
623+
await cmdBar
624+
.selectOption({
625+
name: 'theta',
626+
})
627+
.click()
628+
await cmdBar.expectState({
629+
stage: 'arguments',
630+
commandName: 'Edit parameter',
631+
currentArgKey: 'value',
632+
currentArgValue: '45deg',
633+
headerArguments: {
634+
Name: 'theta',
635+
Value: '',
636+
},
637+
highlightedHeaderArg: 'value',
638+
})
639+
await cmdBar.argumentInput
640+
.locator('[contenteditable]')
641+
.fill('45deg + 1deg')
642+
await cmdBar.progressCmdBar()
643+
await cmdBar.expectState({
644+
stage: 'review',
645+
commandName: 'Edit parameter',
646+
headerArguments: {
647+
Name: 'theta',
648+
Value: '46deg',
649+
},
650+
})
651+
await cmdBar.progressCmdBar()
652+
await cmdBar.expectState({
653+
stage: 'commandBarClosed',
654+
})
655+
})
618656

619657
await editor.expectEditor.toContain(
620-
`a = 5b = a * amyParameter001 = ${newValue}c = 3 + a`
658+
`a = 5b = a * amyParameter001 = ${newValue}c = 3 + atheta = 45deg + 1deg`
621659
)
622660
})
623661

e2e/playwright/point-click.spec.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -136,17 +136,17 @@ test.describe('Point-and-click tests', () => {
136136
highlightedHeaderArg: 'length',
137137
commandName: 'Extrude',
138138
})
139-
await page.keyboard.insertText('width - 0.001')
139+
await page.keyboard.insertText('width - 0.001in')
140140
await cmdBar.progressCmdBar()
141141
await cmdBar.expectState({
142142
stage: 'review',
143143
headerArguments: {
144-
Length: '4.999',
144+
Length: '4.999in',
145145
},
146146
commandName: 'Extrude',
147147
})
148148
await cmdBar.progressCmdBar()
149-
await editor.expectEditor.toContain('extrude(length = width - 0.001)')
149+
await editor.expectEditor.toContain('extrude(length = width - 0.001in)')
150150
})
151151

152152
await test.step(`Edit second extrude via feature tree`, async () => {

rust/kcl-lib/src/execution/types.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -840,6 +840,18 @@ pub enum UnitType {
840840
Angle(UnitAngle),
841841
}
842842

843+
impl UnitType {
844+
pub(crate) fn to_suffix(self) -> Option<String> {
845+
match self {
846+
UnitType::Count => Some("_".to_owned()),
847+
UnitType::Length(UnitLen::Unknown) => None,
848+
UnitType::Angle(UnitAngle::Unknown) => None,
849+
UnitType::Length(l) => Some(l.to_string()),
850+
UnitType::Angle(a) => Some(a.to_string()),
851+
}
852+
}
853+
}
854+
843855
impl std::fmt::Display for UnitType {
844856
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
845857
match self {

rust/kcl-lib/src/fmt.rs

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,31 @@ pub fn format_number_literal(value: f64, suffix: NumericSuffix) -> Result<String
4545
}
4646
}
4747

48+
#[derive(Debug, Clone, PartialEq, Serialize, thiserror::Error)]
49+
#[serde(tag = "type")]
50+
pub enum FormatNumericTypeError {
51+
#[error("Invalid numeric type: {0:?}")]
52+
Invalid(NumericType),
53+
}
54+
55+
/// For UI code generation, format a number value with a suffix such that the
56+
/// result can parse as a literal. If it can't be done, returns an error.
57+
///
58+
/// This is used by TS.
59+
pub fn format_number_value(value: f64, ty: NumericType) -> Result<String, FormatNumericTypeError> {
60+
match ty {
61+
NumericType::Default { .. } => Ok(value.to_string()),
62+
// There isn't a syntactic suffix for these. For unknown, we don't want
63+
// to ever generate the unknown suffix. We currently warn on it, and we
64+
// may remove it in the future.
65+
NumericType::Unknown | NumericType::Any => Err(FormatNumericTypeError::Invalid(ty)),
66+
NumericType::Known(unit_type) => unit_type
67+
.to_suffix()
68+
.map(|suffix| format!("{value}{suffix}"))
69+
.ok_or(FormatNumericTypeError::Invalid(ty)),
70+
}
71+
}
72+
4873
#[cfg(test)]
4974
mod tests {
5075
use pretty_assertions::assert_eq;
@@ -134,4 +159,74 @@ mod tests {
134159
Err(FormatNumericSuffixError::Invalid(NumericSuffix::Unknown))
135160
);
136161
}
162+
163+
#[test]
164+
fn test_format_number_value() {
165+
assert_eq!(
166+
format_number_value(
167+
1.0,
168+
NumericType::Default {
169+
len: Default::default(),
170+
angle: Default::default()
171+
}
172+
),
173+
Ok("1".to_owned())
174+
);
175+
assert_eq!(
176+
format_number_value(1.0, NumericType::Known(UnitType::Length(UnitLen::Unknown))),
177+
Err(FormatNumericTypeError::Invalid(NumericType::Known(UnitType::Length(
178+
UnitLen::Unknown
179+
))))
180+
);
181+
assert_eq!(
182+
format_number_value(1.0, NumericType::Known(UnitType::Angle(UnitAngle::Unknown))),
183+
Err(FormatNumericTypeError::Invalid(NumericType::Known(UnitType::Angle(
184+
UnitAngle::Unknown
185+
))))
186+
);
187+
assert_eq!(
188+
format_number_value(1.0, NumericType::Known(UnitType::Count)),
189+
Ok("1_".to_owned())
190+
);
191+
assert_eq!(
192+
format_number_value(1.0, NumericType::Known(UnitType::Length(UnitLen::Mm))),
193+
Ok("1mm".to_owned())
194+
);
195+
assert_eq!(
196+
format_number_value(1.0, NumericType::Known(UnitType::Length(UnitLen::Cm))),
197+
Ok("1cm".to_owned())
198+
);
199+
assert_eq!(
200+
format_number_value(1.0, NumericType::Known(UnitType::Length(UnitLen::M))),
201+
Ok("1m".to_owned())
202+
);
203+
assert_eq!(
204+
format_number_value(1.0, NumericType::Known(UnitType::Length(UnitLen::Inches))),
205+
Ok("1in".to_owned())
206+
);
207+
assert_eq!(
208+
format_number_value(1.0, NumericType::Known(UnitType::Length(UnitLen::Feet))),
209+
Ok("1ft".to_owned())
210+
);
211+
assert_eq!(
212+
format_number_value(1.0, NumericType::Known(UnitType::Length(UnitLen::Yards))),
213+
Ok("1yd".to_owned())
214+
);
215+
assert_eq!(
216+
format_number_value(1.0, NumericType::Known(UnitType::Angle(UnitAngle::Degrees))),
217+
Ok("1deg".to_owned())
218+
);
219+
assert_eq!(
220+
format_number_value(1.0, NumericType::Known(UnitType::Angle(UnitAngle::Radians))),
221+
Ok("1rad".to_owned())
222+
);
223+
assert_eq!(
224+
format_number_value(1.0, NumericType::Unknown),
225+
Err(FormatNumericTypeError::Invalid(NumericType::Unknown))
226+
);
227+
assert_eq!(
228+
format_number_value(1.0, NumericType::Any),
229+
Err(FormatNumericTypeError::Invalid(NumericType::Any))
230+
);
231+
}
137232
}

rust/kcl-lib/src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@ pub mod std_utils {
140140

141141
pub mod pretty {
142142
pub use crate::{
143-
fmt::{format_number_literal, human_display_number},
143+
fmt::{format_number_literal, format_number_value, human_display_number},
144144
parsing::token::NumericSuffix,
145145
};
146146
}

rust/kcl-wasm-lib/src/wasm.rs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,37 @@ pub fn format_number_literal(value: f64, suffix_json: &str) -> Result<String, Js
6161
kcl_lib::pretty::format_number_literal(value, suffix).map_err(JsError::from)
6262
}
6363

64+
#[wasm_bindgen]
65+
pub fn format_number_value(value: f64, numeric_type_json: &str) -> Result<String, String> {
66+
console_error_panic_hook::set_once();
67+
68+
// ts-rs can't handle tuple types, so it mashes all of these types together.
69+
if let Ok(ty) = serde_json::from_str::<NumericType>(numeric_type_json) {
70+
if let Ok(formatted) = kcl_lib::pretty::format_number_value(value, ty) {
71+
return Ok(formatted);
72+
}
73+
}
74+
if let Ok(unit_type) = serde_json::from_str::<UnitType>(numeric_type_json) {
75+
let ty = NumericType::Known(unit_type);
76+
if let Ok(formatted) = kcl_lib::pretty::format_number_value(value, ty) {
77+
return Ok(formatted);
78+
}
79+
}
80+
if let Ok(unit_len) = serde_json::from_str::<UnitLen>(numeric_type_json) {
81+
let ty = NumericType::Known(UnitType::Length(unit_len));
82+
if let Ok(formatted) = kcl_lib::pretty::format_number_value(value, ty) {
83+
return Ok(formatted);
84+
}
85+
}
86+
if let Ok(unit_angle) = serde_json::from_str::<UnitAngle>(numeric_type_json) {
87+
let ty = NumericType::Known(UnitType::Angle(unit_angle));
88+
if let Ok(formatted) = kcl_lib::pretty::format_number_value(value, ty) {
89+
return Ok(formatted);
90+
}
91+
}
92+
Err(format!("Invalid type: {numeric_type_json}"))
93+
}
94+
6495
#[wasm_bindgen]
6596
pub fn human_display_number(value: f64, ty_json: &str) -> Result<String, String> {
6697
console_error_panic_hook::set_once();

src/components/CommandBar/CommandBarHeaderFooter.tsx

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import type {
1212
} from '@src/lib/commandTypes'
1313
import type { Selections } from '@src/lib/selections'
1414
import { getSelectionTypeDisplayText } from '@src/lib/selections'
15-
import { roundOff } from '@src/lib/utils'
15+
import { roundOffWithUnits } from '@src/lib/utils'
1616
import { commandBarActor, useCommandBarState } from '@src/lib/singletons'
1717

1818
function CommandBarHeaderFooter({
@@ -163,10 +163,8 @@ function CommandBarHeaderFooter({
163163
arg.inputType === 'selectionMixed' ? (
164164
getSelectionTypeDisplayText(argValue as Selections)
165165
) : arg.inputType === 'kcl' ? (
166-
roundOff(
167-
Number(
168-
(argValue as KclCommandValue).valueCalculated
169-
),
166+
roundOffWithUnits(
167+
(argValue as KclCommandValue).valueCalculated,
170168
4
171169
)
172170
) : arg.inputType === 'text' &&

src/components/CommandBar/CommandBarKclInput.tsx

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,13 @@ import { Spinner } from '@src/components/Spinner'
2121
import { createLocalName, createVariableDeclaration } from '@src/lang/create'
2222
import { getNodeFromPath } from '@src/lang/queryAst'
2323
import type { SourceRange, VariableDeclarator } from '@src/lang/wasm'
24-
import { isPathToNode } from '@src/lang/wasm'
24+
import { formatNumberValue, isPathToNode } from '@src/lang/wasm'
2525
import type { CommandArgument, KclCommandValue } from '@src/lib/commandTypes'
2626
import { kclManager } from '@src/lib/singletons'
2727
import { getSystemTheme } from '@src/lib/theme'
2828
import { err } from '@src/lib/trap'
2929
import { useCalculateKclExpression } from '@src/lib/useCalculateKclExpression'
30-
import { roundOff } from '@src/lib/utils'
30+
import { roundOff, roundOffWithUnits } from '@src/lib/utils'
3131
import { varMentions } from '@src/lib/varCompletionExtension'
3232
import { useSettings } from '@src/lib/singletons'
3333
import { commandBarActor, useCommandBarState } from '@src/lib/singletons'
@@ -128,10 +128,22 @@ function CommandBarKclInput({
128128
sourceRange: sourceRangeForPrevVariables,
129129
})
130130

131-
const varMentionData: Completion[] = prevVariables.map((v) => ({
132-
label: v.key,
133-
detail: String(roundOff(Number(v.value))),
134-
}))
131+
const varMentionData: Completion[] = prevVariables.map((v) => {
132+
const roundedWithUnits = (() => {
133+
if (typeof v.value !== 'number' || !v.ty) {
134+
return undefined
135+
}
136+
const numWithUnits = formatNumberValue(v.value, v.ty)
137+
if (err(numWithUnits)) {
138+
return undefined
139+
}
140+
return roundOffWithUnits(numWithUnits)
141+
})()
142+
return {
143+
label: v.key,
144+
detail: roundedWithUnits ?? String(roundOff(Number(v.value))),
145+
}
146+
})
135147
const varMentionsExtension = varMentions(varMentionData)
136148

137149
const { setContainer, view } = useCodeMirror({
@@ -282,7 +294,7 @@ function CommandBarKclInput({
282294
) : calcResult === 'NAN' ? (
283295
"Can't calculate"
284296
) : (
285-
roundOff(Number(calcResult), 4)
297+
roundOffWithUnits(calcResult, 4)
286298
)}
287299
</span>
288300
</label>

src/lang/queryAst.test.ts

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -63,11 +63,36 @@ variableBelowShouldNotBeIncluded = 3
6363
execState.variables,
6464
topLevelRange(rangeStart, rangeStart)
6565
)
66+
const defaultTy = {
67+
type: 'Default',
68+
angle: {
69+
type: 'Degrees',
70+
},
71+
len: {
72+
type: 'Mm',
73+
},
74+
}
6675
expect(variables).toEqual([
67-
{ key: 'baseThick', value: 1 },
68-
{ key: 'armAngle', value: 60 },
69-
{ key: 'baseThickHalf', value: 0.5 },
70-
{ key: 'halfArmAngle', value: 30 },
76+
{
77+
key: 'baseThick',
78+
value: 1,
79+
ty: defaultTy,
80+
},
81+
{
82+
key: 'armAngle',
83+
value: 60,
84+
ty: defaultTy,
85+
},
86+
{
87+
key: 'baseThickHalf',
88+
value: 0.5,
89+
ty: defaultTy,
90+
},
91+
{
92+
key: 'halfArmAngle',
93+
value: 30,
94+
ty: defaultTy,
95+
},
7196
// no arrExpShouldNotBeIncluded, variableBelowShouldNotBeIncluded etc
7297
])
7398
// there are 4 number variables and 2 non-number variables before the sketch var

src/lang/queryAst.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ import type { OpKclValue, Operation } from '@rust/kcl-lib/bindings/Operation'
5555
import { ARG_INDEX_FIELD, LABELED_ARG_FIELD } from '@src/lang/queryAstConstants'
5656
import type { KclCommandValue } from '@src/lib/commandTypes'
5757
import type { UnaryExpression } from 'typescript'
58+
import type { NumericType } from '@rust/kcl-lib/bindings/NumericType'
5859

5960
/**
6061
* Retrieves a node from a given path within a Program node structure, optionally stopping at a specified node type.
@@ -306,6 +307,7 @@ export function traverse(
306307
export interface PrevVariable<T> {
307308
key: string
308309
value: T
310+
ty: NumericType | undefined
309311
}
310312

311313
export function findAllPreviousVariablesPath(
@@ -353,6 +355,7 @@ export function findAllPreviousVariablesPath(
353355
variables.push({
354356
key: varName,
355357
value: varValue.value,
358+
ty: varValue.type === 'Number' ? varValue.ty : undefined,
356359
})
357360
})
358361

0 commit comments

Comments
 (0)