BREAKING: KCL @settings are the source of truth for units (#5808)

This commit is contained in:
Jonathan Tran
2025-03-31 10:56:03 -04:00
committed by GitHub
parent eac5abba79
commit efc8c82d8b
237 changed files with 820 additions and 2146 deletions

View File

@ -1,11 +1,11 @@
interface CommandBarOverwriteWarningProps {
heading?: string
message?: string
heading: string
message: string
}
export function CommandBarOverwriteWarning({
heading = 'Overwrite current file and units?',
message = 'This will permanently replace the current code in the editor, and overwrite your current units.',
heading,
message,
}: CommandBarOverwriteWarningProps) {
return (
<>

View File

@ -15,6 +15,7 @@ import {
import { fileMachine } from 'machines/fileMachine'
import { isDesktop } from 'lib/isDesktop'
import {
DEFAULT_DEFAULT_LENGTH_UNIT,
DEFAULT_FILE_NAME,
DEFAULT_PROJECT_KCL_FILE,
FILE_EXT,
@ -29,11 +30,12 @@ import {
} from 'lib/getKclSamplesManifest'
import { markOnce } from 'lib/performance'
import { commandBarActor } from 'machines/commandBarMachine'
import { settingsActor, useSettings } from 'machines/appMachine'
import { useSettings } from 'machines/appMachine'
import { createRouteCommands } from 'lib/commandBarConfigs/routeCommandConfig'
import { useToken } from 'machines/appMachine'
import { createNamedViewsCommand } from 'lib/commandBarConfigs/namedViewsConfig'
import { reportRejection } from 'lib/trap'
import { err, reportRejection } from 'lib/trap'
import { newKclFile } from 'lang/project'
type MachineContext<T extends AnyStateMachine> = {
state: StateFrom<T>
@ -237,7 +239,12 @@ export const FileMachineProvider = ({
createdPath
)
} else {
await window.electron.writeFile(createdPath, input.content ?? '')
const codeToWrite = newKclFile(
input.content,
settings.modeling.defaultUnit.current
)
if (err(codeToWrite)) return Promise.reject(codeToWrite)
await window.electron.writeFile(createdPath, codeToWrite)
}
}
@ -266,7 +273,12 @@ export const FileMachineProvider = ({
})
createdName = name
createdPath = path
await window.electron.writeFile(createdPath, input.content ?? '')
const codeToWrite = newKclFile(
input.content,
settings.modeling.defaultUnit.current
)
if (err(codeToWrite)) return Promise.reject(codeToWrite)
await window.electron.writeFile(createdPath, codeToWrite)
}
return {
@ -357,10 +369,16 @@ export const FileMachineProvider = ({
const hasKclEntries =
entries.filter((e: string) => e.endsWith('.kcl')).length !== 0
if (!hasKclEntries) {
await window.electron.writeFile(
window.electron.path.join(project.path, DEFAULT_PROJECT_KCL_FILE),
''
const codeToWrite = newKclFile(
undefined,
settings.modeling.defaultUnit.current
)
if (err(codeToWrite)) return Promise.reject(codeToWrite)
const path = window.electron.path.join(
project.path,
DEFAULT_PROJECT_KCL_FILE
)
await window.electron.writeFile(path, codeToWrite)
// Refresh the route selected above because it's possible we're on
// the same path on the navigate, which doesn't cause anything to
// refresh, leaving a stale execution state.
@ -401,7 +419,9 @@ export const FileMachineProvider = ({
authToken: token ?? '',
projectData,
settings: {
defaultUnit: settings.modeling.defaultUnit.current ?? 'mm',
defaultUnit:
settings.modeling.defaultUnit.current ??
DEFAULT_DEFAULT_LENGTH_UNIT,
},
specialPropsForSampleCommand: {
onSubmit: async (data) => {
@ -419,18 +439,6 @@ export const FileMachineProvider = ({
},
})
}
// Either way, we want to overwrite the defaultUnit project setting
// with the sample's setting.
if (data.sampleUnits) {
settingsActor.send({
type: 'set.modeling.defaultUnit',
data: {
level: 'project',
value: data.sampleUnits,
},
})
}
},
providedOptions: kclSamples.map((sample) => ({
value: sample.pathFromProjectDirectoryToFirstFile,

View File

@ -32,6 +32,8 @@ import {
} from 'lib/constants'
import { codeManager, kclManager } from 'lib/singletons'
import { Project } from 'lib/project'
import { newKclFile } from 'lang/project'
import { err } from 'lib/trap'
type MachineContext<T extends AnyStateMachine> = {
state?: StateFrom<T>
@ -120,7 +122,13 @@ const ProjectsContextWeb = ({ children }: { children: React.ReactNode }) => {
createFile: fromPromise(async ({ input }) => {
// Browser version doesn't navigate, just overwrites the current file
clearImportSearchParams()
codeManager.updateCodeStateEditor(input.code || '')
const codeToWrite = newKclFile(
input.code,
settings.modeling.defaultUnit.current
)
if (err(codeToWrite)) return Promise.reject(codeToWrite)
codeManager.updateCodeStateEditor(codeToWrite)
await codeManager.writeToFile()
await kclManager.executeCode(true)
@ -388,8 +396,10 @@ const ProjectsContextDesktop = ({
}
// Create the project around the file if newProject
let fileLoaded = false
if (input.method === 'newProject') {
await createNewProjectDirectory(projectName, input.code)
fileLoaded = true
message = `Project "${projectName}" created successfully with link contents`
} else {
message = `File "${fileName}" created successfully`
@ -406,8 +416,16 @@ const ProjectsContextDesktop = ({
})
fileName = name
await window.electron.writeFile(path, input.code || '')
if (!fileLoaded) {
const codeToWrite = newKclFile(
input.code,
settings.modeling.defaultUnit.current
)
if (err(codeToWrite)) return Promise.reject(codeToWrite)
await window.electron.writeFile(path, codeToWrite)
}
// TODO: Return the project's file name if one was created.
return {
message,
fileName,

View File

@ -1,6 +1,11 @@
import { Popover } from '@headlessui/react'
import { settingsActor, useSettings } from 'machines/appMachine'
import { changeKclSettings, unitLengthToUnitLen } from 'lang/wasm'
import {
changeKclSettings,
unitAngleToUnitAng,
unitLengthToUnitLen,
} from 'lang/wasm'
import { DEFAULT_DEFAULT_ANGLE_UNIT } from 'lib/constants'
import { DEFAULT_DEFAULT_LENGTH_UNIT } from 'lib/constants'
import { baseUnitLabels, baseUnitsUnion } from 'lib/settings/settingsTypes'
import { codeManager, kclManager } from 'lib/singletons'
import { err, reportRejection } from 'lib/trap'
@ -8,24 +13,10 @@ import { useEffect, useState } from 'react'
import toast from 'react-hot-toast'
export function UnitsMenu() {
const settings = useSettings()
const [hasPerFileLengthUnit, setHasPerFileLengthUnit] = useState(
Boolean(kclManager.fileSettings.defaultLengthUnit)
)
const [lengthSetting, setLengthSetting] = useState(
kclManager.fileSettings.defaultLengthUnit ||
settings.modeling.defaultUnit.current
)
const [fileSettings, setFileSettings] = useState(kclManager.fileSettings)
useEffect(() => {
setHasPerFileLengthUnit(Boolean(kclManager.fileSettings.defaultLengthUnit))
setLengthSetting(
kclManager.fileSettings.defaultLengthUnit ||
settings.modeling.defaultUnit.current
)
}, [
kclManager.fileSettings.defaultLengthUnit,
settings.modeling.defaultUnit.current,
])
setFileSettings(kclManager.fileSettings)
}, [kclManager.fileSettings])
return (
<Popover className="relative pointer-events-auto">
@ -41,7 +32,7 @@ export function UnitsMenu() {
<div className="absolute w-[1px] h-[1em] bg-primary right-0 top-1/2 -translate-y-1/2"></div>
</div>
<span className="sr-only">Current units are:&nbsp;</span>
{lengthSetting}
{fileSettings.defaultLengthUnit ?? DEFAULT_DEFAULT_LENGTH_UNIT}
</Popover.Button>
<Popover.Panel
className={`absolute bottom-full right-0 mb-2 w-48 bg-chalkboard-10 dark:bg-chalkboard-90
@ -54,40 +45,35 @@ export function UnitsMenu() {
<button
className="flex items-center gap-2 m-0 py-1.5 px-2 cursor-pointer hover:bg-chalkboard-20 dark:hover:bg-chalkboard-80 border-none text-left"
onClick={() => {
if (hasPerFileLengthUnit) {
const newCode = changeKclSettings(codeManager.code, {
defaultLengthUnits: unitLengthToUnitLen(unit),
defaultAngleUnits: { type: 'Degrees' },
})
if (err(newCode)) {
toast.error(
`Failed to set per-file units: ${newCode.message}`
)
} else {
codeManager.updateCodeStateEditor(newCode)
Promise.all([
codeManager.writeToFile(),
kclManager.executeCode(),
])
.then(() => {
toast.success(`Updated per-file units to ${unit}`)
})
.catch(reportRejection)
}
const newCode = changeKclSettings(codeManager.code, {
defaultLengthUnits: unitLengthToUnitLen(unit),
defaultAngleUnits: unitAngleToUnitAng(
fileSettings.defaultAngleUnit ??
DEFAULT_DEFAULT_ANGLE_UNIT
),
})
if (err(newCode)) {
toast.error(
`Failed to set per-file units: ${newCode.message}`
)
} else {
settingsActor.send({
type: 'set.modeling.defaultUnit',
data: {
level: 'project',
value: unit,
},
})
codeManager.updateCodeStateEditor(newCode)
Promise.all([
codeManager.writeToFile(),
kclManager.executeCode(),
])
.then(() => {
toast.success(`Updated per-file units to ${unit}`)
})
.catch(reportRejection)
}
close()
}}
>
<span className="flex-1">{baseUnitLabels[unit]}</span>
{unit === lengthSetting && (
{unit ===
(fileSettings.defaultLengthUnit ??
DEFAULT_DEFAULT_LENGTH_UNIT) && (
<span className="text-chalkboard-60">current</span>
)}
</button>

33
src/lang/project.ts Normal file
View File

@ -0,0 +1,33 @@
import { UnitLength } from '@rust/kcl-lib/bindings/ModelingCmd'
import {
changeKclSettings,
unitAngleToUnitAng,
unitLengthToUnitLen,
} from './wasm'
import { DEFAULT_DEFAULT_ANGLE_UNIT } from 'lib/constants'
import { DEFAULT_DEFAULT_LENGTH_UNIT } from 'lib/constants'
/**
* Create a new KCL file with the given initial content and default length unit.
* @returns KCL string
*/
export function newKclFile(
initialContent: string | undefined,
defaultLengthUnit: UnitLength
): string | Error {
// If we're given initial content, we're loading a file that should already
// have units in it. Don't modify it.
if (initialContent !== undefined) {
return initialContent
}
// If the default length unit is the same as the default default length unit,
// there's no need to add the attribute.
if (defaultLengthUnit === DEFAULT_DEFAULT_LENGTH_UNIT) {
return ''
}
return changeKclSettings('', {
defaultLengthUnits: unitLengthToUnitLen(defaultLengthUnit),
defaultAngleUnits: unitAngleToUnitAng(DEFAULT_DEFAULT_ANGLE_UNIT),
})
}

View File

@ -18,6 +18,7 @@ import {
serialize_project_configuration,
serialize_configuration,
reloadModule,
is_kcl_empty_or_only_settings,
} from 'lib/wasm_lib_wrapper'
import { KCLError } from './errors'
@ -55,6 +56,10 @@ import { UnitAngle as UnitAng } from '@rust/kcl-lib/bindings/UnitAngle'
import { ModulePath } from '@rust/kcl-lib/bindings/ModulePath'
import { DefaultPlanes } from '@rust/kcl-lib/bindings/DefaultPlanes'
import { isArray } from 'lib/utils'
import {
DEFAULT_DEFAULT_ANGLE_UNIT,
DEFAULT_DEFAULT_LENGTH_UNIT,
} from 'lib/constants'
export type { Artifact } from '@rust/kcl-lib/bindings/Artifact'
export type { ArtifactCommand } from '@rust/kcl-lib/bindings/Artifact'
@ -607,7 +612,27 @@ export function changeKclSettings(
}
/**
* Convert a `UnitLength_type` to a `UnitLen`
* Returns true if the given KCL is empty or only contains settings that would
* be auto-generated.
*/
export function isKclEmptyOrOnlySettings(kcl: string): boolean {
if (kcl === '') {
// Fast path.
return true
}
try {
return is_kcl_empty_or_only_settings(kcl)
} catch (e) {
console.debug('Caught error checking if KCL is empty', e)
// If there's a parse error, it can't be empty or auto-generated.
return false
}
}
/**
* Convert a `UnitLength` (used in settings and modeling commands) to a
* `UnitLen` (used in execution).
*/
export function unitLengthToUnitLen(input: UnitLength): UnitLen {
switch (input) {
@ -627,7 +652,8 @@ export function unitLengthToUnitLen(input: UnitLength): UnitLen {
}
/**
* Convert `UnitLen` to `UnitLength_type`.
* Convert `UnitLen` (used in execution) to `UnitLength` (used in settings
* and modeling commands).
*/
export function unitLenToUnitLength(input: UnitLen): UnitLength {
switch (input.type) {
@ -642,19 +668,33 @@ export function unitLenToUnitLength(input: UnitLen): UnitLength {
case 'Inches':
return 'in'
default:
return 'mm'
return DEFAULT_DEFAULT_LENGTH_UNIT
}
}
/**
* Convert `UnitAngle` to `UnitAngle_type`.
* Convert a `UnitAngle` (used in modeling commands) to a `UnitAng` (used in
* execution).
*/
export function unitAngleToUnitAng(input: UnitAngle): UnitAng {
switch (input) {
case 'radians':
return { type: 'Radians' }
default:
return { type: 'Degrees' }
}
}
/**
* Convert `UnitAng` (used in execution) to `UnitAngle` (used in modeling
* commands).
*/
export function unitAngToUnitAngle(input: UnitAng): UnitAngle {
switch (input.type) {
case 'Radians':
return 'radians'
default:
return 'degrees'
return DEFAULT_DEFAULT_ANGLE_UNIT
}
}

View File

@ -1,4 +1,5 @@
import { Models } from '@kittycad/lib/dist/types/src'
import { UnitAngle, UnitLength } from '@rust/kcl-lib/bindings/ModelingCmd'
export const APP_NAME = 'Modeling App'
/** Search string in new project names to increment as an index */
@ -168,6 +169,18 @@ export const ZOO_STUDIO_PROTOCOL = 'zoo-studio'
*/
export const ASK_TO_OPEN_QUERY_PARAM = 'ask-open-desktop'
/**
* When no annotation is in the KCL file to specify the defaults, we use these
* default units.
*/
export const DEFAULT_DEFAULT_ANGLE_UNIT: UnitAngle = 'degrees'
/**
* When no annotation is in the KCL file to specify the defaults, we use these
* default units.
*/
export const DEFAULT_DEFAULT_LENGTH_UNIT: UnitLength = 'mm'
/** Real execution. */
export const EXECUTION_TYPE_REAL = 'real'
/** Mock execution. */

View File

@ -9,6 +9,7 @@ import {
parseProjectSettings,
} from 'lang/wasm'
import {
DEFAULT_DEFAULT_LENGTH_UNIT,
PROJECT_ENTRYPOINT,
PROJECT_FOLDER,
PROJECT_IMAGE_NAME,
@ -21,6 +22,7 @@ import {
import { DeepPartial } from './types'
import { ProjectConfiguration } from '@rust/kcl-lib/bindings/ProjectConfiguration'
import { Configuration } from '@rust/kcl-lib/bindings/Configuration'
import { newKclFile } from 'lang/project'
export async function renameProjectDirectory(
projectPath: string,
@ -113,7 +115,15 @@ export async function createNewProjectDirectory(
}
const projectFile = window.electron.path.join(projectDir, PROJECT_ENTRYPOINT)
await window.electron.writeFile(projectFile, initialCode ?? '')
// When initialCode is present, we're loading existing code. If it's not
// present, we're creating a new project, and we want to incorporate the
// user's settings.
const codeToWrite = newKclFile(
initialCode,
configuration?.settings?.modeling?.base_unit ?? DEFAULT_DEFAULT_LENGTH_UNIT
)
if (err(codeToWrite)) return Promise.reject(codeToWrite)
await window.electron.writeFile(projectFile, codeToWrite)
const metadata = await window.electron.stat(projectFile)
return {

View File

@ -2,11 +2,22 @@ import { CommandBarOverwriteWarning } from 'components/CommandBarOverwriteWarnin
import { Command, CommandArgumentOption } from './commandTypes'
import { codeManager, kclManager } from './singletons'
import { isDesktop } from './isDesktop'
import { FILE_EXT } from './constants'
import {
DEFAULT_DEFAULT_ANGLE_UNIT,
DEFAULT_DEFAULT_LENGTH_UNIT,
FILE_EXT,
} from './constants'
import { UnitLength_type } from '@kittycad/lib/dist/types/src/models'
import { reportRejection } from './trap'
import { err, reportRejection } from './trap'
import { IndexLoaderData } from './types'
import { copyFileShareLink } from './links'
import { baseUnitsUnion } from './settings/settingsTypes'
import toast from 'react-hot-toast'
import {
changeKclSettings,
unitLengthToUnitLen,
unitAngleToUnitAng,
} from 'lang/wasm'
interface OnSubmitProps {
sampleName: string
@ -31,6 +42,59 @@ interface KclCommandConfig {
export function kclCommands(commandProps: KclCommandConfig): Command[] {
return [
{
name: 'set-file-units',
displayName: 'Set file units',
description:
'Set the length unit for all dimensions not given explicit units in the current file.',
needsReview: false,
groupId: 'code',
icon: 'code',
args: {
unit: {
required: true,
inputType: 'options',
defaultValue:
kclManager.fileSettings.defaultLengthUnit ||
DEFAULT_DEFAULT_LENGTH_UNIT,
options: () =>
Object.values(baseUnitsUnion).map((v) => {
return {
name: v,
value: v,
isCurrent: kclManager.fileSettings.defaultLengthUnit
? v === kclManager.fileSettings.defaultLengthUnit
: v === DEFAULT_DEFAULT_LENGTH_UNIT,
}
}),
},
},
onSubmit: (data) => {
if (typeof data === 'object' && 'unit' in data) {
const newCode = changeKclSettings(codeManager.code, {
defaultLengthUnits: unitLengthToUnitLen(data.unit),
defaultAngleUnits: unitAngleToUnitAng(
kclManager.fileSettings.defaultAngleUnit ??
DEFAULT_DEFAULT_ANGLE_UNIT
),
})
if (err(newCode)) {
toast.error(`Failed to set per-file units: ${newCode.message}`)
} else {
codeManager.updateCodeStateEditor(newCode)
Promise.all([codeManager.writeToFile(), kclManager.executeCode()])
.then(() => {
toast.success(`Updated per-file units to ${data.unit}`)
})
.catch(reportRejection)
}
} else {
toast.error(
'Failed to set per-file units: no value provided to submit function. This is a bug.'
)
}
},
},
{
name: 'format-code',
displayName: 'Format Code',
@ -49,12 +113,18 @@ export function kclCommands(commandProps: KclCommandConfig): Command[] {
needsReview: true,
icon: 'code',
reviewMessage: ({ argumentsToSubmit }) =>
argumentsToSubmit.method === 'newFile'
? CommandBarOverwriteWarning({
heading: 'Create a new file, overwrite project units?',
message: `This will add the sample as a new file to your project, and replace your current project units with the sample's units.`,
})
: CommandBarOverwriteWarning({}),
CommandBarOverwriteWarning({
heading:
'method' in argumentsToSubmit &&
argumentsToSubmit.method === 'newFile'
? 'Create a new file from sample?'
: 'Overwrite current file with sample?',
message:
'method' in argumentsToSubmit &&
argumentsToSubmit.method === 'newFile'
? 'This will create a new file in the current project and open it.'
: 'This will erase your current file and load the sample part.',
}),
groupId: 'code',
onSubmit(data) {
if (!data?.sample) {

View File

@ -22,6 +22,7 @@ import { CameraProjectionType } from '@rust/kcl-lib/bindings/CameraProjectionTyp
import { OnboardingStatus } from '@rust/kcl-lib/bindings/OnboardingStatus'
import { NamedView } from '@rust/kcl-lib/bindings/NamedView'
import { CameraOrbitType } from '@rust/kcl-lib/bindings/CameraOrbitType'
import { DEFAULT_DEFAULT_LENGTH_UNIT } from 'lib/constants'
/**
* A setting that can be set at the user or project level
@ -300,8 +301,9 @@ export function createSettings() {
* The default unit to use in modeling dimensions
*/
defaultUnit: new Setting<BaseUnit>({
defaultValue: 'mm',
description: 'The default unit to use in modeling dimensions',
defaultValue: DEFAULT_DEFAULT_LENGTH_UNIT,
description:
'Set the default length unit setting value to give any new files.',
validate: (v) => baseUnitsUnion.includes(v),
commandConfig: {
inputType: 'options',

View File

@ -145,7 +145,7 @@ export type SaveSettingsPayload = RecursiveSettingsPayloads<typeof settings>
/**
* Annotation names for default units are defined on rust side in
* src/wasm-lib/kcl/src/execution/annotations.rs
* rust/kcl-lib/src/execution/annotations.rs
*/
export interface KclSettingsAnnotation {
defaultLengthUnit?: UnitLength_type

View File

@ -22,6 +22,7 @@ import {
base64_decode as Base64Decode,
kcl_settings as KclSettings,
change_kcl_settings as ChangeKclSettings,
is_kcl_empty_or_only_settings as IsKclEmptyOrOnlySettings,
get_kcl_version as GetKclVersion,
serialize_configuration as SerializeConfiguration,
serialize_project_configuration as SerializeProjectConfiguration,
@ -93,6 +94,11 @@ export const kcl_settings: typeof KclSettings = (...args) => {
export const change_kcl_settings: typeof ChangeKclSettings = (...args) => {
return getModule().change_kcl_settings(...args)
}
export const is_kcl_empty_or_only_settings: typeof IsKclEmptyOrOnlySettings = (
...args
) => {
return getModule().is_kcl_empty_or_only_settings(...args)
}
export const get_kcl_version: typeof GetKclVersion = () => {
return getModule().get_kcl_version()
}

View File

@ -126,7 +126,7 @@ export const fileMachine = setup({
makeDir: boolean
selectedDirectory: FileEntry
targetPathToClone?: string
content: string
content?: string
shouldSetToRename: boolean
}
}) => Promise.resolve({ message: '', path: '' })
@ -152,7 +152,7 @@ export const fileMachine = setup({
name: string
makeDir: boolean
selectedDirectory: FileEntry
content: string
content?: string
}
}) => Promise.resolve({ path: '' })
),
@ -238,7 +238,7 @@ export const fileMachine = setup({
makeDir: event.data.makeDir,
selectedDirectory: context.selectedDirectory,
targetPathToClone: event.data.targetPathToClone,
content: event.data.content ?? '',
content: event.data.content,
shouldSetToRename: event.data.shouldSetToRename ?? false,
}
},
@ -417,7 +417,7 @@ export const fileMachine = setup({
name: event.data.name,
makeDir: event.data.makeDir,
selectedDirectory: context.selectedDirectory,
content: event.data.content ?? '',
content: event.data.content,
}
},
onDone: 'Reading files',

View File

@ -14,6 +14,7 @@ import { useFileContext } from 'hooks/useFileContext'
import { useLspContext } from 'components/LspProvider'
import { reportRejection } from 'lib/trap'
import { useSettings } from 'machines/appMachine'
import { isKclEmptyOrOnlySettings } from 'lang/wasm'
/**
* Show either a welcome screen or a warning screen
@ -21,7 +22,7 @@ import { useSettings } from 'machines/appMachine'
*/
export default function OnboardingIntroduction() {
const [shouldShowWarning, setShouldShowWarning] = useState(
codeManager.code !== '' && codeManager.code !== bracket
!isKclEmptyOrOnlySettings(codeManager.code) && codeManager.code !== bracket
)
return shouldShowWarning ? (