Merge branch 'main' into nadro/adhoc/system-io-machine

This commit is contained in:
Kevin Nadro
2025-04-15 13:06:24 -06:00
188 changed files with 8721 additions and 1815 deletions

View File

@ -344,6 +344,15 @@ export type CommandArgument<
machineContext?: ContextFrom<T>
) => OutputType)
}
| {
inputType: 'path'
defaultValue?:
| OutputType
| ((
commandBarContext: ContextFrom<typeof commandBarMachine>,
machineContext?: ContextFrom<T>
) => OutputType)
}
| {
inputType: 'text'
defaultValue?:

View File

@ -5,10 +5,8 @@ import type { OsInfo } from '@rust/kcl-lib/bindings/OsInfo'
import type { WebrtcStats } from '@rust/kcl-lib/bindings/WebrtcStats'
import type CodeManager from '@src/lang/codeManager'
import type {
CommandLog,
EngineCommandManager,
} from '@src/lang/std/engineConnection'
import type { CommandLog } from '@src/lang/std/commandLog'
import type { EngineCommandManager } from '@src/lang/std/engineConnection'
import { isDesktop } from '@src/lib/isDesktop'
import type RustContext from '@src/lib/rustContext'
import screenshot from '@src/lib/screenshot'

View File

@ -1,3 +1,4 @@
import { relevantFileExtensions } from '@src/lang/wasmUtils'
import {
FILE_EXT,
INDEX_IDENTIFIER,
@ -200,14 +201,20 @@ export function getNextFileName({
entryName: string
baseDir: string
}) {
// Preserve the extension in case of a relevant but foreign file
let extension = window.electron.path.extname(entryName)
if (!relevantFileExtensions().includes(extension.replace('.', ''))) {
extension = FILE_EXT
}
// Remove any existing index from the name before adding a new one
let createdName = entryName.replace(FILE_EXT, '') + FILE_EXT
let createdName = entryName.replace(extension, '') + extension
let createdPath = window.electron.path.join(baseDir, createdName)
let i = 1
while (window.electron.exists(createdPath)) {
const matchOnIndexAndExtension = new RegExp(`(-\\d+)?(${FILE_EXT})?$`)
const matchOnIndexAndExtension = new RegExp(`(-\\d+)?(${extension})?$`)
createdName =
entryName.replace(matchOnIndexAndExtension, '') + `-${i}` + FILE_EXT
entryName.replace(matchOnIndexAndExtension, '') + `-${i}` + extension
createdPath = window.electron.path.join(baseDir, createdName)
i++
}

View File

@ -1,10 +1,10 @@
import type { Stats } from 'fs'
import * as path from 'path'
import {
importFileExtensions,
relevantFileExtensions,
} from '@src/lang/wasmUtils'
import type { Stats } from 'fs'
import * as fs from 'fs/promises'
import * as path from 'path'
import { PROJECT_ENTRYPOINT } from '@src/lib/constants'

View File

@ -28,16 +28,17 @@ import type { CommandBarContext } from '@src/machines/commandBarMachine'
import { IS_NIGHTLY_OR_DEBUG } from '@src/routes/utils'
interface OnSubmitProps {
sampleName: string
code: string
sampleUnits?: UnitLength_type
name: string
content?: string
targetPathToClone?: string
method: 'overwrite' | 'newFile'
source: 'kcl-samples' | 'local'
}
interface KclCommandConfig {
// TODO: find a different approach that doesn't require
// special props for a single command
specialPropsForSampleCommand: {
specialPropsForLoadCommand: {
onSubmit: (p: OnSubmitProps) => Promise<void>
providedOptions: CommandArgumentOption<string>[]
}
@ -170,69 +171,108 @@ export function kclCommands(commandProps: KclCommandConfig): Command[] {
},
},
{
name: 'open-kcl-example',
displayName: 'Open sample',
description: 'Imports an example KCL program into the editor.',
name: 'load-external-model',
displayName: 'Load external model',
description:
'Loads a model from an external source into the current project.',
needsReview: true,
icon: 'code',
icon: 'importFile',
reviewMessage: ({ argumentsToSubmit }) =>
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.',
}),
argumentsToSubmit['method'] === 'overwrite'
? CommandBarOverwriteWarning({
heading: 'Overwrite current file with sample?',
message:
'This will erase your current file and load the sample part.',
})
: 'This will create a new file in the current project and open it.',
groupId: 'code',
onSubmit(data) {
if (!data?.sample) {
return
if (!data) {
return new Error('No input data')
}
const pathParts = data.sample.split('/')
const projectPathPart = pathParts[0]
const primaryKclFile = pathParts[1]
// local only
const sampleCodeUrl =
(isDesktop() ? '.' : '') +
`/kcl-samples/${encodeURIComponent(
projectPathPart
)}/${encodeURIComponent(primaryKclFile)}`
fetch(sampleCodeUrl)
.then(async (codeResponse): Promise<OnSubmitProps> => {
if (!codeResponse.ok) {
console.error(
'Failed to fetch sample code:',
codeResponse.statusText
)
return Promise.reject(new Error('Failed to fetch sample code'))
}
const code = await codeResponse.text()
return {
sampleName: data.sample.split('/')[0] + FILE_EXT,
code,
method: data.method,
}
})
.then((props) => {
if (props?.code) {
commandProps.specialPropsForSampleCommand
.onSubmit(props)
const { method, source, sample, path } = data
if (source === 'local' && path) {
commandProps.specialPropsForLoadCommand
.onSubmit({
name: '',
targetPathToClone: path,
method,
source,
})
.catch(reportError)
} else if (source === 'kcl-samples' && sample) {
const pathParts = sample.split('/')
const projectPathPart = pathParts[0]
const primaryKclFile = pathParts[1]
// local only
const sampleCodeUrl =
(isDesktop() ? '.' : '') +
`/kcl-samples/${encodeURIComponent(
projectPathPart
)}/${encodeURIComponent(primaryKclFile)}`
fetch(sampleCodeUrl)
.then(async (codeResponse) => {
if (!codeResponse.ok) {
console.error(
'Failed to fetch sample code:',
codeResponse.statusText
)
return Promise.reject(new Error('Failed to fetch sample code'))
}
const code = await codeResponse.text()
commandProps.specialPropsForLoadCommand
.onSubmit({
name: data.sample.split('/')[0] + FILE_EXT,
content: code,
source,
method,
})
.catch(reportError)
}
})
.catch(reportError)
})
.catch(reportError)
} else {
toast.error("The command couldn't be submitted, check the arguments.")
}
},
args: {
method: {
source: {
inputType: 'options',
required: true,
skip: false,
defaultValue: 'local',
hidden: !isDesktop(),
options() {
return [
{
value: 'kcl-samples',
name: 'KCL Samples',
isCurrent: true,
},
...(isDesktop()
? [
{
value: 'local',
name: 'Local Drive',
isCurrent: false,
},
]
: []),
]
},
},
method: {
inputType: 'options',
skip: true,
required: (commandContext) =>
!['local'].includes(
commandContext.argumentsToSubmit.source as string
),
hidden: (commandContext) =>
['local'].includes(
commandContext.argumentsToSubmit.source as string
),
defaultValue: isDesktop() ? 'newFile' : 'overwrite',
options() {
return [
@ -255,7 +295,14 @@ export function kclCommands(commandProps: KclCommandConfig): Command[] {
},
sample: {
inputType: 'options',
required: true,
required: (commandContext) =>
!['local'].includes(
commandContext.argumentsToSubmit.source as string
),
hidden: (commandContext) =>
['local'].includes(
commandContext.argumentsToSubmit.source as string
),
valueSummary(value) {
const MAX_LENGTH = 12
if (typeof value === 'string') {
@ -265,7 +312,15 @@ export function kclCommands(commandProps: KclCommandConfig): Command[] {
}
return value
},
options: commandProps.specialPropsForSampleCommand.providedOptions,
options: commandProps.specialPropsForLoadCommand.providedOptions,
},
path: {
inputType: 'path',
valueSummary: (value) => window.electron.path.basename(value),
required: (commandContext) =>
['local'].includes(
commandContext.argumentsToSubmit.source as string
),
},
},
},

View File

@ -1,4 +1,4 @@
import type { Operation, OpKclValue } from '@rust/kcl-lib/bindings/Operation'
import type { OpKclValue, Operation } from '@rust/kcl-lib/bindings/Operation'
import type { CustomIconName } from '@src/components/CustomIcon'
import { getNodePathFromSourceRange } from '@src/lang/queryAstNodePathUtils'

View File

@ -1,4 +1,4 @@
import { useRef, useState } from 'react'
import { useRef } from 'react'
import type { CameraOrbitType } from '@rust/kcl-lib/bindings/CameraOrbitType'
import type { CameraProjectionType } from '@rust/kcl-lib/bindings/CameraProjectionType'
@ -6,7 +6,6 @@ import type { NamedView } from '@rust/kcl-lib/bindings/NamedView'
import type { OnboardingStatus } from '@rust/kcl-lib/bindings/OnboardingStatus'
import { CustomIcon } from '@src/components/CustomIcon'
import { Toggle } from '@src/components/Toggle/Toggle'
import Tooltip from '@src/components/Tooltip'
import type { CameraSystem } from '@src/lib/cameraControls'
import { cameraMouseDragGuards, cameraSystems } from '@src/lib/cameraControls'
@ -216,104 +215,37 @@ export function createSettings() {
hideOnLevel: 'project',
description: 'Save bandwidth & battery',
validate: (v) =>
v === undefined ||
(typeof v === 'number' &&
v >= 1 * MS_IN_MINUTE &&
v <= 60 * MS_IN_MINUTE),
Component: ({
value: settingValueInStorage,
updateValue: writeSettingValueToStorage,
}) => {
const [timeoutId, setTimeoutId] = useState<
ReturnType<typeof setTimeout> | undefined
>(undefined)
const [preview, setPreview] = useState(
settingValueInStorage === undefined
? settingValueInStorage
: settingValueInStorage / MS_IN_MINUTE
)
const onChangeRange = (e: React.SyntheticEvent) => {
if (
!(
e.isTrusted &&
'value' in e.currentTarget &&
e.currentTarget.value
)
)
return
setPreview(Number(e.currentTarget.value))
}
const onSaveRange = (e: React.SyntheticEvent) => {
if (preview === undefined) return
if (
!(
e.isTrusted &&
'value' in e.currentTarget &&
e.currentTarget.value
)
)
return
writeSettingValueToStorage(
Number(e.currentTarget.value) * MS_IN_MINUTE
)
}
return (
<div className="flex item-center gap-4 m-0 py-0">
<Toggle
name="streamIdleModeToggle"
offLabel="Off"
onLabel="On"
checked={settingValueInStorage !== undefined}
onChange={(event: React.SyntheticEvent<HTMLInputElement>) => {
if (timeoutId) {
return
}
const isChecked = event.currentTarget.checked
clearTimeout(timeoutId)
setTimeoutId(
setTimeout(() => {
const requested = !isChecked ? undefined : 5
setPreview(requested)
writeSettingValueToStorage(
requested === undefined
? undefined
: Number(requested) * MS_IN_MINUTE
)
setTimeoutId(undefined)
}, 100)
)
}}
className="block w-4 h-4"
/>
<div className="flex flex-col grow">
<input
type="range"
onChange={onChangeRange}
onMouseUp={onSaveRange}
onKeyUp={onSaveRange}
onPointerUp={onSaveRange}
disabled={preview === undefined}
value={
preview !== null && preview !== undefined ? preview : 5
}
min={1}
max={60}
step={1}
className="block flex-1"
/>
{preview !== undefined && preview !== null && (
<div>
{preview / MS_IN_MINUTE === 60
? '1 hour'
: preview / MS_IN_MINUTE === 1
? '1 minute'
: preview + ' minutes'}
</div>
)}
</div>
</div>
)
String(v) == 'undefined' ||
(Number(v) >= 0 && Number(v) <= 60 * MS_IN_MINUTE),
commandConfig: {
inputType: 'options',
defaultValueFromContext: (context) =>
context.app.streamIdleMode.current,
options: (cmdContext, settingsContext) =>
[
undefined,
5 * 1000,
30 * 1000,
1 * MS_IN_MINUTE,
2 * MS_IN_MINUTE,
5 * MS_IN_MINUTE,
15 * MS_IN_MINUTE,
30 * MS_IN_MINUTE,
60 * MS_IN_MINUTE,
].map((v) => ({
name:
v === undefined
? 'Off'
: v < MS_IN_MINUTE
? `${Math.floor(v / 1000)} seconds`
: `${Math.floor(v / MS_IN_MINUTE)} minutes`,
value: v,
isCurrent:
v ===
settingsContext.app.streamIdleMode[
cmdContext.argumentsToSubmit.level as SettingsLevel
],
})),
},
}),
allowOrbitInSketchMode: new Setting<boolean>({

View File

@ -44,7 +44,12 @@ export const kclManager = new KclManager(engineCommandManager, {
// CYCLIC REF
editorManager.kclManager = kclManager
// These are all late binding because of their circular dependency.
// TODO: proper dependency injection.
engineCommandManager.kclManager = kclManager
engineCommandManager.codeManager = codeManager
engineCommandManager.rustContext = rustContext
kclManager.sceneInfraBaseUnitMultiplierSetter = (unit: BaseUnit) => {
sceneInfra.baseUnit = unit
}

View File

@ -3,6 +3,7 @@ import type { EventFrom, StateFrom } from 'xstate'
import type { CustomIconName } from '@src/components/CustomIcon'
import { createLiteral } from '@src/lang/create'
import { isDesktop } from '@src/lib/isDesktop'
import { commandBarActor } from '@src/machines/commandBarMachine'
import type { modelingMachine } from '@src/machines/modelingMachine'
import {
@ -337,6 +338,50 @@ export const toolbarConfig: Record<ToolbarModeName, ToolbarMode> = {
links: [{ label: 'KCL docs', url: 'https://zoo.dev/docs/kcl/helix' }],
},
'break',
{
id: 'modules',
array: [
{
id: 'insert',
onClick: () =>
commandBarActor.send({
type: 'Find and select command',
data: { name: 'Insert', groupId: 'code' },
}),
hotkey: 'I',
icon: 'import',
status: DEV || IS_NIGHTLY_OR_DEBUG ? 'available' : 'kcl-only',
disabled: () => !isDesktop(),
title: 'Insert',
description: 'Insert from a file in the current project directory',
links: [
{
label: 'API docs',
url: 'https://zoo.dev/docs/kcl/import',
},
],
},
{
id: 'transform',
icon: 'angle',
status: 'kcl-only',
title: 'Transform',
description: 'Apply a translation and/or rotation to a module',
onClick: () => undefined,
links: [
{
label: 'API docs',
url: 'https://zoo.dev/docs/kcl/translate',
},
{
label: 'API docs',
url: 'https://zoo.dev/docs/kcl/rotate',
},
],
},
],
},
'break',
{
id: 'ai',
array: [

View File

@ -23,6 +23,10 @@ export function isArray(val: any): val is unknown[] {
return Array.isArray(val)
}
export type SafeArray<T> = Omit<Array<T>, number> & {
[index: number]: T | undefined
}
/**
* An alternative to `Object.keys()` that returns an array of keys with types.
*

View File

@ -1,6 +1,7 @@
import type { Coords2d } from '@src/lang/std/sketch'
import { isPointsCCW } from '@src/lang/wasm'
import { initPromise } from '@src/lang/wasmUtils'
import { closestPointOnRay } from '@src/lib/utils2d'
beforeAll(async () => {
await initPromise
@ -20,3 +21,72 @@ describe('test isPointsCW', () => {
expect(CW).toBe(-1)
})
})
describe('test closestPointOnRay', () => {
test('point lies on ray', () => {
const rayOrigin: Coords2d = [0, 0]
const rayDirection: Coords2d = [1, 0]
const pointToCheck: Coords2d = [7, 0]
const result = closestPointOnRay(rayOrigin, rayDirection, pointToCheck)
expect(result.closestPoint).toEqual([7, 0])
expect(result.t).toBe(7)
})
test('point is above ray', () => {
const rayOrigin: Coords2d = [1, 0]
const rayDirection: Coords2d = [1, 0]
const pointToCheck: Coords2d = [7, 7]
const result = closestPointOnRay(rayOrigin, rayDirection, pointToCheck)
expect(result.closestPoint).toEqual([7, 0])
expect(result.t).toBe(6)
})
test('point lies behind ray origin and allowNegative=false', () => {
const rayOrigin: Coords2d = [0, 0]
const rayDirection: Coords2d = [1, 0]
const pointToCheck: Coords2d = [-7, 7]
const result = closestPointOnRay(rayOrigin, rayDirection, pointToCheck)
expect(result.closestPoint).toEqual([0, 0])
expect(result.t).toBe(0)
})
test('point lies behind ray origin and allowNegative=true', () => {
const rayOrigin: Coords2d = [0, 0]
const rayDirection: Coords2d = [1, 0]
const pointToCheck: Coords2d = [-7, 7]
const result = closestPointOnRay(
rayOrigin,
rayDirection,
pointToCheck,
true
)
expect(result.closestPoint).toEqual([-7, 0])
expect(result.t).toBe(-7)
})
test('diagonal ray and point', () => {
const rayOrigin: Coords2d = [0, 0]
const rayDirection: Coords2d = [1, 1]
const pointToCheck: Coords2d = [3, 4]
const result = closestPointOnRay(rayOrigin, rayDirection, pointToCheck)
expect(result.closestPoint[0]).toBeCloseTo(3.5)
expect(result.closestPoint[1]).toBeCloseTo(3.5)
expect(result.t).toBeCloseTo(4.95, 1)
})
test('non-normalized direction vector', () => {
const rayOrigin: Coords2d = [0, 0]
const rayDirection: Coords2d = [2, 2]
const pointToCheck: Coords2d = [3, 4]
const result = closestPointOnRay(rayOrigin, rayDirection, pointToCheck)
expect(result.closestPoint[0]).toBeCloseTo(3.5)
expect(result.closestPoint[1]).toBeCloseTo(3.5)
expect(result.t).toBeCloseTo(4.95, 1)
})
})

View File

@ -17,3 +17,38 @@ export function getTangentPointFromPreviousArc(
Math.sin(deg2Rad(tangentialAngle)) * 10 + lastArcEnd[1],
]
}
export function closestPointOnRay(
rayOrigin: Coords2d,
rayDirection: Coords2d,
pointToCheck: Coords2d,
allowNegative = false
) {
const dirMagnitude = Math.sqrt(
rayDirection[0] * rayDirection[0] + rayDirection[1] * rayDirection[1]
)
const normalizedDir: Coords2d = [
rayDirection[0] / dirMagnitude,
rayDirection[1] / dirMagnitude,
]
const originToPoint: Coords2d = [
pointToCheck[0] - rayOrigin[0],
pointToCheck[1] - rayOrigin[1],
]
let t =
originToPoint[0] * normalizedDir[0] + originToPoint[1] * normalizedDir[1]
if (!allowNegative) {
t = Math.max(0, t)
}
return {
closestPoint: [
rayOrigin[0] + normalizedDir[0] * t,
rayOrigin[1] + normalizedDir[1] * t,
] as Coords2d,
t,
}
}