Compare commits

..

24 Commits

Author SHA1 Message Date
1cb519e66c Make the command bar button hotkey label update live 2024-10-28 14:53:56 -04:00
5374fab199 Fix multi-step interactions post-V5 2024-10-28 14:40:16 -04:00
8ede8bc1e6 fmt 2024-10-28 14:28:43 -04:00
74c9596506 Migration interaction map machinery to XState V5 2024-10-28 13:09:21 -04:00
88030afdce Merge messed with import order 2024-10-28 11:50:58 -04:00
839b702a92 Merge branch 'main' into franknoirot/editable-hotkeys 2024-10-28 11:45:54 -04:00
d159136ef0 Merge branch 'main' into franknoirot/editable-hotkeys 2024-08-09 17:57:30 +02:00
b6ec308a4a Less churny registration using an object instead of array 2024-08-02 00:37:13 -04:00
19688a8f5f A snapshot a day keeps the bugs away! 📷🐛 (OS: ubuntu) 2024-08-01 19:33:47 +00:00
3ba79406d8 Programmatic keybindings for sidebar and toolbar 2024-08-01 15:22:47 -04:00
cac848ab22 Basic editable (not persisted) keybindings 2024-08-01 15:04:35 -04:00
b5f2e0ea3e Merge branch 'main' into franknoirot/editable-hotkeys 2024-08-01 12:52:04 -04:00
4c3eb24220 Merge branch 'main' into franknoirot/editable-hotkeys 2024-05-31 08:22:10 -04:00
cd554b7f93 Add start of recording new keymap entries (they don't get saved when submitted yet) 2024-05-28 19:36:41 -04:00
c140a038c8 Fix mistakes in merge commit 2024-05-28 18:12:27 -04:00
f5bb011cb7 Merge branch 'main' into franknoirot/editable-hotkeys 2024-05-28 17:51:42 -04:00
4b6723869e Style keybinding listings a bit, add link to view them from Help menu 2024-05-21 19:18:22 -04:00
c4b3ff3f51 Refactor Settings fields out and add tab to Keybindings to Settings 2024-05-21 19:09:17 -04:00
8c94e166ca Make buttons' sub-elements not interactable so out handlers work correctly 2024-05-21 18:31:09 -04:00
83f8b7bb93 Don't toLowerCase mouse button sequence steps 2024-05-21 18:05:14 -04:00
93f6a52740 Merge branch 'main' into franknoirot/editable-hotkeys 2024-05-21 16:43:02 -04:00
a1e654d0f8 TODO: don't let clicks get swallowed, don't rerender all the time 2024-05-14 12:02:08 -04:00
eba79867d8 Proof of concept of global interactionMap 2024-05-14 09:40:30 -04:00
21f10c8d92 Add initial interactionMachine 2024-05-10 17:57:28 -04:00
116 changed files with 29262 additions and 11962 deletions

Binary file not shown.

View File

@ -1,4 +1,3 @@
src/wasm-lib/*
src/lib/engine-utils/engine.js
*.typegen.ts
packages/codemirror-lsp-client/dist/*

View File

@ -15,6 +15,7 @@ on:
env:
CUT_RELEASE_PR: ${{ github.event_name == 'pull_request' && (contains(github.event.pull_request.title, 'Cut release v')) }}
BUILD_RELEASE: ${{ github.event_name == 'release' || github.event_name == 'schedule' || github.event_name == 'pull_request' && (contains(github.event.pull_request.title, 'Cut release v')) }}
NOTES: ${{ github.event_name == 'release' && github.event.release.body || format('Non-release build, commit {0}', github.sha) }}
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
@ -25,7 +26,6 @@ jobs:
runs-on: ubuntu-22.04 # seperate job on Ubuntu for easy string manipulations (compared to Windows)
outputs:
version: ${{ steps.export_version.outputs.version }}
notes: ${{ steps.export_version.outputs.notes }}
steps:
- uses: actions/checkout@v4
@ -55,8 +55,6 @@ jobs:
# TODO: see if we need to inject updater nightly URL here https://dl.zoo.dev/releases/modeling-app/nightly/last_update.json
- name: Generate release notes
env:
NOTES: ${{ github.event_name == 'release' && github.event.release.body || format('Non-release build, commit {0}', github.sha) }}
run: |
echo "$NOTES" > release-notes.md
cat release-notes.md
@ -84,9 +82,6 @@ jobs:
path: |
electron-builder.yml
- id: export_notes
run: echo "notes=`cat release-notes.md'`" >> "$GITHUB_OUTPUT"
- name: Prepare electron-builder.yml file for updater test
if: ${{ env.CUT_RELEASE_PR == 'true' }}
run: |
@ -114,17 +109,8 @@ jobs:
platform: linux
runs-on: ${{ matrix.os }}
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
CSC_LINK: ${{ secrets.APPLE_CERTIFICATE }}
CSC_KEY_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
CSC_KEYCHAIN: ${{ secrets.APPLE_SIGNING_IDENTITY }}
CSC_FOR_PULL_REQUEST: true
VERSION: ${{ github.event_name == 'schedule' && needs.prepare-files.outputs.version || format('v{0}', needs.prepare-files.outputs.version) }}
VERSION_NO_V: ${{ needs.prepare-files.outputs.version }}
WINDOWS_CERTIFICATE_THUMBPRINT: F4C9A52FF7BC26EE5E054946F6B11DEEA94C748D
steps:
- uses: actions/checkout@v4
@ -186,8 +172,25 @@ jobs:
smksp_cert_sync.exe
shell: cmd
- name: Build the app
run: yarn electron-builder --config ${{ env.BUILD_RELEASE && '--publish always' || '' }}
- name: Build the app (debug)
if: ${{ env.BUILD_RELEASE == 'false' }}
# electron-builder doesn't have a concept of release vs debug,
# this is just not doing any codesign or release yml generation
run: yarn electron-builder --config
- name: Build the app (release)
if: ${{ env.BUILD_RELEASE == 'true' }}
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
CSC_LINK: ${{ secrets.APPLE_CERTIFICATE }}
CSC_KEY_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
CSC_KEYCHAIN: ${{ secrets.APPLE_SIGNING_IDENTITY }}
CSC_FOR_PULL_REQUEST: true
WINDOWS_CERTIFICATE_THUMBPRINT: ${{ secrets.WINDOWS_CERTIFICATE_THUMBPRINT }}
run: yarn electron-builder --config --publish always
- name: List artifacts in out/
run: ls -R out
@ -231,7 +234,17 @@ jobs:
- name: Build the app (updater-test)
if: ${{ env.CUT_RELEASE_PR == 'true' }}
run: yarn electron-builder --config ${{ env.BUILD_RELEASE && '--publish always' || '' }}
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
CSC_LINK: ${{ secrets.APPLE_CERTIFICATE }}
CSC_KEY_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
CSC_KEYCHAIN: ${{ secrets.APPLE_SIGNING_IDENTITY }}
CSC_FOR_PULL_REQUEST: true
WINDOWS_CERTIFICATE_THUMBPRINT: ${{ secrets.WINDOWS_CERTIFICATE_THUMBPRINT }}
run: yarn electron-builder --config --publish always
- uses: actions/upload-artifact@v3
if: ${{ env.CUT_RELEASE_PR == 'true' }}
@ -262,7 +275,6 @@ jobs:
VERSION_NO_V: ${{ needs.prepare-files.outputs.version }}
VERSION: ${{ github.event_name == 'schedule' && needs.prepare-files.outputs.version || format('v{0}', needs.prepare-files.outputs.version) }}
PUB_DATE: ${{ github.event_name == 'release' && github.event.release.created_at || github.event.repository.updated_at }}
NOTES: ${{ needs.prepare-files.outputs.notes }}
BUCKET_DIR: ${{ github.event_name == 'schedule' && 'dl.kittycad.io/releases/modeling-app/nightly' || 'dl.kittycad.io/releases/modeling-app' }}
WEBSITE_DIR: ${{ github.event_name == 'schedule' && 'dl.zoo.dev/releases/modeling-app/nightly' || 'dl.zoo.dev/releases/modeling-app' }}
URL_CODED_NAME: ${{ github.event_name == 'schedule' && 'Zoo%20Modeling%20App%20%28Nightly%29' || 'Zoo%20Modeling%20App' }}

View File

@ -62,7 +62,7 @@ jobs:
shell: bash
run: |-
cd "${{ matrix.dir }}"
cargo llvm-cov nextest --all --lcov --output-path lcov.info --test-threads=1 --no-fail-fast -P ci 2>&1 | tee /tmp/github-actions.log
cargo llvm-cov nextest --workspace --lcov --output-path lcov.info --test-threads=1 --no-fail-fast -P ci 2>&1 | tee /tmp/github-actions.log
env:
KITTYCAD_API_TOKEN: ${{secrets.KITTYCAD_API_TOKEN}}
RUST_MIN_STACK: 10485760000

4
.gitignore vendored
View File

@ -66,7 +66,3 @@ venv
# electron
out/
# engine wasm utils
src/lib/engine-utils/engine.wasm
src/lib/engine-utils/engine.js

2
.nvmrc
View File

@ -1 +1 @@
v21.7.1
v21.7.3

File diff suppressed because it is too large Load Diff

View File

@ -162,6 +162,28 @@ A base path.
----
A circular arc, not necessarily tangential to the current point.
**Type:** `object`
## Properties
| Property | Type | Description | Required |
|----------|------|-------------|----------|
| `type` |enum: `Arc`| | No |
| `center` |`[number, number]`| Center of the circle that this arc is drawn on. | No |
| `radius` |`number`| Radius of the circle that this arc is drawn on. | No |
| `from` |`[number, number]`| The from point. | No |
| `to` |`[number, number]`| The to point. | No |
| `tag` |[`TagDeclarator`](/docs/kcl/types#tag-declaration)| The tag of the path. | No |
| `__geoMeta` |[`GeoMeta`](/docs/kcl/types/GeoMeta)| Metadata. | No |
----

View File

@ -16,8 +16,8 @@ A sketch is a collection of paths.
| Property | Type | Description | Required |
|----------|------|-------------|----------|
| `id` |`string`| The id of the sketch (this will change when the engine's reference to it changes. | No |
| `value` |`[` [`Path`](/docs/kcl/types/Path) `]`| The paths in the sketch. | No |
| `id` |`string`| The id of the sketch (this will change when the engine's reference to it changes). | No |
| `paths` |`[` [`Path`](/docs/kcl/types/Path) `]`| The paths in the sketch. | No |
| `on` |[`SketchSurface`](/docs/kcl/types/SketchSurface)| What the sketch is on (can be a plane or a face). | No |
| `start` |[`BasePath`](/docs/kcl/types/BasePath)| The starting path. | No |
| `tags` |`object`| Tag identifiers that have been declared in this sketch. | No |

View File

@ -25,8 +25,8 @@ A sketch is a collection of paths.
| Property | Type | Description | Required |
|----------|------|-------------|----------|
| `type` |enum: `sketch`| | No |
| `id` |`string`| The id of the sketch (this will change when the engine's reference to it changes. | No |
| `value` |`[` [`Path`](/docs/kcl/types/Path) `]`| The paths in the sketch. | No |
| `id` |`string`| The id of the sketch (this will change when the engine's reference to it changes). | No |
| `paths` |`[` [`Path`](/docs/kcl/types/Path) `]`| The paths in the sketch. | No |
| `on` |[`SketchSurface`](/docs/kcl/types/SketchSurface)| What the sketch is on (can be a plane or a face). | No |
| `start` |[`BasePath`](/docs/kcl/types/BasePath)| The starting path. | No |
| `tags` |`object`| Tag identifiers that have been declared in this sketch. | No |

View File

@ -18,7 +18,7 @@ Engine information for a tag.
|----------|------|-------------|----------|
| `id` |`string`| The id of the tagged object. | No |
| `sketch` |`string`| The sketch the tag is on. | No |
| `path` |[`BasePath`](/docs/kcl/types/BasePath)| The path the tag is on. | No |
| `path` |[`Path`](/docs/kcl/types/Path)| The path the tag is on. | No |
| `surface` |[`ExtrudeSurface`](/docs/kcl/types/ExtrudeSurface)| The surface information for the tag. | No |

View File

@ -55,6 +55,53 @@ test.describe('Onboarding tests', () => {
await expect(page.locator('.cm-content')).toContainText('// Shelf Bracket')
})
test(
'Desktop: fresh onboarding executes and loads',
{ tag: '@electron' },
async ({ browserName: _ }, testInfo) => {
const { electronApp, page } = await setupElectron({
testInfo,
appSettings: {
app: {
onboardingStatus: 'incomplete',
},
},
cleanProjectDir: true,
})
const u = await getUtils(page)
const viewportSize = { width: 1200, height: 500 }
await page.setViewportSize(viewportSize)
// Locators and constants
const newProjectButton = page.getByRole('button', { name: 'New project' })
const projectLink = page.getByTestId('project-link')
await test.step(`Create a project and open to the onboarding`, async () => {
await newProjectButton.click()
await projectLink.click()
await test.step(`Ensure the engine connection works by testing the sketch button`, async () => {
await u.waitForPageLoad()
})
})
await test.step(`Ensure we see the onboarding stuff`, async () => {
// Test that the onboarding pane loaded
await expect(
page.getByText('Welcome to Modeling App! This')
).toBeVisible()
// *and* that the code is shown in the editor
await expect(page.locator('.cm-content')).toContainText(
'// Shelf Bracket'
)
})
await electronApp.close()
}
)
test('Code resets after confirmation', async ({ page }) => {
const initialCode = `sketch001 = startSketchOn('XZ')`

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 38 KiB

View File

@ -888,7 +888,17 @@ export async function setupElectron({
const tempSettingsFilePath = join(projectDirName, SETTINGS_FILE_NAME)
const settingsOverrides = TOML.stringify(
appSettings
? { settings: appSettings }
? {
settings: {
...TEST_SETTINGS,
...appSettings,
app: {
...TEST_SETTINGS.app,
projectDirectory: projectDirName,
...appSettings.app,
},
},
}
: {
settings: {
...TEST_SETTINGS,

View File

@ -292,7 +292,7 @@ test.describe(`Testing gizmo, fixture-based`, () => {
await test.step(`Verify the camera moved`, async () => {
await scene.expectState({
camera: {
position: [0, -23865.37, 11073.54],
position: [0, -23865.37, 11073.53],
target: [0, 0, 0],
},
})

View File

@ -430,7 +430,6 @@ test.describe('Testing settings', () => {
await test.step('Check color of logo changed when in modeling view', async () => {
await page.getByRole('button', { name: 'New project' }).click()
await page.getByTestId('project-link').first().click()
await page.getByRole('button', { name: 'Dismiss' }).click()
await changeColor('58')
await expect(logoLink).toHaveCSS('--primary-hue', '58')
})

2
interface.d.ts vendored
View File

@ -2,7 +2,7 @@ import fs from 'node:fs/promises'
import fsSync from 'node:fs'
import path from 'path'
import { dialog, shell } from 'electron'
import { MachinesListing } from 'lib/machineManager'
import { MachinesListing } from 'components/MachineManagerProvider'
type EnvFn = (value?: string) => string

View File

@ -107,6 +107,13 @@
},
"type": "array"
},
"loaded_filament_idx": {
"description": "The currently loaded filament index.",
"format": "uint",
"minimum": 0,
"nullable": true,
"type": "integer"
},
"nozzle_diameter": {
"description": "Diameter of the extrusion nozzle, in mm.",
"format": "double",
@ -285,6 +292,21 @@
"type"
],
"type": "object"
},
{
"description": "Unknown material",
"properties": {
"type": {
"enum": [
"unknown"
],
"type": "string"
}
},
"required": [
"type"
],
"type": "object"
}
]
},
@ -974,7 +996,7 @@
},
"description": "",
"title": "machine-api",
"version": "0.1.0"
"version": "0.1.1"
},
"openapi": "3.0.3",
"paths": {

View File

@ -1,6 +1,6 @@
{
"name": "zoo-modeling-app",
"version": "0.26.0",
"version": "0.26.2",
"private": true,
"productName": "Zoo Modeling App",
"author": {
@ -161,7 +161,7 @@
"@types/isomorphic-fetch": "^0.0.39",
"@types/minimist": "^1.2.5",
"@types/mocha": "^10.0.6",
"@types/node": "^22.5.0",
"@types/node": "^22.7.8",
"@types/pixelmatch": "^5.2.6",
"@types/pngjs": "^6.0.4",
"@types/react": "^18.3.4",

0
release-notes.md Normal file
View File

View File

@ -21,6 +21,7 @@ import { WasmErrBanner } from 'components/WasmErrBanner'
import { CommandBar } from 'components/CommandBar/CommandBar'
import ModelingMachineProvider from 'components/ModelingMachineProvider'
import FileMachineProvider from 'components/FileMachineProvider'
import { MachineManagerProvider } from 'components/MachineManagerProvider'
import { PATHS } from 'lib/paths'
import {
fileLoader,
@ -33,14 +34,15 @@ import SettingsAuthProvider from 'components/SettingsAuthProvider'
import LspProvider from 'components/LspProvider'
import { KclContextProvider } from 'lang/KclProvider'
import { BROWSER_PROJECT_NAME } from 'lib/constants'
import { CoreDumpManager } from 'lib/coredump'
import { codeManager, engineCommandManager } from 'lib/singletons'
import { AppStateProvider } from 'AppState'
import { InteractionMapMachineProvider } from 'components/InteractionMapMachineProvider'
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
import { useMemo } from 'react'
import { CoreDumpManager } from 'lib/coredump'
import useHotkeyWrapper from 'lib/hotkeyWrapper'
import toast from 'react-hot-toast'
import { coreDump } from 'lang/wasm'
import { useMemo } from 'react'
import { AppStateProvider } from 'AppState'
import { reportRejection } from 'lib/trap'
const createRouter = isDesktop() ? createHashRouter : createBrowserRouter
@ -49,20 +51,25 @@ const router = createRouter([
{
loader: settingsLoader,
id: PATHS.INDEX,
// TODO: Re-evaluate if this is true
/* Make sure auth is the outermost provider or else we will have
* inefficient re-renders, use the react profiler to see. */
element: (
<CommandBarProvider>
<SettingsAuthProvider>
<LspProvider>
<KclContextProvider>
<AppStateProvider>
<Outlet />
</AppStateProvider>
</KclContextProvider>
</LspProvider>
</SettingsAuthProvider>
</CommandBarProvider>
<InteractionMapMachineProvider>
<CommandBarProvider>
<SettingsAuthProvider>
<LspProvider>
<KclContextProvider>
<AppStateProvider>
<MachineManagerProvider>
<Outlet />
</MachineManagerProvider>
</AppStateProvider>
</KclContextProvider>
</LspProvider>
</SettingsAuthProvider>
</CommandBarProvider>
</InteractionMapMachineProvider>
),
errorElement: <ErrorPage />,
children: [

View File

@ -1,4 +1,4 @@
import { useRef, useMemo, memo } from 'react'
import { useRef, useMemo, memo, useCallback } from 'react'
import { isCursorInSketchCommandRange } from 'lang/util'
import { engineCommandManager, kclManager } from 'lib/singletons'
import { useModelingContext } from 'hooks/useModelingContext'
@ -7,10 +7,11 @@ import { useNetworkContext } from 'hooks/useNetworkContext'
import { NetworkHealthState } from 'hooks/useNetworkStatus'
import { ActionButton } from 'components/ActionButton'
import { isSingleCursorInPipe } from 'lang/queryAst'
import { useKclContext } from 'lang/KclProvider'
import { useShouldDisableModelingActions } from 'hooks/useShouldDisableModelingActions'
import { useInteractionMap } from 'hooks/useInteractionMap'
import { ActionButtonDropdown } from 'components/ActionButtonDropdown'
import { useHotkeys } from 'react-hotkeys-hook'
import Tooltip from 'components/Tooltip'
import { KEYBINDING_CATEGORIES } from 'lib/constants'
import { useAppState } from 'AppState'
import { CustomIcon } from 'components/CustomIcon'
import {
@ -20,6 +21,9 @@ import {
ToolbarItemResolved,
ToolbarModeName,
} from 'lib/toolbar'
import { useKclContext } from 'lang/KclProvider'
import { useInteractionMapContext } from 'hooks/useInteractionMapContext'
import { InteractionSequence } from 'components/Settings/AllKeybindingsFields'
import { isDesktop } from 'lib/isDesktop'
import { openExternalBrowserIfDesktop } from 'lib/openWindow'
@ -273,18 +277,36 @@ const ToolbarItemTooltip = memo(function ToolbarItemContents({
itemConfig: ToolbarItemResolved
configCallbackProps: ToolbarItemCallbackProps
}) {
useHotkeys(
itemConfig.hotkey || '',
() => {
itemConfig.onClick(configCallbackProps)
},
const { state: interactionMapState } = useInteractionMapContext()
const resolvedSequence = useMemo(
() =>
interactionMapState.context.overrides[
`${KEYBINDING_CATEGORIES.MODELING}.${itemConfig.id}`
] ||
(itemConfig.hotkey instanceof Array
? itemConfig.hotkey[0]
: itemConfig.hotkey) ||
'',
[interactionMapState.context.overrides, itemConfig.id, itemConfig.hotkey]
)
useInteractionMap(
KEYBINDING_CATEGORIES.MODELING,
{
enabled:
itemConfig.status === 'available' &&
!!itemConfig.hotkey &&
!itemConfig.disabled &&
!itemConfig.disableHotkey,
}
[itemConfig.id]: {
name: itemConfig.id,
title: itemConfig.title,
sequence: resolvedSequence,
action: () => itemConfig.onClick(configCallbackProps),
guard: () =>
itemConfig.status === 'available' &&
!!itemConfig.hotkey &&
!itemConfig.disabled &&
!itemConfig.disableHotkey,
ownerId: KEYBINDING_CATEGORIES.MODELING,
},
},
[itemConfig.disabled, itemConfig.disableHotkey, resolvedSequence]
)
return (

View File

@ -92,6 +92,7 @@ export class CameraControls {
target: Vector3
domElement: HTMLCanvasElement
isDragging: boolean
wasDragging: boolean
mouseDownPosition: Vector2
mouseNewPosition: Vector2
rotationSpeed = 0.3
@ -233,6 +234,7 @@ export class CameraControls {
this.target = new Vector3()
this.domElement = domElement
this.isDragging = false
this.wasDragging = false
this.mouseDownPosition = new Vector2()
this.mouseNewPosition = new Vector2()
@ -363,6 +365,8 @@ export class CameraControls {
onMouseDown = (event: PointerEvent) => {
this.domElement.setPointerCapture(event.pointerId)
this.isDragging = true
// Reset the wasDragging flag to false when starting a new drag
this.wasDragging = false
this.mouseDownPosition.set(event.clientX, event.clientY)
let interaction = this.getInteractionType(event)
if (interaction === 'none') return
@ -392,6 +396,10 @@ export class CameraControls {
const interaction = this.getInteractionType(event)
if (interaction === 'none') return
// If there's a valid interaction and the mouse is moving,
// our past (and current) interaction was a drag.
this.wasDragging = true
if (this.syncDirection === 'engineToClient') {
this.moveSender.send(() => {
this.doMove(interaction, [event.clientX, event.clientY])
@ -399,6 +407,7 @@ export class CameraControls {
return
}
// else "clientToEngine" (Sketch Mode) or forceUpdate
// Implement camera movement logic here based on deltaMove
// For example, for rotating the camera around the target:
if (interaction === 'rotate') {
@ -427,6 +436,9 @@ export class CameraControls {
* under the cursor. This recently moved from being handled in App.tsx.
* This might not be the right spot, but it is more consolidated.
*/
// Clear any previous drag state
this.wasDragging = false
if (this.syncDirection === 'engineToClient') {
const newCmdId = uuidv4()

View File

@ -338,6 +338,11 @@ export class SceneEntities {
sceneInfra.setCallbacks({
onClick: async (args) => {
if (!args) return
// If there is a valid camera interaction that matches, do that instead
const interaction = sceneInfra.camControls.getInteractionType(
args.mouseEvent
)
if (interaction !== 'none') return
if (args.mouseEvent.which !== 1) return
const { intersectionPoint } = args
if (!intersectionPoint?.twoD || !sketchDetails?.sketchPathToNode) return
@ -407,7 +412,7 @@ export class SceneEntities {
if (err(sketch)) return Promise.reject(sketch)
if (!sketch) return Promise.reject('sketch not found')
if (!isArray(sketch?.value))
if (!isArray(sketch?.paths))
return {
truncatedAst,
programMemoryOverride,
@ -435,7 +440,7 @@ export class SceneEntities {
maybeModdedAst,
sketch.start.__geoMeta.sourceRange
)
if (sketch?.value?.[0]?.type !== 'Circle') {
if (sketch?.paths?.[0]?.type !== 'Circle') {
const _profileStart = createProfileStartHandle({
from: sketch.start.from,
id: sketch.start.__geoMeta.id,
@ -451,16 +456,16 @@ export class SceneEntities {
this.activeSegments[JSON.stringify(segPathToNode)] = _profileStart
}
const callbacks: (() => SegmentOverlayPayload | null)[] = []
sketch.value.forEach((segment, index) => {
sketch.paths.forEach((segment, index) => {
let segPathToNode = getNodePathFromSourceRange(
maybeModdedAst,
segment.__geoMeta.sourceRange
)
if (
draftExpressionsIndices &&
(sketch.value[index - 1] || sketch.start)
(sketch.paths[index - 1] || sketch.start)
) {
const previousSegment = sketch.value[index - 1] || sketch.start
const previousSegment = sketch.paths[index - 1] || sketch.start
const previousSegmentPathToNode = getNodePathFromSourceRange(
maybeModdedAst,
previousSegment.__geoMeta.sourceRange
@ -511,7 +516,7 @@ export class SceneEntities {
to: segment.to,
}
const result = initSegment({
prevSegment: sketch.value[index - 1],
prevSegment: sketch.paths[index - 1],
callExpName,
input,
id: segment.__geoMeta.id,
@ -610,9 +615,9 @@ export class SceneEntities {
variableDeclarationName
)
if (err(sg)) return Promise.reject(sg)
const lastSeg = sg?.value?.slice(-1)[0] || sg.start
const lastSeg = sg?.paths?.slice(-1)[0] || sg.start
const index = sg.value.length // because we've added a new segment that's not in the memory yet, no need for `-1`
const index = sg.paths.length // because we've added a new segment that's not in the memory yet, no need for `-1`
const mod = addNewSketchLn({
node: _ast,
programMemory: kclManager.programMemory,
@ -645,7 +650,13 @@ export class SceneEntities {
sceneInfra.setCallbacks({
onClick: async (args) => {
if (!args) return
// If there is a valid camera interaction that matches, do that instead
const interaction = sceneInfra.camControls.getInteractionType(
args.mouseEvent
)
if (interaction !== 'none') return
if (args.mouseEvent.which !== 1) return
const { intersectionPoint } = args
let intersection2d = intersectionPoint?.twoD
const profileStart = args.intersects
@ -654,7 +665,7 @@ export class SceneEntities {
let modifiedAst
if (profileStart) {
const lastSegment = sketch.value.slice(-1)[0]
const lastSegment = sketch.paths.slice(-1)[0]
modifiedAst = addCallExpressionsToPipe({
node: kclManager.ast,
programMemory: kclManager.programMemory,
@ -686,7 +697,7 @@ export class SceneEntities {
})
if (trap(modifiedAst)) return Promise.reject(modifiedAst)
} else if (intersection2d) {
const lastSegment = sketch.value.slice(-1)[0]
const lastSegment = sketch.paths.slice(-1)[0]
const tmp = addNewSketchLn({
node: kclManager.ast,
programMemory: kclManager.programMemory,
@ -735,7 +746,6 @@ export class SceneEntities {
},
})
},
...this.mouseEnterLeaveCallbacks(),
})
}
setupDraftRectangle = async (
@ -817,7 +827,7 @@ export class SceneEntities {
variableDeclarationName
)
if (err(sketch)) return Promise.reject(sketch)
const sgPaths = sketch.value
const sgPaths = sketch.paths
const orthoFactor = orthoScale(sceneInfra.camControls.camera)
this.updateSegment(sketch.start, 0, 0, _ast, orthoFactor, sketch)
@ -826,6 +836,11 @@ export class SceneEntities {
)
},
onClick: async (args) => {
// If there is a valid camera interaction that matches, do that instead
const interaction = sceneInfra.camControls.getInteractionType(
args.mouseEvent
)
if (interaction !== 'none') return
// Commit the rectangle to the full AST/code and return to sketch.idle
const cornerPoint = args.intersectionPoint?.twoD
if (!cornerPoint || args.mouseEvent.button !== 0) return
@ -868,7 +883,7 @@ export class SceneEntities {
variableDeclarationName
)
if (err(sketch)) return
const sgPaths = sketch.value
const sgPaths = sketch.paths
const orthoFactor = orthoScale(sceneInfra.camControls.camera)
// Update the starting segment of the THREEjs scene
@ -985,7 +1000,7 @@ export class SceneEntities {
variableDeclarationName
)
if (err(sketch)) return
const sgPaths = sketch.value
const sgPaths = sketch.paths
const orthoFactor = orthoScale(sceneInfra.camControls.camera)
this.updateSegment(sketch.start, 0, 0, _ast, orthoFactor, sketch)
@ -994,6 +1009,11 @@ export class SceneEntities {
)
},
onClick: async (args) => {
// If there is a valid camera interaction that matches, do that instead
const interaction = sceneInfra.camControls.getInteractionType(
args.mouseEvent
)
if (interaction !== 'none') return
// Commit the rectangle to the full AST/code and return to sketch.idle
const cornerPoint = args.intersectionPoint?.twoD
if (!cornerPoint || args.mouseEvent.button !== 0) return
@ -1105,7 +1125,7 @@ export class SceneEntities {
const pipeIndex = pathToNode[pathToNodeIndex + 1][0] as number
if (addingNewSegmentStatus === 'nothing') {
const prevSegment = sketch.value[pipeIndex - 2]
const prevSegment = sketch.paths[pipeIndex - 2]
const mod = addNewSketchLn({
node: kclManager.ast,
programMemory: kclManager.programMemory,
@ -1157,6 +1177,11 @@ export class SceneEntities {
},
onMove: () => {},
onClick: (args) => {
// If there is a valid camera interaction that matches, do that instead
const interaction = sceneInfra.camControls.getInteractionType(
args.mouseEvent
)
if (interaction !== 'none') return
if (args?.mouseEvent.which !== 1) return
if (!args || !args.selected) {
sceneInfra.modelingSend({
@ -1345,7 +1370,7 @@ export class SceneEntities {
}
if (!sketch) return
const sgPaths = sketch.value
const sgPaths = sketch.paths
const orthoFactor = orthoScale(sceneInfra.camControls.camera)
this.updateSegment(
@ -1393,7 +1418,7 @@ export class SceneEntities {
modifiedAst,
segment.__geoMeta.sourceRange
)
const sgPaths = sketch.value
const sgPaths = sketch.paths
const originalPathToNodeStr = JSON.stringify(segPathToNode)
segPathToNode[1][0] = varDecIndex
const pathToNodeStr = JSON.stringify(segPathToNode)
@ -1701,7 +1726,7 @@ function prepareTruncatedMemoryAndAst(
variableDeclarationName
)
if (err(sg)) return sg
const lastSeg = sg?.value.slice(-1)[0]
const lastSeg = sg?.paths.slice(-1)[0]
if (draftSegment) {
// truncatedAst needs to setup with another segment at the end
let newSegment

View File

@ -213,7 +213,7 @@ export class SceneInfra {
to: Coords2d
angle?: number
}): SegmentOverlayPayload | null {
if (group.userData.pathToNode && arrowGroup) {
if (!group.userData.draft && group.userData.pathToNode && arrowGroup) {
const vector = new Vector3(0, 0, 0)
// Get the position of the object3D in world space

View File

@ -58,7 +58,7 @@ import { err } from 'lib/trap'
interface CreateSegmentArgs {
input: SegmentInputs
prevSegment: Sketch['value'][number]
prevSegment: Sketch['paths'][number]
id: string
pathToNode: PathToNode
isDraftSegment?: boolean
@ -72,7 +72,7 @@ interface CreateSegmentArgs {
interface UpdateSegmentArgs {
input: SegmentInputs
prevSegment: Sketch['value'][number]
prevSegment: Sketch['paths'][number]
group: Group
sceneInfra: SceneInfra
scale?: number
@ -147,6 +147,7 @@ class StraightSegment implements SegmentUtils {
segmentGroup.name = STRAIGHT_SEGMENT
segmentGroup.userData = {
type: STRAIGHT_SEGMENT,
draft: isDraftSegment,
id,
from,
to,
@ -347,6 +348,7 @@ class TangentialArcToSegment implements SegmentUtils {
mesh.name = meshName
group.userData = {
type: TANGENTIAL_ARC_TO_SEGMENT,
draft: isDraftSegment,
id,
from,
to,
@ -515,11 +517,18 @@ class CircleSegment implements SegmentUtils {
const meshType = isDraftSegment ? CIRCLE_SEGMENT_DASH : CIRCLE_SEGMENT_BODY
const arrowGroup = createArrowhead(scale, theme, color)
const circleCenterGroup = createCircleCenterHandle(scale, theme, color)
// A radius indicator that appears from the center to the perimeter
const radiusIndicatorGroup = createLengthIndicator({
from: center,
to: [center[0] + radius, center[1]],
scale,
})
arcMesh.userData.type = meshType
arcMesh.name = meshType
group.userData = {
type: CIRCLE_SEGMENT,
draft: isDraftSegment,
id,
from,
radius,
@ -532,7 +541,7 @@ class CircleSegment implements SegmentUtils {
}
group.name = CIRCLE_SEGMENT
group.add(arcMesh, arrowGroup, circleCenterGroup)
group.add(arcMesh, arrowGroup, circleCenterGroup, radiusIndicatorGroup)
const updateOverlaysCallback = this.update({
prevSegment,
input,
@ -564,6 +573,9 @@ class CircleSegment implements SegmentUtils {
group.userData.radius = radius
group.userData.prevSegment = prevSegment
const arrowGroup = group.getObjectByName(ARROWHEAD) as Group
const radiusLengthIndicator = group.getObjectByName(
SEGMENT_LENGTH_LABEL
) as Group
const circleCenterHandle = group.getObjectByName(
CIRCLE_CENTER_HANDLE
) as Group
@ -581,11 +593,14 @@ class CircleSegment implements SegmentUtils {
}
if (arrowGroup) {
arrowGroup.position.set(
center[0] + Math.cos(Math.PI / 4) * radius,
center[1] + Math.sin(Math.PI / 4) * radius,
0
)
// The arrowhead is placed at the perimeter of the circle,
// pointing up and to the right
const arrowPoint = {
x: center[0] + Math.cos(Math.PI / 4) * radius,
y: center[1] + Math.sin(Math.PI / 4) * radius,
}
arrowGroup.position.set(arrowPoint.x, arrowPoint.y, 0)
const arrowheadAngle = Math.PI / 4
arrowGroup.quaternion.setFromUnitVectors(
@ -596,6 +611,31 @@ class CircleSegment implements SegmentUtils {
arrowGroup.visible = isHandlesVisible
}
if (radiusLengthIndicator) {
// The radius indicator is placed at the midpoint of the radius,
// at a 45 degree CCW angle from the positive X-axis
const indicatorPoint = {
x: center[0] + (Math.cos(Math.PI / 4) * radius) / 2,
y: center[1] + (Math.sin(Math.PI / 4) * radius) / 2,
}
const labelWrapper = radiusLengthIndicator.getObjectByName(
SEGMENT_LENGTH_LABEL_TEXT
) as CSS2DObject
const labelWrapperElem = labelWrapper.element as HTMLDivElement
const label = labelWrapperElem.children[0] as HTMLParagraphElement
label.innerText = `${roundOff(radius)}`
label.classList.add(SEGMENT_LENGTH_LABEL_TEXT)
const isPlaneBackFace = center[0] > indicatorPoint.x
label.style.setProperty(
'--degree',
`${isPlaneBackFace ? '45' : '-45'}deg`
)
label.style.setProperty('--x', `0px`)
label.style.setProperty('--y', `0px`)
labelWrapper.position.set(indicatorPoint.x, indicatorPoint.y, 0)
radiusLengthIndicator.visible = isHandlesVisible
}
if (circleCenterHandle) {
circleCenterHandle.position.set(center[0], center[1], 0)
circleCenterHandle.scale.set(scale, scale, scale)

View File

@ -140,8 +140,6 @@ function CommandArgOptionInput({
}
className="flex-grow px-2 py-1 border-b border-b-chalkboard-100 dark:border-b-chalkboard-80 !bg-transparent focus:outline-none"
onKeyDown={(event) => {
if (event.metaKey && event.key === 'k')
commandBarSend({ type: 'Close' })
if (event.key === 'Backspace' && !event.currentTarget.value) {
stepBack()
}

View File

@ -1,11 +1,12 @@
import { Dialog, Popover, Transition } from '@headlessui/react'
import { Fragment, useEffect } from 'react'
import { Fragment, useEffect, useMemo } from 'react'
import { useCommandsContext } from 'hooks/useCommandsContext'
import CommandBarArgument from './CommandBarArgument'
import CommandComboBox from '../CommandComboBox'
import CommandBarReview from './CommandBarReview'
import { useLocation } from 'react-router-dom'
import useHotkeyWrapper from 'lib/hotkeyWrapper'
import { useInteractionMap } from 'hooks/useInteractionMap'
import { KEYBINDING_CATEGORIES } from 'lib/constants'
import { CustomIcon } from 'components/CustomIcon'
import Tooltip from 'components/Tooltip'
@ -25,15 +26,36 @@ export const CommandBar = () => {
commandBarSend({ type: 'Close' })
}, [pathname])
// Hook up keyboard shortcuts
useHotkeyWrapper([COMMAND_PALETTE_HOTKEY], () => {
if (commandBarState.context.commands.length === 0) return
if (commandBarState.matches('Closed')) {
commandBarSend({ type: 'Open' })
} else {
commandBarSend({ type: 'Close' })
}
})
useInteractionMap(
KEYBINDING_CATEGORIES.COMMAND_BAR,
{
toggle: {
name: 'toggle',
title: 'Toggle Command Bar',
sequence: 'meta+k g RightButton+shift',
action: () => {
const type = commandBarState.matches('Closed') ? 'Open' : 'Close'
console.log('toggling command bar', type)
commandBarSend({
type,
})
},
guard: () => true,
ownerId: KEYBINDING_CATEGORIES.COMMAND_BAR,
},
close: {
name: 'close',
title: 'Close Command Bar',
sequence: 'esc',
action: () => {
commandBarSend({ type: 'Close' })
},
guard: () => !commandBarState.matches('Closed'),
ownerId: KEYBINDING_CATEGORIES.COMMAND_BAR,
},
},
[commandBarState, commandBarSend]
)
function stepBack() {
if (!currentArgument) {

View File

@ -15,8 +15,7 @@ function CommandBarBasicInput({
stepBack: () => void
onSubmit: (event: unknown) => void
}) {
const { commandBarSend, commandBarState } = useCommandsContext()
useHotkeys('mod + k, mod + /', () => commandBarSend({ type: 'Close' }))
const { commandBarState } = useCommandsContext()
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {

View File

@ -26,7 +26,7 @@ function CommandBarKclInput({
stepBack: () => void
onSubmit: (event: unknown) => void
}) {
const { commandBarSend, commandBarState } = useCommandsContext()
const { commandBarState } = useCommandsContext()
const previouslySetValue = commandBarState.context.argumentsToSubmit[
arg.name
] as KclCommandValue | undefined
@ -39,7 +39,6 @@ function CommandBarKclInput({
previouslySetValue && 'variableName' in previouslySetValue
)
const [canSubmit, setCanSubmit] = useState(true)
useHotkeys('mod + k, mod + /', () => commandBarSend({ type: 'Close' }))
const editorRef = useRef<HTMLDivElement>(null)
const {

View File

@ -2,10 +2,22 @@ import { useCommandsContext } from 'hooks/useCommandsContext'
import usePlatform from 'hooks/usePlatform'
import { hotkeyDisplay } from 'lib/hotkeyWrapper'
import { COMMAND_PALETTE_HOTKEY } from './CommandBar/CommandBar'
import { KEYBINDING_CATEGORIES } from 'lib/constants'
import { useMemo } from 'react'
import { useInteractionMapContext } from 'hooks/useInteractionMapContext'
export function CommandBarOpenButton() {
const { commandBarSend } = useCommandsContext()
const platform = usePlatform()
const { commandBarSend } = useCommandsContext()
const { state: interactionMapState } = useInteractionMapContext()
const resolvedKeybinding = useMemo(
() =>
interactionMapState.context.overrides[
`${KEYBINDING_CATEGORIES.COMMAND_BAR}.toggle`
] || COMMAND_PALETTE_HOTKEY,
[interactionMapState.context.overrides]
)
return (
<button
@ -15,7 +27,7 @@ export function CommandBarOpenButton() {
>
<span>Commands</span>
<kbd className="bg-primary/10 dark:bg-chalkboard-80 dark:group-hover:bg-primary font-mono rounded-sm dark:text-inherit inline-block px-1 border-primary dark:border-chalkboard-90">
{hotkeyDisplay(COMMAND_PALETTE_HOTKEY, platform)}
{hotkeyDisplay(resolvedKeybinding, platform)}
</kbd>
</button>
)

View File

@ -44,15 +44,6 @@ function CommandComboBox({
<Combobox.Input
onChange={(event) => setQuery(event.target.value)}
className="w-full bg-transparent focus:outline-none selection:bg-primary/20 dark:selection:bg-primary/40 dark:focus:outline-none"
onKeyDown={(event) => {
if (
(event.metaKey && event.key === 'k') ||
(event.key === 'Backspace' && !event.currentTarget.value)
) {
event.preventDefault()
commandBarSend({ type: 'Close' })
}
}}
placeholder={
(defaultOption && defaultOption.name) ||
placeholder ||

View File

@ -140,6 +140,13 @@ const FileTreeItem = ({
async (eventType, path) => {
// Don't try to read a file that was removed.
if (isCurrentFile && eventType !== 'unlink') {
// Prevents a cyclic read / write causing editor problems such as
// misplaced cursor positions.
if (codeManager.writeCausedByAppCheckedInFileTreeFileSystemWatcher) {
codeManager.writeCausedByAppCheckedInFileTreeFileSystemWatcher = false
return
}
let code = await window.electron.readFile(path, { encoding: 'utf-8' })
code = normalizeLineEndings(code)
codeManager.updateCodeStateEditor(code)

View File

@ -0,0 +1,88 @@
import { createActorContext, useMachine } from '@xstate/react'
import { INTERACTION_MAP_SEPARATOR } from 'lib/constants'
import {
isModifierKey,
mapKey,
mouseButtonToName,
resolveInteractionEvent,
sortKeys,
} from 'lib/keyboard'
import {
InteractionMapItem,
MouseButtonName,
getSortedInteractionMapSequences,
interactionMapMachine,
makeOverrideKey,
normalizeSequence,
} from 'machines/interactionMapMachine'
import { createContext, useEffect } from 'react'
import toast from 'react-hot-toast'
import {
AnyStateMachine,
StateFrom,
Prop,
InterpreterFrom,
assign,
} from 'xstate'
type MachineContext<T extends AnyStateMachine> = {
state: StateFrom<T>
send: Prop<InterpreterFrom<T>, 'send'>
}
export const InteractionMapMachineContext = createActorContext(
interactionMapMachine
)
export const InteractionMapMachineProvider = ({
children,
}: {
children: React.ReactNode
}) => {
return (
<InteractionMapMachineContext.Provider>
<InteractionMapProviderInner>{children}</InteractionMapProviderInner>
</InteractionMapMachineContext.Provider>
)
}
function InteractionMapProviderInner({
children,
}: {
children: React.ReactNode
}) {
const interactionMap = InteractionMapMachineContext.useActorRef()
// Setting up global event listeners
useEffect(() => {
if (!globalThis || !globalThis.window) {
return
}
const fireEvent = (event: MouseEvent | KeyboardEvent) => {
// Don't fire click events on interactable elements,
// and make sure these fire last in the bubbling phase
if (
event.BUBBLING_PHASE &&
!(
(event.target instanceof HTMLElement &&
['INPUT', 'BUTTON', 'ANCHOR'].includes(event.target.tagName)) ||
(event.target as HTMLElement).getAttribute('contenteditable') ===
'true'
)
) {
interactionMap.send({ type: 'Fire event', data: { event } })
}
}
window.addEventListener('keydown', fireEvent)
window.addEventListener('mousedown', fireEvent)
return () => {
window.removeEventListener('keydown', fireEvent)
window.removeEventListener('mousedown', fireEvent)
}
}, [])
return children
}

View File

@ -11,9 +11,20 @@ import toast from 'react-hot-toast'
import { CoreDumpManager } from 'lib/coredump'
import openWindow, { openExternalBrowserIfDesktop } from 'lib/openWindow'
import { NetworkMachineIndicator } from './NetworkMachineIndicator'
import { useInteractionMapContext } from 'hooks/useInteractionMapContext'
import { ModelStateIndicator } from './ModelStateIndicator'
import { reportRejection } from 'lib/trap'
function InteractionSequenceInfo() {
const {
state: {
context: { currentSequence },
},
} = useInteractionMapContext()
return <span className="font-mono text-xs">{currentSequence}</span>
}
export function LowerRightControls({
children,
coreDumpManager,
@ -23,6 +34,7 @@ export function LowerRightControls({
}) {
const location = useLocation()
const filePath = useAbsoluteFilePath()
const linkOverrideClassName =
'!text-chalkboard-70 hover:!text-chalkboard-80 dark:!text-chalkboard-40 dark:hover:!text-chalkboard-30'
@ -69,6 +81,7 @@ export function LowerRightControls({
<section className="fixed bottom-2 right-2 flex flex-col items-end gap-3 pointer-events-none">
{children}
<menu className="flex items-center justify-end gap-3 pointer-events-auto">
<InteractionSequenceInfo />
{!location.pathname.startsWith(PATHS.HOME) && <ModelStateIndicator />}
<a
onClick={openExternalBrowserIfDesktop(

View File

@ -0,0 +1,123 @@
import { createContext, useEffect, useState } from 'react'
import { engineCommandManager } from 'lib/singletons'
import { CommandsContext } from 'components/CommandBar/CommandBarProvider'
import { isDesktop } from 'lib/isDesktop'
import { components } from 'lib/machine-api'
import { reportRejection } from 'lib/trap'
import { toSync } from 'lib/utils'
export type MachinesListing = Array<
components['schemas']['MachineInfoResponse']
>
export interface MachineManager {
machines: MachinesListing
machineApiIp: string | null
currentMachine: components['schemas']['MachineInfoResponse'] | null
noMachinesReason: () => string | undefined
setCurrentMachine: (
m: components['schemas']['MachineInfoResponse'] | null
) => void
}
export const MachineManagerContext = createContext<MachineManager>({
machines: [],
machineApiIp: null,
currentMachine: null,
setCurrentMachine: (
_: components['schemas']['MachineInfoResponse'] | null
) => {},
noMachinesReason: () => undefined,
})
export const MachineManagerProvider = ({
children,
}: {
children: React.ReactNode
}) => {
const [machines, setMachines] = useState<MachinesListing>([])
const [machineApiIp, setMachineApiIp] = useState<string | null>(null)
const [currentMachine, setCurrentMachine] = useState<
components['schemas']['MachineInfoResponse'] | null
>(null)
const commandBarActor = CommandsContext.useActorRef()
// Get the reason message for why there are no machines.
const noMachinesReason = (): string | undefined => {
if (machines.length > 0) {
return undefined
}
if (machineApiIp === null) {
return 'Machine API server was not discovered'
}
return 'Machine API server was discovered, but no machines are available'
}
useEffect(() => {
if (!isDesktop()) return
const update = async () => {
const _machineApiIp = await window.electron.getMachineApiIp()
if (_machineApiIp === null) return
setMachineApiIp(_machineApiIp)
const _machines = await window.electron.listMachines(_machineApiIp)
setMachines(_machines)
}
// Start a background job to update the machines every ten seconds.
// If MDNS is already watching, this timeout will wait until it's done to trigger the
// finding again.
let timeoutId: ReturnType<typeof setTimeout> | undefined = undefined
const timeoutLoop = () => {
clearTimeout(timeoutId)
timeoutId = setTimeout(
toSync(async () => {
await update()
timeoutLoop()
}, reportRejection),
1000
)
}
timeoutLoop()
update().catch(reportRejection)
}, [])
// Update engineCommandManager's copy of this data.
useEffect(() => {
const machineManagerNext = {
machines,
machineApiIp,
currentMachine,
noMachinesReason,
setCurrentMachine,
}
engineCommandManager.machineManager = machineManagerNext
commandBarActor.send({
type: 'Set machine manager',
data: machineManagerNext,
})
}, [machines, machineApiIp, currentMachine])
return (
<MachineManagerContext.Provider
value={{
machines,
machineApiIp,
currentMachine,
setCurrentMachine,
noMachinesReason,
}}
>
{' '}
{children}{' '}
</MachineManagerContext.Provider>
)
}

View File

@ -1,5 +1,11 @@
import { useMachine } from '@xstate/react'
import React, { createContext, useEffect, useMemo, useRef } from 'react'
import React, {
createContext,
useEffect,
useMemo,
useRef,
useContext,
} from 'react'
import {
Actor,
AnyStateMachine,
@ -28,7 +34,7 @@ import {
editorManager,
sceneEntitiesManager,
} from 'lib/singletons'
import { machineManager } from 'lib/machineManager'
import { MachineManagerContext } from 'components/MachineManagerProvider'
import { useHotkeys } from 'react-hotkeys-hook'
import { applyConstraintHorzVertDistance } from './Toolbar/SetHorzVertDistance'
import {
@ -140,6 +146,8 @@ export const ModelingMachineProvider = ({
// >
// )
const machineManager = useContext(MachineManagerContext)
const [modelingState, modelingSend, modelingActor] = useMachine(
modelingMachine.provide({
actions: {
@ -408,7 +416,7 @@ export const ModelingMachineProvider = ({
return {}
}
),
Make: ({ event }) => {
Make: ({ context, event }) => {
if (event.type !== 'Make') return
// Check if we already have an export intent.
if (engineCommandManager.exportInfo) {
@ -422,7 +430,21 @@ export const ModelingMachineProvider = ({
}
// Set the current machine.
machineManager.currentMachine = event.data.machine
// Due to our use of singeton pattern, we need to do this to reliably
// update this object across React and non-React boundary.
// We need to do this eagerly because of the exportToEngine call below.
if (engineCommandManager.machineManager === null) {
console.warn(
"engineCommandManager.machineManager is null. It shouldn't be at this point. Aborting operation."
)
return
} else {
engineCommandManager.machineManager.currentMachine =
event.data.machine
}
// Update the rest of the UI that needs to know the current machine
context.machineManager.setCurrentMachine(event.data.machine)
const format: Models['OutputFormat_type'] = {
type: 'stl',
@ -644,6 +666,7 @@ export const ModelingMachineProvider = ({
input.plane
)
await kclManager.updateAst(modifiedAst, false)
sceneInfra.camControls.enableRotate = false
sceneInfra.camControls.syncDirection = 'clientToEngine'
await letEngineAnimateAndSyncCamAfter(
@ -994,6 +1017,7 @@ export const ModelingMachineProvider = ({
...modelingMachineDefaultContext.store,
...persistedContext,
},
machineManager,
},
// devTools: true,
}

View File

@ -95,7 +95,7 @@ export const processMemory = (programMemory: ProgramMemory) => {
return rest
})
} else if (!err(sg)) {
processedMemory[key] = sg.value.map(({ __geoMeta, ...rest }: Path) => {
processedMemory[key] = sg.paths.map(({ __geoMeta, ...rest }: Path) => {
return rest
})
} else if ((val.type as any) === 'Function') {

View File

@ -21,6 +21,8 @@ export type SidebarType =
| 'lspMessages'
| 'variables'
const PANE_KEYBINDING_PREFIX = 'ctrl+shift+p ' as const
export interface BadgeInfo {
value: (props: PaneCallbackProps) => boolean | number
onClick?: MouseEventHandler<any>
@ -64,7 +66,7 @@ export const sidebarPanes: SidebarPane[] = [
title: 'KCL Code',
icon: 'code',
Content: KclEditorPane,
keybinding: 'Shift + C',
keybinding: PANE_KEYBINDING_PREFIX + 'c',
Menu: KclEditorMenu,
showBadge: {
value: ({ kclContext }) => {
@ -81,7 +83,7 @@ export const sidebarPanes: SidebarPane[] = [
title: 'Project Files',
icon: 'folder',
Content: FileTreeInner,
keybinding: 'Shift + F',
keybinding: PANE_KEYBINDING_PREFIX + 'f',
Menu: FileTreeMenu,
hide: ({ platform }) => platform === 'web',
},
@ -91,21 +93,21 @@ export const sidebarPanes: SidebarPane[] = [
icon: 'make-variable',
Content: MemoryPane,
Menu: MemoryPaneMenu,
keybinding: 'Shift + V',
keybinding: PANE_KEYBINDING_PREFIX + 'v',
},
{
id: 'logs',
title: 'Logs',
icon: 'logs',
Content: LogsPane,
keybinding: 'Shift + L',
keybinding: PANE_KEYBINDING_PREFIX + 'l',
},
{
id: 'debug',
title: 'Debug',
icon: faBugSlash,
Content: DebugPane,
keybinding: 'Shift + D',
keybinding: PANE_KEYBINDING_PREFIX + 'd',
hide: ({ settings }) => !settings.modeling.showDebugPanel.current,
},
]

View File

@ -1,7 +1,12 @@
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
import { Resizable } from 're-resizable'
import { MouseEventHandler, useCallback, useEffect, useMemo } from 'react'
import { useHotkeys } from 'react-hotkeys-hook'
import {
MouseEventHandler,
useCallback,
useContext,
useEffect,
useMemo,
} from 'react'
import { SidebarAction, SidebarType, sidebarPanes } from './ModelingPanes'
import Tooltip from 'components/Tooltip'
import { ActionIcon } from 'components/ActionIcon'
@ -13,7 +18,11 @@ import { CustomIconName } from 'components/CustomIcon'
import { useCommandsContext } from 'hooks/useCommandsContext'
import { IconDefinition } from '@fortawesome/free-solid-svg-icons'
import { useKclContext } from 'lang/KclProvider'
import { machineManager } from 'lib/machineManager'
import { MachineManagerContext } from 'components/MachineManagerProvider'
import { KEYBINDING_CATEGORIES } from 'lib/constants'
import { useInteractionMap } from 'hooks/useInteractionMap'
import { useInteractionMapContext } from 'hooks/useInteractionMapContext'
import { InteractionSequence } from 'components/Settings/AllKeybindingsFields'
interface ModelingSidebarProps {
paneOpacity: '' | 'opacity-20' | 'opacity-40'
@ -29,6 +38,7 @@ function getPlatformString(): 'web' | 'desktop' {
}
export function ModelingSidebar({ paneOpacity }: ModelingSidebarProps) {
const machineManager = useContext(MachineManagerContext)
const { commandBarSend } = useCommandsContext()
const kclContext = useKclContext()
const { settings } = useSettingsAuthContext()
@ -55,7 +65,7 @@ export function ModelingSidebar({ paneOpacity }: ModelingSidebarProps) {
id: 'export',
title: 'Export part',
icon: 'floppyDiskArrow',
keybinding: 'Ctrl + Shift + E',
keybinding: 'Ctrl+Shift+E',
action: () =>
commandBarSend({
type: 'Find and select command',
@ -66,7 +76,7 @@ export function ModelingSidebar({ paneOpacity }: ModelingSidebarProps) {
id: 'make',
title: 'Make part',
icon: 'printer3d',
keybinding: 'Ctrl + Shift + M',
keybinding: 'Ctrl+Shift+M',
// eslint-disable-next-line @typescript-eslint/no-misused-promises
action: async () => {
commandBarSend({
@ -151,6 +161,24 @@ export function ModelingSidebar({ paneOpacity }: ModelingSidebarProps) {
[context.store?.openPanes, send]
)
useInteractionMap(
KEYBINDING_CATEGORIES.USER_INTERFACE,
Object.fromEntries(
filteredPanes.map((pane) => [
pane.id,
{
name: pane.id,
action: () => togglePane(pane.id),
keybinding: pane.keybinding,
title: `Toggle ${pane.title} pane`,
sequence: pane.keybinding,
ownerId: KEYBINDING_CATEGORIES.USER_INTERFACE,
},
])
),
[filteredPanes, context.store?.openPanes]
)
return (
<Resizable
className={`group flex-1 flex flex-col z-10 my-2 pr-1 ${paneOpacity} ${pointerEventsCssClass}`}
@ -283,9 +311,15 @@ function ModelingPaneButton({
disabledText,
...props
}: ModelingPaneButtonProps) {
useHotkeys(paneConfig.keybinding, onClick, {
scopes: ['modeling'],
})
const { state: interactionMapState } = useInteractionMapContext()
const resolvedKeybinding = useMemo(
() =>
interactionMapState.context.overrides[
`${KEYBINDING_CATEGORIES.USER_INTERFACE}.${paneConfig.id}`
] || paneConfig.keybinding,
[interactionMapState.context.overrides]
)
return (
<div id={paneConfig.id + '-button-holder'}>
@ -321,9 +355,10 @@ function ModelingPaneButton({
{disabledText !== undefined ? ` (${disabledText})` : ''}
{paneIsOpen !== undefined ? ` pane` : ''}
</span>
<kbd className="hotkey text-xs capitalize">
{paneConfig.keybinding}
</kbd>
<InteractionSequence
sequence={resolvedKeybinding}
className="flex-nowrap !gap-1"
/>
</Tooltip>
</button>
{!!showBadge?.value && (

View File

@ -1,7 +1,9 @@
import { Popover } from '@headlessui/react'
import { useContext } from 'react'
import Tooltip from './Tooltip'
import { machineManager } from 'lib/machineManager'
import { isDesktop } from 'lib/isDesktop'
import { components } from 'lib/machine-api'
import { MachineManagerContext } from 'components/MachineManagerProvider'
import { CustomIcon } from './CustomIcon'
export const NetworkMachineIndicator = ({
@ -9,9 +11,12 @@ export const NetworkMachineIndicator = ({
}: {
className?: string
}) => {
const machineCount = machineManager.machineCount()
const reason = machineManager.noMachinesReason()
const machines = machineManager.machines
const {
noMachinesReason,
machines,
machines: { length: machineCount },
} = useContext(MachineManagerContext)
const reason = noMachinesReason()
return isDesktop() ? (
<Popover className="relative">
@ -47,34 +52,36 @@ export const NetworkMachineIndicator = ({
</div>
{machineCount > 0 && (
<ul className="divide-y divide-chalkboard-20 dark:divide-chalkboard-80">
{machines.map((machine) => {
return (
<li key={machine.id} className={'px-2 py-4 gap-1 last:mb-0 '}>
<p className="">{machine.id.toUpperCase()}</p>
<p className="text-chalkboard-60 dark:text-chalkboard-50 text-xs">
{machine.make_model.model}
</p>
{machine.extra &&
machine.extra.type === 'bambu' &&
machine.extra.nozzle_diameter && (
<p className="text-chalkboard-60 dark:text-chalkboard-50 text-xs">
Nozzle Diameter: {machine.extra.nozzle_diameter}
</p>
)}
<p className="text-chalkboard-60 dark:text-chalkboard-50 text-xs">
{`Status: ${machine.state.state
.charAt(0)
.toUpperCase()}${machine.state.state.slice(1)}`}
{machine.state.state === 'failed' && machine.state.message
? ` (${machine.state.message})`
: ''}
{machine.state.state === 'running' && machine.progress
? ` (${Math.round(machine.progress)}%)`
: ''}
</p>
</li>
)
})}
{machines.map(
(machine: components['schemas']['MachineInfoResponse']) => {
return (
<li key={machine.id} className={'px-2 py-4 gap-1 last:mb-0 '}>
<p className="">{machine.id.toUpperCase()}</p>
<p className="text-chalkboard-60 dark:text-chalkboard-50 text-xs">
{machine.make_model.model}
</p>
{machine.extra &&
machine.extra.type === 'bambu' &&
machine.extra.nozzle_diameter && (
<p className="text-chalkboard-60 dark:text-chalkboard-50 text-xs">
Nozzle Diameter: {machine.extra.nozzle_diameter}
</p>
)}
<p className="text-chalkboard-60 dark:text-chalkboard-50 text-xs">
{`Status: ${machine.state.state
.charAt(0)
.toUpperCase()}${machine.state.state.slice(1)}`}
{machine.state.state === 'failed' && machine.state.message
? ` (${machine.state.message})`
: ''}
{machine.state.state === 'running' && machine.progress
? ` (${Math.round(machine.progress)}%)`
: ''}
</p>
</li>
)
}
)}
</ul>
)}
</Popover.Panel>

View File

@ -4,14 +4,14 @@ import { type IndexLoaderData } from 'lib/types'
import { PATHS } from 'lib/paths'
import { isDesktop } from '../lib/isDesktop'
import { Link, useLocation, useNavigate } from 'react-router-dom'
import { Fragment, useMemo } from 'react'
import { Fragment, useMemo, useContext } from 'react'
import { Logo } from './Logo'
import { APP_NAME } from 'lib/constants'
import { useCommandsContext } from 'hooks/useCommandsContext'
import { CustomIcon } from './CustomIcon'
import { useLspContext } from './LspProvider'
import { engineCommandManager } from 'lib/singletons'
import { machineManager } from 'lib/machineManager'
import { MachineManagerContext } from 'components/MachineManagerProvider'
import usePlatform from 'hooks/usePlatform'
import { useAbsoluteFilePath } from 'hooks/useAbsoluteFilePath'
import Tooltip from './Tooltip'
@ -96,6 +96,8 @@ function ProjectMenuPopover({
const location = useLocation()
const navigate = useNavigate()
const filePath = useAbsoluteFilePath()
const machineManager = useContext(MachineManagerContext)
const { commandBarState, commandBarSend } = useCommandsContext()
const { onProjectClose } = useLspContext()
const exportCommandInfo = { name: 'Export', groupId: 'modeling' }
@ -106,7 +108,7 @@ function ProjectMenuPopover({
(c) => c.name === obj.name && c.groupId === obj.groupId
)
)
const machineCount = machineManager.machineCount()
const machineCount = machineManager.machines.length
// We filter this memoized list so that no orphan "break" elements are rendered.
const projectMenuItems = useMemo<(ActionButtonProps | 'break')[]>(

View File

@ -1,87 +1,213 @@
import { CustomIcon } from 'components/CustomIcon'
import decamelize from 'decamelize'
import { useInteractionMapContext } from 'hooks/useInteractionMapContext'
import { resolveInteractionEvent } from 'lib/keyboard'
import {
InteractionMapItem,
interactionMap,
sortInteractionMapByCategory,
} from 'lib/settings/initialKeybindings'
import { ForwardedRef, forwardRef } from 'react'
import { useLocation } from 'react-router-dom'
interface AllKeybindingsFieldsProps {}
export const AllKeybindingsFields = forwardRef(
(
props: AllKeybindingsFieldsProps,
scrollRef: ForwardedRef<HTMLDivElement>
) => {
// This is how we will get the interaction map from the context
// in the future whene franknoirot/editable-hotkeys is merged.
// const { state } = useInteractionMapContext()
return (
<div className="relative overflow-y-auto pb-16">
<div ref={scrollRef} className="flex flex-col gap-12">
{Object.entries(interactionMap)
.sort(sortInteractionMapByCategory)
.map(([category, categoryItems]) => (
<div className="flex flex-col gap-4 px-2 pr-4">
<h2
id={`category-${category}`}
className="text-xl mt-6 first-of-type:mt-0 capitalize font-bold"
>
{category}
</h2>
{categoryItems.map((item) => (
<KeybindingField
key={category + '-' + item.name}
category={category}
item={item}
/>
))}
</div>
))}
</div>
</div>
)
}
)
function KeybindingField({
item,
category,
}: {
item: InteractionMapItem
category: string
}) {
const location = useLocation()
makeOverrideKey,
} from 'machines/interactionMapMachine'
import { FormEvent, HTMLProps, useEffect, useRef, useState } from 'react'
export function AllKeybindingsFields() {
const { state } = useInteractionMapContext()
return (
<div
className={
'flex gap-16 justify-between items-start py-1 px-2 -my-1 -mx-2 ' +
(location.hash === `#${item.name}`
? 'bg-primary/5 dark:bg-chalkboard-90'
: '')
}
id={item.name}
>
<div>
<h3 className="text-lg font-normal capitalize tracking-wide">
{item.title}
</h3>
<p className="text-xs text-chalkboard-60 dark:text-chalkboard-50">
{item.description}
</p>
</div>
<div className="flex-1 flex flex-wrap justify-end gap-3">
{item.sequence.split(' ').map((chord, i) => (
<kbd
key={`${category}-${item.name}-${chord}-${i}`}
className="py-0.5 px-1.5 rounded bg-primary/10 dark:bg-chalkboard-80"
>
{chord}
</kbd>
))}
<div className="relative overflow-y-auto">
<div className="flex flex-col gap-4 px-2">
{Object.entries(state.context.interactionMap).map(
([category, categoryItems]) => (
<KeybindingSection
key={category}
category={category}
items={categoryItems}
/>
)
)}
</div>
</div>
)
}
function KeybindingSection({
category,
items,
...props
}: HTMLProps<HTMLDivElement> & {
category: string
items: Record<string, InteractionMapItem>
}) {
return (
<section {...props}>
<h2
id={`category-${category}`}
className="text-xl mt-6 first-of-type:mt-0 capitalize font-bold"
>
{decamelize(category, { separator: ' ' })}
</h2>
<div className="flex flex-col my-2 gap-2">
{Object.entries(items).map(([_, item]) => (
<KeybindingField key={item.ownerId + '-' + item.name} item={item} />
))}
</div>
</section>
)
}
function KeybindingField({ item }: { item: InteractionMapItem }) {
const { send, state } = useInteractionMapContext()
const [isEditing, setIsEditing] = useState(false)
const [newSequence, setNewSequence] = useState('')
const submitRef = useRef<HTMLButtonElement>(null)
function handleSubmit(e: FormEvent) {
e.preventDefault()
if (newSequence !== item.sequence) {
send({
type: 'Update overrides',
data: {
[makeOverrideKey(item)]: newSequence,
},
})
}
setIsEditing(false)
}
useEffect(() => {
const blockOtherEvents = (e: KeyboardEvent | MouseEvent) => {
e.preventDefault()
e.stopPropagation()
e.stopImmediatePropagation()
}
const handleInteraction = (e: KeyboardEvent | MouseEvent) => {
if (e instanceof KeyboardEvent && e.key === 'Escape') {
blockOtherEvents(e)
setIsEditing(false)
return
} else if (e instanceof KeyboardEvent && e.key === 'Enter') {
return
} else if (e instanceof MouseEvent && e.target === submitRef.current) {
return
}
blockOtherEvents(e)
const resolvedInteraction = resolveInteractionEvent(e)
if (resolvedInteraction.isModifier) return
setNewSequence((prev) => {
const newSequence =
prev + (prev.length ? ' ' : '') + resolvedInteraction.asString
console.log('newSequence', newSequence)
return newSequence
})
}
const handleContextMenu = (e: MouseEvent) => {
blockOtherEvents(e)
}
if (!isEditing) {
setNewSequence('')
globalThis?.window?.removeEventListener('keydown', handleInteraction, {
capture: true,
})
globalThis?.window?.removeEventListener('mousedown', handleInteraction, {
capture: true,
})
globalThis?.window?.removeEventListener(
'contextmenu',
handleContextMenu,
{ capture: true }
)
} else {
globalThis?.window?.addEventListener('keydown', handleInteraction, {
capture: true,
})
globalThis?.window?.addEventListener('mousedown', handleInteraction, {
capture: true,
})
globalThis?.window?.addEventListener('contextmenu', handleContextMenu, {
capture: true,
})
}
return () => {
globalThis?.window?.removeEventListener('keydown', handleInteraction, {
capture: true,
})
globalThis?.window?.removeEventListener('mousedown', handleInteraction, {
capture: true,
})
globalThis?.window?.removeEventListener(
'contextmenu',
handleContextMenu,
{ capture: true }
)
}
}, [isEditing, setNewSequence])
return isEditing ? (
<form
key={item.ownerId + '-' + item.name}
className="group flex gap-2 justify-between items-center"
onSubmit={handleSubmit}
>
<h3>{item.title}</h3>
<InteractionSequence sequence={newSequence} showNoSequence />
<input type="hidden" value={item.sequence} name="sequence" />
<button className="p-0 m-0" onClick={() => setIsEditing(false)}>
<CustomIcon name="close" className="w-5 h-5" />
<span className="sr-only">Cancel</span>
</button>
<button ref={submitRef} className="p-0 m-0" type="submit">
<CustomIcon name="checkmark" className="w-5 h-5" />
<span className="sr-only">Save</span>
</button>
</form>
) : (
<div
key={item.ownerId + '-' + item.name}
className="group flex gap-2 justify-between items-center"
>
<h3>{item.title}</h3>
<InteractionSequence
sequence={
state.context.overrides[makeOverrideKey(item)] || item.sequence
}
showNoSequence
/>
<button
ref={submitRef}
className="invisible group-focus:visible group-hover:visible p-0 m-0 [&:not(:hover)]:border-transparent"
onClick={() => setIsEditing(true)}
>
<CustomIcon name="sketch" className="w-5 h-5" />
<span className="sr-only">Edit</span>
</button>
</div>
)
}
export function InteractionSequence({
sequence,
className = '',
showNoSequence = false,
...props
}: HTMLProps<HTMLDivElement> & { sequence: string; showNoSequence?: boolean }) {
return sequence.length ? (
<div
className={
'cursor-default flex-1 flex flex-wrap justify-end gap-3 ' + className
}
{...props}
>
{sequence.split(' ').map((chord, i) => (
<kbd key={`sequence-${sequence}-${chord}-${i}`} className="hotkey">
{chord}
</kbd>
))}
</div>
) : (
showNoSequence && (
<div className="flex-1 flex justify-end text-xs">No sequence set</div>
)
)
}

View File

@ -256,6 +256,8 @@ export const SettingsAuthProviderBase = ({
if (settingsState.context.commandBar.includeSettings.current === false)
return
console.log('settingsState', settingsState)
const commands = settingsWithCommandConfigs(settingsState.context)
.map((type) =>
createSettingsCommand({

View File

@ -255,12 +255,16 @@ export const Stream = () => {
}, [mediaStream])
const handleMouseUp: MouseEventHandler<HTMLDivElement> = (e) => {
// If we've got no stream or connection, don't do anything
if (!isNetworkOkay) return
if (!videoRef.current) return
// If we're in sketch mode, don't send a engine-side select event
if (state.matches('Sketch')) return
if (state.matches({ idle: 'showPlanes' })) return
// If we're mousing up from a camera drag, don't send a select event
if (sceneInfra.camControls.wasDragging === true) return
if (btnName(e.nativeEvent).left) {
if (btnName(e).left) {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
sendSelectEventToEngine(e, videoRef.current)
}

View File

@ -0,0 +1,41 @@
import { InteractionMapItem } from 'machines/interactionMapMachine'
import { useEffect, useMemo } from 'react'
import { useInteractionMapContext } from './useInteractionMapContext'
import { INTERACTION_MAP_SEPARATOR } from 'lib/constants'
/**
* Custom hook to add an interaction map to the interaction map machine
* from within a component, and remove it when the component unmounts.
* @param deps - Any dependencies that should trigger a resetting of the interaction map when they change.
*/
export function useInteractionMap(
/** An ID for the interaction map set. */
ownerId: string,
/** A set of iteraction map items to add */
items: Record<string, InteractionMapItem>,
/** Any dependencies that should invalidate the items */
deps: any[]
) {
const interactionMachine = useInteractionMapContext()
const memoizedItems = useMemo(() => items, deps)
const itemKeys = Object.keys(memoizedItems).map(
(key) => `${ownerId}${INTERACTION_MAP_SEPARATOR}${key}`
)
useEffect(() => {
interactionMachine.send({
type: 'Add to interaction map',
data: {
ownerId,
items: memoizedItems,
},
})
return () => {
interactionMachine.send({
type: 'Remove from interaction map',
data: itemKeys,
})
}
}, [memoizedItems])
}

View File

@ -0,0 +1,13 @@
import { InteractionMapMachineContext } from 'components/InteractionMapMachineProvider'
export const useInteractionMapContext = () => {
const interactionMapActor = InteractionMapMachineContext.useActorRef()
const interactionMapState = InteractionMapMachineContext.useSelector(
(state) => state
)
return {
actor: interactionMapActor,
send: interactionMapActor.send,
state: interactionMapState,
}
}

View File

@ -0,0 +1,16 @@
import { useAppState } from 'AppState'
import { NetworkHealthState, useNetworkStatus } from 'hooks/useNetworkStatus'
import { useKclContext } from 'lang/KclProvider'
/**
* Custom hook to determine if modeling actions should be disabled
* based on the current network status, KCL execution status, and stream readiness.
* @returns boolean
*/
export function useShouldDisableModelingActions() {
const { overallState } = useNetworkStatus()
const { isExecuting } = useKclContext()
const { isStreamReady } = useAppState()
return overallState !== NetworkHealthState.Ok || isExecuting || !isStreamReady
}

View File

@ -6,12 +6,12 @@ import { modelingMachine } from 'machines/modelingMachine'
import { authMachine } from 'machines/authMachine'
import { settingsMachine } from 'machines/settingsMachine'
import { homeMachine } from 'machines/homeMachine'
import { useKclContext } from 'lang/KclProvider'
import {
Command,
StateMachineCommandSetConfig,
StateMachineCommandSetSchema,
} from 'lib/commandTypes'
import { useKclContext } from 'lang/KclProvider'
import { useNetworkContext } from 'hooks/useNetworkContext'
import { NetworkHealthState } from 'hooks/useNetworkStatus'
import { useAppState } from 'AppState'

View File

@ -108,6 +108,11 @@ button:disabled {
@apply bg-chalkboard-90 text-chalkboard-40 border-chalkboard-70;
}
/* Thanks Chris I needed this https://css-tricks.com/slightly-careful-sub-elements-clickable-things/ */
button > * {
@apply pointer-events-none;
}
a {
@apply text-primary hover:hue-rotate-15;
}

View File

@ -32,7 +32,7 @@ const mySketch001 = startSketchOn('XY')
sourceRange: [46, 71],
},
},
value: [
paths: [
{
type: 'ToPoint',
tag: null,
@ -96,7 +96,7 @@ const mySketch001 = startSketchOn('XY')
on: expect.any(Object),
start: expect.any(Object),
type: 'Sketch',
value: [
paths: [
{
type: 'ToPoint',
from: [0, 0],
@ -202,7 +202,7 @@ const sk2 = startSketchOn('XY')
info: expect.any(Object),
},
},
value: [
paths: [
{
type: 'ToPoint',
from: [0, 0],
@ -294,7 +294,7 @@ const sk2 = startSketchOn('XY')
info: expect.any(Object),
},
},
value: [
paths: [
{
type: 'ToPoint',
from: [0, 0],

View File

@ -20,6 +20,8 @@ export default class CodeManager {
private _hotkeys: { [key: string]: () => void } = {}
private timeoutWriter: ReturnType<typeof setTimeout> | undefined = undefined
public writeCausedByAppCheckedInFileTreeFileSystemWatcher = false
constructor() {
if (isDesktop()) {
this.code = ''
@ -120,6 +122,7 @@ export default class CodeManager {
// and file-system watchers which read, will receive empty data during
// writes.
clearTimeout(this.timeoutWriter)
this.writeCausedByAppCheckedInFileTreeFileSystemWatcher = true
this.timeoutWriter = setTimeout(() => {
// Wait one event loop to give a chance for params to be set
// Save the file to disk

View File

@ -58,7 +58,7 @@ const newVar = myVar + 1`
`
const mem = await exe(code)
// geo is three js buffer geometry and is very bloated to have in tests
const minusGeo = mem.get('mySketch')?.value?.value
const minusGeo = mem.get('mySketch')?.value?.paths
expect(minusGeo).toEqual([
{
type: 'ToPoint',
@ -175,7 +175,7 @@ const newVar = myVar + 1`
info: expect.any(Object),
},
},
value: [
paths: [
{
type: 'ToPoint',
to: [1, 1],
@ -367,7 +367,7 @@ describe('testing math operators', () => {
const mem = await exe(code)
const sketch = sketchFromKclValue(mem.get('part001'), 'part001')
// result of `-legLen(5, min(3, 999))` should be -4
const yVal = (sketch as Sketch).value?.[0]?.to?.[1]
const yVal = (sketch as Sketch).paths?.[0]?.to?.[1]
expect(yVal).toBe(-4)
})
it('test that % substitution feeds down CallExp->ArrExp->UnaryExp->CallExp', async () => {
@ -385,8 +385,8 @@ describe('testing math operators', () => {
const mem = await exe(code)
const sketch = sketchFromKclValue(mem.get('part001'), 'part001')
// expect -legLen(segLen('seg01'), myVar) to equal -4 setting the y value back to 0
expect((sketch as Sketch).value?.[1]?.from).toEqual([3, 4])
expect((sketch as Sketch).value?.[1]?.to).toEqual([6, 0])
expect((sketch as Sketch).paths?.[1]?.from).toEqual([3, 4])
expect((sketch as Sketch).paths?.[1]?.to).toEqual([6, 0])
const removedUnaryExp = code.replace(
`-legLen(segLen(seg01), myVar)`,
`legLen(segLen(seg01), myVar)`
@ -398,7 +398,7 @@ describe('testing math operators', () => {
)
// without the minus sign, the y value should be 8
expect((removedUnaryExpMemSketch as Sketch).value?.[1]?.to).toEqual([6, 8])
expect((removedUnaryExpMemSketch as Sketch).paths?.[1]?.to).toEqual([6, 8])
})
it('with nested callExpression and binaryExpression', async () => {
const code = 'const myVar = 2 + min(100, -1 + legLen(5, 3))'

View File

@ -717,7 +717,7 @@ export function isLinesParallelAndConstrained(
constraintType === 'angle' || constraintLevel === 'full'
// get the previous segment
const prevSegment = sg.value[secondaryIndex - 1]
const prevSegment = sg.paths[secondaryIndex - 1]
const prevSourceRange = prevSegment.__geoMeta.sourceRange
const isParallelAndConstrained =

View File

@ -28,6 +28,7 @@ import {
} from 'lib/constants'
import { KclManager } from 'lang/KclSingleton'
import { reportRejection } from 'lib/trap'
import { MachineManager } from 'components/MachineManagerProvider'
// TODO(paultag): This ought to be tweakable.
const pingIntervalMs = 5_000
@ -1415,6 +1416,9 @@ export class EngineCommandManager extends EventTarget {
(() => {}) as any
kclManager: null | KclManager = null
// The current "manufacturing machine" aka 3D printer, CNC, etc.
public machineManager: MachineManager | null = null
set exportInfo(info: ExportInfo | null) {
this._exportInfo = info
}
@ -1630,10 +1634,16 @@ export class EngineCommandManager extends EventTarget {
break
}
case ExportIntent.Make: {
if (!this.machineManager) {
console.warn('Some how, no manufacturing machine is selected.')
break
}
exportMake(
event.data,
this.exportInfo.name,
this.pendingExport.toastId
this.pendingExport.toastId,
this.machineManager
).then((result) => {
if (result) {
this.pendingExport?.resolve(null)

View File

@ -64,7 +64,7 @@ const ARC_SEGMENT_ERR = new Error('Invalid input, expected "arc-segment"')
export type Coords2d = [number, number]
export function getCoordsFromPaths(skGroup: Sketch, index = 0): Coords2d {
const currentPath = skGroup?.value?.[index]
const currentPath = skGroup?.paths?.[index]
if (!currentPath && skGroup?.start) {
return skGroup.start.to
} else if (!currentPath) {
@ -1704,7 +1704,7 @@ export const angledLineThatIntersects: SketchLineHelper = {
varName
)
if (err(sketch)) return sketch
const intersectPath = sketch.value.find(
const intersectPath = sketch.paths.find(
({ tag }: Path) => tag && tag.value === intersectTagName
)
let offset = 0

View File

@ -18,7 +18,7 @@ export function getSketchSegmentFromPathToNode(
pathToNode: PathToNode
):
| {
segment: Sketch['value'][number]
segment: Sketch['paths'][number]
index: number
}
| Error {
@ -39,15 +39,15 @@ export function getSketchSegmentFromSourceRange(
[rangeStart, rangeEnd]: SourceRange
):
| {
segment: Sketch['value'][number]
segment: Sketch['paths'][number]
index: number
}
| Error {
const lineIndex = sketch.value.findIndex(
const lineIndex = sketch.paths.findIndex(
({ __geoMeta: { sourceRange } }: Path) =>
sourceRange[0] <= rangeStart && sourceRange[1] >= rangeEnd
)
const line = sketch.value[lineIndex]
const line = sketch.paths[lineIndex]
if (line) {
return {
segment: line,

View File

@ -1732,7 +1732,7 @@ export function transformAstSketchLines({
if (err(_segment)) return _segment
referencedSegment = _segment.segment
} else {
referencedSegment = sketch.value.find(
referencedSegment = sketch.paths.find(
(path) => path.tag?.value === _referencedSegmentName
)
}

View File

@ -110,7 +110,6 @@ const initialise = async () => {
const fullUrl = wasmUrl()
const input = await fetch(fullUrl)
const buffer = await input.arrayBuffer()
return await init(buffer)
} catch (e) {
console.log('Error initialising WASM', e)

View File

@ -3,7 +3,6 @@ import { StateMachineCommandSetConfig, KclCommandValue } from 'lib/commandTypes'
import { KCL_DEFAULT_LENGTH, KCL_DEFAULT_DEGREE } from 'lib/constants'
import { components } from 'lib/machine-api'
import { Selections } from 'lib/selections'
import { machineManager } from 'lib/machineManager'
import { modelingMachine, SketchTool } from 'machines/modelingMachine'
type OutputFormat = Models['OutputFormat_type']
@ -187,41 +186,41 @@ export const modelingMachineCommandConfig: StateMachineCommandSetConfig<
machine.make_model.model ||
machine.make_model.manufacturer ||
'Unknown Machine',
options: () => {
return Object.entries(machineManager.machines).map(
([_, machine]) => ({
name:
`${machine.id} (${
machine.make_model.model || machine.make_model.manufacturer
}) (${machine.state.state})` +
(machine.hardware_configuration &&
machine.hardware_configuration.type !== 'none' &&
machine.hardware_configuration.config.nozzle_diameter
? ` - Nozzle Diameter: ${machine.hardware_configuration.config.nozzle_diameter}`
: '') +
(machine.hardware_configuration &&
machine.hardware_configuration.type !== 'none' &&
machine.hardware_configuration.config.filaments &&
machine.hardware_configuration.config.filaments[0]
? ` - ${
machine.hardware_configuration.config.filaments[0].name
} #${
machine.hardware_configuration.config &&
machine.hardware_configuration.config.filaments[0].color?.slice(
0,
6
)
}`
: ''),
isCurrent: false,
disabled: machine.state.state !== 'idle',
value: machine as components['schemas']['MachineInfoResponse'],
})
)
},
defaultValue: () => {
options: (commandBarContext) => {
return Object.values(
machineManager.machines
commandBarContext.machineManager?.machines || []
).map((machine: components['schemas']['MachineInfoResponse']) => ({
name:
`${machine.id} (${
machine.make_model.model || machine.make_model.manufacturer
}) (${machine.state.state})` +
(machine.hardware_configuration &&
machine.hardware_configuration.type !== 'none' &&
machine.hardware_configuration.config.nozzle_diameter
? ` - Nozzle Diameter: ${machine.hardware_configuration.config.nozzle_diameter}`
: '') +
(machine.hardware_configuration &&
machine.hardware_configuration.type !== 'none' &&
machine.hardware_configuration.config.filaments &&
machine.hardware_configuration.config.filaments[0]
? ` - ${
machine.hardware_configuration.config.filaments[0].name
} #${
machine.hardware_configuration.config &&
machine.hardware_configuration.config.filaments[0].color?.slice(
0,
6
)
}`
: ''),
isCurrent: false,
disabled: machine.state.state !== 'idle',
value: machine,
}))
},
defaultValue: (commandBarContext) => {
return Object.values(
commandBarContext.machineManager.machines || []
)[0] as components['schemas']['MachineInfoResponse']
},
},

View File

@ -5,6 +5,7 @@ import { Selection } from './selections'
import { Identifier, Expr, VariableDeclaration } from 'lang/wasm'
import { commandBarMachine } from 'machines/commandBarMachine'
import { ReactNode } from 'react'
import { MachineManager } from 'components/MachineManagerProvider'
type Icon = CustomIconName
const PLATFORMS = ['both', 'web', 'desktop'] as const
@ -127,6 +128,7 @@ export type CommandArgumentConfig<
| ((
commandBarContext: {
argumentsToSubmit: Record<string, unknown>
machineManager?: MachineManager
}, // Should be the commandbarMachine's context, but it creates a circular dependency
machineContext?: C
) => CommandArgumentOption<OutputType>[])

View File

@ -45,6 +45,8 @@ export const RELEVANT_FILE_TYPES = [
] as const
/** The default name for a tutorial project */
export const ONBOARDING_PROJECT_NAME = 'Tutorial Project $nn'
/** The separator between keys/buttons in an InteractionMapItem's step */
export const INTERACTION_MAP_SEPARATOR = '+'
/**
* The default starting constant name for various modeling operations.
* These are used to generate unique names for new objects.
@ -106,3 +108,9 @@ export const KCL_SAMPLES_MANIFEST_URLS = {
/** Toast id for the app auto-updater toast */
export const AUTO_UPDATER_TOAST_ID = 'auto-updater-toast'
/** keybinding categories */
export const KEYBINDING_CATEGORIES = {
MODELING: 'modeling',
COMMAND_BAR: 'command-bar',
USER_INTERFACE: 'user-interface',
}

View File

@ -1,31 +0,0 @@
import EngineUtils from '@engine-utils'
type KCEngineUtilsEvaluatePath = {
(sketch: string, t: number): string
}
let kcEngineUtilsEvaluatePath: KCEngineUtilsEvaluatePath
export async function init() {
return await new Promise((resolve, reject) => {
try {
EngineUtils().then((module) => {
kcEngineUtilsEvaluatePath = module.cwrap(
'kcEngineUtilsEvaluatePath',
'string',
['string', 'number']
)
resolve(true)
})
} catch (e) {
reject(e)
}
})
}
export async function getTruePathEndPos(sketch: string) {
if (!kcEngineUtilsEvaluatePath) {
await init()
}
return kcEngineUtilsEvaluatePath(sketch, 1.0)
}

View File

@ -1,5 +1,5 @@
import { deserialize_files } from 'wasm-lib/pkg/wasm_lib'
import { machineManager } from './machineManager'
import { MachineManager } from 'components/MachineManagerProvider'
import toast from 'react-hot-toast'
import { components } from './machine-api'
import ModelingAppFile from './modelingAppFile'
@ -9,7 +9,8 @@ import { MAKE_TOAST_MESSAGES } from './constants'
export async function exportMake(
data: ArrayBuffer,
name: string,
toastId: string
toastId: string,
machineManager: MachineManager
): Promise<Response | null> {
if (name === '') {
console.error(MAKE_TOAST_MESSAGES.NO_NAME)
@ -17,7 +18,7 @@ export async function exportMake(
return null
}
if (machineManager.machineCount() === 0) {
if (machineManager.machines.length === 0) {
console.error(MAKE_TOAST_MESSAGES.NO_MACHINES)
toast.error(MAKE_TOAST_MESSAGES.NO_MACHINES, { id: toastId })
return null

94
src/lib/keyboard.ts Normal file
View File

@ -0,0 +1,94 @@
import { MouseButtonName } from 'machines/interactionMapMachine'
import { INTERACTION_MAP_SEPARATOR } from './constants'
/**
* From https://github.com/JohannesKlauss/react-hotkeys-hook/blob/main/src/parseHotkeys.ts
* we don't want to use the whole library (as cool as it is) because it attaches
* new listeners for each hotkey. Just the key parsing part is good for us.
*/
const reservedModifierKeywords = ['shift', 'alt', 'meta', 'mod', 'ctrl']
const mappedKeys: Record<string, string> = {
esc: 'escape',
return: 'enter',
'.': 'period',
',': 'comma',
'-': 'slash',
' ': 'space',
'`': 'backquote',
'#': 'backslash',
'+': 'bracketright',
ShiftLeft: 'shift',
ShiftRight: 'shift',
AltLeft: 'alt',
AltRight: 'alt',
MetaLeft: 'meta',
MetaRight: 'meta',
OSLeft: 'meta',
OSRight: 'meta',
ControlLeft: 'ctrl',
ControlRight: 'ctrl',
}
export function mapKey(key: string): string {
if (key.includes('Button')) return key
return (mappedKeys[key] || key)
.trim()
.toLowerCase()
.replace(/key|digit|numpad|arrow/, '')
}
export function isModifierKey(key: string) {
return reservedModifierKeywords.includes(key)
}
// Sorts keys in the order of modifier keys, then alphabetically
export function sortKeys(a: string, b: string) {
return isModifierKey(a) ? -1 : isModifierKey(b) ? 1 : a.localeCompare(b)
}
export function mouseButtonToName(
button: MouseEvent['button']
): MouseButtonName {
switch (button) {
case 0:
return 'LeftButton'
case 1:
return 'MiddleButton'
case 2:
return 'RightButton'
default:
return 'LeftButton'
}
}
type ResolveKeymapEvent = {
action: string
modifiers: string[]
isModifier: boolean
asString: string
}
export type InteractionEvent = MouseEvent | KeyboardEvent
export function resolveInteractionEvent(
event: InteractionEvent
): ResolveKeymapEvent {
// First, determine if this is a key or mouse event
const action =
'key' in event ? mapKey(event.code) : mouseButtonToName(event.button)
const modifiers = [
event.ctrlKey && 'ctrl',
event.shiftKey && 'shift',
event.altKey && 'alt',
event.metaKey && 'meta',
].filter((item) => item !== false) as string[]
return {
action,
modifiers,
isModifier: isModifierKey(action),
asString: [action, ...modifiers]
.sort(sortKeys)
.join(INTERACTION_MAP_SEPARATOR),
}
}

View File

@ -138,6 +138,11 @@ export interface components {
FdmHardwareConfiguration: {
/** @description The filaments the printer has access to. */
filaments: components['schemas']['Filament'][]
/**
* Format: uint
* @description The currently loaded filament index.
*/
loaded_filament_idx?: number | null
/**
* Format: double
* @description Diameter of the extrusion nozzle, in mm.
@ -191,6 +196,10 @@ export interface components {
/** @enum {string} */
type: 'composite'
}
| {
/** @enum {string} */
type: 'unknown'
}
/** @description The hardware configuration of a machine. */
HardwareConfiguration:
| {

View File

@ -1,105 +0,0 @@
import { isDesktop } from './isDesktop'
import { components } from './machine-api'
import { reportRejection } from './trap'
import { toSync } from './utils'
export type MachinesListing = Array<
components['schemas']['MachineInfoResponse']
>
export class MachineManager {
private _isDesktop: boolean = isDesktop()
private _machines: MachinesListing = []
private _machineApiIp: string | null = null
private _currentMachine: components['schemas']['MachineInfoResponse'] | null =
null
constructor() {
if (!this._isDesktop) {
return
}
this.updateMachines().catch(reportRejection)
}
start() {
if (!this._isDesktop) {
return
}
// Start a background job to update the machines every ten seconds.
// If MDNS is already watching, this timeout will wait until it's done to trigger the
// finding again.
let timeoutId: ReturnType<typeof setTimeout> | undefined = undefined
const timeoutLoop = () => {
clearTimeout(timeoutId)
timeoutId = setTimeout(
toSync(async () => {
await this.updateMachineApiIp()
await this.updateMachines()
timeoutLoop()
}, reportRejection),
10000
)
}
timeoutLoop()
}
get machines(): MachinesListing {
return this._machines
}
machineCount(): number {
return this._machines.length
}
get machineApiIp(): string | null {
return this._machineApiIp
}
// Get the reason message for why there are no machines.
noMachinesReason(): string | undefined {
if (this.machineCount() > 0) {
return undefined
}
if (this.machineApiIp === null) {
return 'Machine API server was not discovered'
}
return 'Machine API server was discovered, but no machines are available'
}
get currentMachine(): components['schemas']['MachineInfoResponse'] | null {
return this._currentMachine
}
set currentMachine(
machine: components['schemas']['MachineInfoResponse'] | null
) {
this._currentMachine = machine
}
private async updateMachines(): Promise<void> {
if (!this._isDesktop) {
return
}
if (this._machineApiIp === null) {
return
}
this._machines = await window.electron.listMachines(this._machineApiIp)
}
private async updateMachineApiIp(): Promise<void> {
if (!this._isDesktop) {
return
}
this._machineApiIp = await window.electron.getMachineApiIp()
}
}
export const machineManager = new MachineManager()
machineManager.start()

View File

@ -288,7 +288,7 @@ export const toolbarConfig: Record<ToolbarModeName, ToolbarMode> = {
status: 'available',
title: 'Exit sketch',
showTitle: true,
hotkey: 'Esc',
hotkey: 'escape',
description: 'Exit the current sketch',
links: [],
},

View File

@ -7,6 +7,7 @@ import {
} from 'lib/commandTypes'
import { Selections } from 'lib/selections'
import { getCommandArgumentKclValuesOnly } from 'lib/commandUtils'
import { MachineManager } from 'components/MachineManagerProvider'
export type CommandBarContext = {
commands: Command[]
@ -14,6 +15,7 @@ export type CommandBarContext = {
currentArgument?: CommandArgument<unknown> & { name: string }
selectionRanges: Selections
argumentsToSubmit: { [x: string]: unknown }
machineManager: MachineManager
}
export type CommandBarMachineEvent =
@ -71,6 +73,7 @@ export type CommandBarMachineEvent =
type: 'Change current argument'
data: { [x: string]: CommandArgumentWithName<unknown> }
}
| { type: 'Set machine manager'; data: MachineManager }
export const commandBarMachine = setup({
types: {
@ -90,6 +93,12 @@ export const commandBarMachine = setup({
}
},
}),
'Set machine manager': assign({
machineManager: ({ event, context }) => {
if (event.type !== 'Set machine manager') return context.machineManager
return event.data
},
}),
'Execute command': ({ context, event }) => {
const { selectedCommand } = context
if (!selectedCommand) return
@ -339,6 +348,13 @@ export const commandBarMachine = setup({
codeBasedSelections: [],
},
argumentsToSubmit: {},
machineManager: {
machines: [],
machineApiIp: null,
currentMachine: null,
setCurrentMachine: () => {},
noMachinesReason: () => undefined,
},
},
id: 'Command Bar',
initial: 'Closed',
@ -520,6 +536,11 @@ export const commandBarMachine = setup({
},
},
on: {
'Set machine manager': {
reenter: false,
actions: 'Set machine manager',
},
Close: {
target: '.Closed',
},

View File

@ -0,0 +1,393 @@
import { INTERACTION_MAP_SEPARATOR } from 'lib/constants'
import {
InteractionEvent,
mapKey,
resolveInteractionEvent,
sortKeys,
} from 'lib/keyboard'
import { interactionMapCategories } from 'lib/settings/initialKeybindings'
import toast from 'react-hot-toast'
import { assign, ContextFrom, createMachine, fromPromise, setup } from 'xstate'
export type MouseButtonName = `${'Left' | 'Middle' | 'Right'}Button`
export type InteractionMapItem = {
name: string
title: string
sequence: string
guard?: (e: MouseEvent | KeyboardEvent) => boolean
action: () => void
ownerId: string
}
export function makeOverrideKey(interactionMapItem: InteractionMapItem) {
return `${interactionMapItem.ownerId}.${interactionMapItem.name}`
}
export type InteractionMap = {
[key: (typeof interactionMapCategories)[number]]: Record<
string,
InteractionMapItem
>
}
export type InteractionMapContext = {
interactionMap: InteractionMap
overrides: Record<string, string>
currentSequence: string
}
export type InteractionMapEvents =
| {
type: 'Add to interaction map'
data: {
ownerId: string
items: {
[key: string]: InteractionMapItem
}
}
}
| {
type: 'Remove from interaction map'
data: string | string[]
}
| {
type: 'Update overrides'
data: Record<string, string>
}
| {
type: 'Fire event'
data: {
event: KeyboardEvent | MouseEvent
}
}
| {
type: 'Add last interaction to sequence'
}
| {
type: 'Clear sequence'
}
| {
type: 'xstate.done.actor.resolveHotkeyByPrefix'
output: InteractionMapItem
}
| {
type: 'xstate.error.actor.resolveHotkeyByPrefix'
error: string | undefined
}
| {
type: 'xstate.done.actor.executeKeymapAction'
}
| {
type: 'xstate.error.actor.executeKeymapAction'
}
export const interactionMapMachine = setup({
/** @xstate-layout N4IgpgJg5mDOIC5QEkB2AXMAnAhgY3QEsB7VAAgFkcAHMgQQOKwGI6IIz1izCNt8ipMgFsaAbQAMAXUShqxWIUGpZIAB6IAzAE4AbADoATBIAcAdk0AWCQFYJmiRMMAaEAE9EAWgCMN7-t0JbRNdS0tDM29dbQBfGNc0TFwCEnIqWgYuFgAlMGFiADcwMgAzLGJhHj5k5RFxaVV5RWVVDQRow30TU29rbrsrb1cPBB8-AKDtXytg7R0TOITqgVTKGnpGLH0AGUJYTFReKFKmKqSV0mYAMUIsYrAijEkZJBAmpVTWxDmbfTMI7wmEyWYGGbThYZaUJGKw2SxwmwWSJmRYgRL8FJCdIbLL6XKwYgAGyKZAAFsR0ABrMBuZgQUhgfS8ArEan6O4E4lgAASFOpbgAQm4AAp3EqENTPRoKD6kL4IbzeTSaLreMyWXS6RWaXRmOaQhB2Aw2KyGawOEyInWo9E1VbYzJMPFwIkk8lUmnMbDlLbUQk4dAlJjCdkurm8j2CkViiVS17vFqvNreCSArq6Yw6iLaRyGGwGqK-bRzUKGbyGM0mYuGG3LTFpdaOrb413Fd38r1YH36P0BoNYEMc1sR-lC0VgcWS7wvOQyxOgZNOfxWeHRDPzMsGgFdPSObrKsxwywo+Jouu1B2bfQAUTUYDwAFdMGR+aJaA8wBg6QymagWWywDvR9MAAaRpN9MlSONZ2aT4k0QMIzH0YJvGCGYbGCVNdANTQ4X0DVUzsCswW6WJT1tC4GwyK9b3vJ9ilfdYPy-b0nV7QNg30QC6NA8CaEg0hoLeOc4IXBCQQCGxjDVawKz1eEt0tdMHDMboU20M0zBPJZznrNZqKyZgAFVqAgANikKb1CAgOAhITUT1EQbUVRBA9gRsEw1SGdwvHLExkLLCR1yCzVyxPU9UGIGz4FeCi9MvLJpVguV4NGbyRl8fRyx1XCTDBQxNH+MJa10i9GyvXZ9k-I4TiwM4MXnYTkpUVL-iQwwTFwzROvhM1bC3DTVSBXQHFsHVtBsEqGvtcrcRbLkyT5GkktlFqxIVY9fiCzyzXUk1DGwnyEGVfxyxzCxAhsXROu8Ka7SxWanVo4CGL499HnQFbGraY9FJVUJgQ1cJUP+CRLDiOIgA */
types: {
context: {} as InteractionMapContext,
events: {} as InteractionMapEvents,
},
actions: {
'Add last interaction to sequence': assign({
currentSequence: ({ context, event }) => {
if (event.type !== 'xstate.error.actor.resolveHotkeyByPrefix') {
return context.currentSequence
}
const newSequenceStep = event.error
const newSequence = newSequenceStep
? context.currentSequence
? context.currentSequence.concat(' ', newSequenceStep)
: newSequenceStep
: context.currentSequence
console.log('newSequence', newSequence)
return newSequence
},
}),
'Clear sequence': assign({
currentSequence: () => {
console.log('clearing sequence')
return ''
},
}),
'Add to interactionMap': assign({
interactionMap: ({ context, event }) => {
if (event.type !== 'Add to interaction map') {
return context.interactionMap
}
const newInteractions: Record<string, InteractionMapItem> =
Object.fromEntries(
Object.entries(event.data.items).map(([name, item]) => [
name,
{
...item,
sequence: normalizeSequence(item.sequence),
},
])
)
const newInteractionMap = {
...context.interactionMap,
[event.data.ownerId]: {
...context.interactionMap[event.data.ownerId],
...newInteractions,
},
}
// console.log('newInteractionMap', newInteractionMap)
return newInteractionMap
},
}),
'Remove from interactionMap': assign({
interactionMap: ({ context, event }) => {
if (event.type !== 'Remove from interaction map') {
return context.interactionMap
}
const newInteractionMap = { ...context.interactionMap }
if (event.data instanceof Array) {
event.data.forEach((key) => {
const [ownerId, itemName] = key.split(INTERACTION_MAP_SEPARATOR)
delete newInteractionMap[ownerId][itemName]
})
} else {
delete newInteractionMap[event.data]
}
return newInteractionMap
},
}),
'Merge into overrides': assign({
overrides: ({ context, event }) => {
if (event.type !== 'Update overrides') {
return context.overrides
}
return {
...context.overrides,
...event.data,
}
},
}),
'Persist keybinding overrides': ({ context }) => {
console.log('Persisting keybinding overrides', context.overrides)
},
},
actors: {
resolveHotkeyByPrefix: fromPromise(
({
input: { context, data },
}: {
input: { context: InteractionMapContext; data: InteractionEvent }
}) => {
return new Promise<InteractionMapItem>((resolve, reject) => {
const resolvedInteraction = resolveInteractionEvent(data)
// if the key is already a modifier key, skip everything else and reject
if (resolvedInteraction.isModifier) {
// We return an empty string so that we don't clear the currentSequence
reject('')
}
// Find all the sequences that start with the current sequence
const searchString =
(context.currentSequence ? context.currentSequence + ' ' : '') +
resolvedInteraction.asString
const sortedInteractions = getSortedInteractionMapSequences(context)
const matches = sortedInteractions.filter(([sequence]) =>
sequence.startsWith(searchString)
)
console.log('matches', {
matches,
sortedInteractions,
searchString,
overrides: context.overrides,
})
// If we have no matches, reject the promise
if (matches.length === 0) {
reject()
}
const exactMatches = matches.filter(
([sequence]) => sequence === searchString
)
console.log('exactMatches', exactMatches)
if (!exactMatches.length) {
// We have a prefix match.
// Reject the promise and return the step
// so we can add it to currentSequence
reject(resolvedInteraction.asString)
}
// Resolve to just one exact match
const availableExactMatches = exactMatches.filter(
([_, item]) => !item.guard || item.guard(data)
)
console.log('availableExactMatches', availableExactMatches)
if (availableExactMatches.length === 0) {
reject()
} else {
// return the last-added, available exact match
resolve(availableExactMatches[availableExactMatches.length - 1][1])
}
})
}
),
'Execute keymap action': fromPromise(
async ({ input }: { input: InteractionMapItem }) => {
try {
console.log('Executing action', input)
input.action()
} catch (error) {
console.error(error)
toast.error('There was an error executing the action.')
}
}
),
},
guards: {
'There are prefix matches': ({ event }) => {
return (
event.type === 'xstate.error.actor.resolveHotkeyByPrefix' &&
event.error !== undefined
)
},
},
}).createMachine({
/** @xstate-layout N4IgpgJg5mDOIC5QEkB2AXMAnAhgY3QEsB7VAAgFkcAHMgQQOKwGI6IIz1izCNt8ipMgFsaAbQAMAXUShqxWIUGpZIAB6IAHBICsAOgCcANgMBGTUc2nrAFgDMOgDQgAnogBM7g3rtGbAdjMjdx1TIzsDHQBfKOc0TFwCEnIqWgYuFgAlMGFiADcwMgAzLGJhHj5E5RFxaVV5RWVVDQR-TXc9U3d-HR1wmy6zG2c3BFM7Gz0JdyMJCM0Iu08jGLjKgWTKGnpGFgBVaggcTDJ87CxCCDhJGSQQBqVk5sRAzT0bCU0DfzmdOwlzJoRoguqsQPF+EkhKkdhk9AAZQiwTCoXhQYpMCoJDakZgAMUIWEKYAKGBu9QUj1IzwQBgcen83RC-n8NiM7IGwIQvUmZncEmM4T8tjBEKqmxh6SYemysGIABsCmQABbEdAAazALmYEFIYD0vDyxE1eiJcsVYAAEmrNS4AEIuAAKRKKhDU5LuDyadxa1jsdj0Vh6fwiwbCXJM-j07msbXcmh0rJsbNF6yhKW2UqwMrgCqVqo1WuY52l1HlxyKTGEptzFuthftTpdbo9ckp3tALQMEiMhhmOm6wRMXScrkQRlMEgZ9gkn2s3wiplT2PTWzSuxz5vzNqLJezZYrVZrW6tO8bzrArvdplubcaTx9IOmph8yYnbJ02h7pi5bI6iYcRNzH9KwbGXSFqklDcAFE1DAPAAFcTltURaBJMAMB1PUDVQI0TTAODEMwABpLVUPSZJW3udsH07RBkyjAwrG+b4BlCdxhjHbkJj0HRvhMPpIgsOxwPFaFMxgwikMKFDtnQzC9z0A90ErLBqwI+DpNIlxyPTKivVo9R6ICQxmMCVlTHYzjRhsTQoyFfw-G+PivGiMFUGIK54DuMUcQzdcMgpe9qUfBAAFofy4uxrAZX4wiWf1EtEvy11haVEWRDC0QxLAsQgwyDJCujWnpSyegBUxjAWUcbLpHw2gnTQbD6PojDctYV0giS4VlPNCgLW0gqpFRQvGMxA0nSzZiMRy-ncLk5ujdw7HaAVQnGbpktXKC4VgzTkLIuTSXQIaOyMhAAl-VkfBmWc2o+WdTBTGIoiAA */
context: {
interactionMap: {},
overrides: {},
currentSequence: '',
},
id: 'Interaction Map Actor',
initial: 'Listening for interaction',
description:
'Manages the keymap of actions that can be take with the keyboard, mouse, or combination of the two while using the app.',
states: {
'Listening for interaction': {
on: {
'Fire event': {
target: 'Resolve hotkey',
},
},
},
'Resolve hotkey': {
invoke: {
id: 'resolveHotkeyByPrefix',
input: ({ context, event }) => {
if (event.type === 'Fire event') {
return {
context,
data: event.data.event,
}
} else {
return {
context,
data: {} as InteractionEvent,
}
}
},
onDone: {
target: 'Execute keymap event',
},
onError: [
{
target: 'Listening for interaction',
actions: {
type: 'Add last interaction to sequence',
},
guard: {
type: 'There are prefix matches',
},
},
{
target: 'Listening for interaction',
actions: {
type: 'Clear sequence',
},
},
],
src: 'resolveHotkeyByPrefix',
},
},
'Execute keymap event': {
exit: {
type: 'Clear sequence',
},
invoke: {
id: 'executeKeymapAction',
input: ({ event }) => {
if (event.type !== 'xstate.done.actor.resolveHotkeyByPrefix') {
return {} as InteractionMapItem
}
return event.output
},
onDone: {
target: 'Listening for interaction',
},
onError: {
target: 'Listening for interaction',
},
src: 'Execute keymap action',
},
},
},
on: {
'Add to interaction map': {
target: '#Interaction Map Actor',
actions: [
{
type: 'Add to interactionMap',
},
],
},
'Remove from interaction map': {
target: '#Interaction Map Actor',
reenter: false,
actions: [
{
type: 'Remove from interactionMap',
},
],
},
'Update overrides': {
target: '#Interaction Map Actor',
reenter: false,
actions: ['Merge into overrides', 'Persist keybinding overrides'],
},
},
})
export function getSortedInteractionMapSequences(
context: ContextFrom<typeof interactionMapMachine>
) {
return Object.values(context.interactionMap)
.flatMap((items) =>
Object.entries(items).map(
([_, item]) =>
[context.overrides[makeOverrideKey(item)] || item.sequence, item] as [
string,
InteractionMapItem
]
)
)
.sort((a, b) => a[0].localeCompare(b[0]))
}
export function normalizeSequence(sequence: string) {
return sequence
.split(' ')
.map((step) =>
step
.split(INTERACTION_MAP_SEPARATOR)
.sort(sortKeys)
.map(mapKey)
.join(INTERACTION_MAP_SEPARATOR)
)
.join(' ')
}

View File

@ -64,6 +64,7 @@ import toast from 'react-hot-toast'
import { ToolbarModeName } from 'lib/toolbar'
import { quaternionFromUpNForward } from 'clientSideScene/helpers'
import { Vector3 } from 'three'
import { MachineManager } from 'components/MachineManagerProvider'
export const MODELING_PERSIST_KEY = 'MODELING_PERSIST_KEY'
@ -301,6 +302,7 @@ export const getPersistedContext = (): Partial<PersistedModelingContext> => {
export interface ModelingMachineContext {
currentMode: ToolbarModeName
currentTool: SketchTool
machineManager: MachineManager
selection: string[]
selectionRanges: Selections
sketchDetails: SketchDetails | null
@ -315,6 +317,13 @@ export interface ModelingMachineContext {
export const modelingMachineDefaultContext: ModelingMachineContext = {
currentMode: 'modeling',
currentTool: 'none',
machineManager: {
machines: [],
machineApiIp: null,
currentMachine: null,
setCurrentMachine: () => {},
noMachinesReason: () => undefined,
},
selection: [],
selectionRanges: {
otherSelections: [],

View File

@ -4,7 +4,7 @@ import fs from 'node:fs/promises'
import os from 'node:os'
import fsSync from 'node:fs'
import packageJson from '../package.json'
import { MachinesListing } from 'lib/machineManager'
import { MachinesListing } from 'components/MachineManagerProvider'
import chokidar from 'chokidar'
const open = (args: any) => ipcRenderer.invoke('dialog.showOpenDialog', args)

View File

@ -23,6 +23,9 @@ import { codeManager, editorManager, kclManager } from 'lib/singletons'
import { bracket } from 'lib/exampleKcl'
import { toSync } from 'lib/utils'
import { reportRejection } from 'lib/trap'
import { useNetworkContext } from 'hooks/useNetworkContext'
import { NetworkHealthState } from 'hooks/useNetworkStatus'
import { EngineConnectionStateType } from 'lang/std/engineConnection'
export const kbdClasses =
'py-0.5 px-1 text-sm rounded bg-chalkboard-10 dark:bg-chalkboard-100 border border-chalkboard-50 border-b-2'
@ -80,8 +83,20 @@ export const onboardingRoutes = [
]
export function useDemoCode() {
const { overallState, immediateState } = useNetworkContext()
useEffect(() => {
if (!editorManager.editorView || codeManager.code === bracket) return
// Don't run if the editor isn't loaded or the code is already the bracket
if (!editorManager.editorView || codeManager.code === bracket) {
return
}
// Don't run if the network isn't healthy or the connection isn't established
if (
overallState !== NetworkHealthState.Ok ||
immediateState.type !== EngineConnectionStateType.ConnectionEstablished
) {
return
}
setTimeout(
toSync(async () => {
codeManager.updateCodeStateEditor(bracket)
@ -89,7 +104,7 @@ export function useDemoCode() {
await codeManager.writeToFile()
}, reportRejection)
)
}, [editorManager.editorView])
}, [editorManager.editorView, immediateState, overallState])
}
export function useNextClick(newStatus: string) {

View File

@ -1,4 +1,5 @@
import { SettingsLevel } from 'lib/settings/settingsTypes'
import { ActionButton } from '../components/ActionButton'
import { SetEventTypes, SettingsLevel } from 'lib/settings/settingsTypes'
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom'
import { useHotkeys } from 'react-hotkeys-hook'
import { PATHS } from 'lib/paths'
@ -124,7 +125,7 @@ export const Settings = () => {
) : (
<>
<KeybindingsSectionsList scrollRef={scrollRef} />
<AllKeybindingsFields ref={scrollRef} />
<AllKeybindingsFields />
</>
)}
</div>

120
src/wasm-lib/Cargo.lock generated
View File

@ -176,7 +176,7 @@ checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.79",
"syn 2.0.85",
]
[[package]]
@ -187,7 +187,7 @@ checksum = "721cae7de5c34fbb2acd27e21e6d2cf7b886dce0c27388d46c4e6c47ea4318dd"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.79",
"syn 2.0.85",
]
[[package]]
@ -204,7 +204,7 @@ checksum = "3c87f3f15e7794432337fc718554eaa4dc8f04c9677a950ffe366f20a162ae42"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.79",
"syn 2.0.85",
]
[[package]]
@ -465,7 +465,7 @@ dependencies = [
"heck 0.5.0",
"proc-macro2",
"quote",
"syn 2.0.79",
"syn 2.0.85",
]
[[package]]
@ -656,7 +656,7 @@ dependencies = [
"proc-macro2",
"quote",
"strsim",
"syn 2.0.79",
"syn 2.0.85",
]
[[package]]
@ -667,7 +667,7 @@ checksum = "d336a2a514f6ccccaa3e09b02d41d35330c07ddf03a62165fcec10bb561c7806"
dependencies = [
"darling_core",
"quote",
"syn 2.0.79",
"syn 2.0.85",
]
[[package]]
@ -722,7 +722,7 @@ checksum = "4078275de501a61ceb9e759d37bdd3d7210e654dbc167ac1a3678ef4435ed57b"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.79",
"syn 2.0.85",
"synstructure",
]
@ -751,7 +751,7 @@ dependencies = [
"rustfmt-wrapper",
"serde",
"serde_tokenstream",
"syn 2.0.79",
"syn 2.0.85",
]
[[package]]
@ -762,7 +762,7 @@ checksum = "67e77553c4162a157adbf834ebae5b415acbecbeafc7a74b0e886657506a7611"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.79",
"syn 2.0.85",
]
[[package]]
@ -789,7 +789,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.79",
"syn 2.0.85",
]
[[package]]
@ -827,7 +827,7 @@ checksum = "a1ab991c1362ac86c61ab6f556cff143daa22e5a15e4e189df818b2fd19fe65b"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.79",
"syn 2.0.85",
]
[[package]]
@ -988,7 +988,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.79",
"syn 2.0.85",
]
[[package]]
@ -1084,7 +1084,7 @@ dependencies = [
"inflections",
"proc-macro2",
"quote",
"syn 2.0.79",
"syn 2.0.85",
]
[[package]]
@ -1542,7 +1542,7 @@ dependencies = [
[[package]]
name = "kcl-lib"
version = "0.2.22"
version = "0.2.23"
dependencies = [
"anyhow",
"approx 0.5.1",
@ -1612,12 +1612,12 @@ dependencies = [
"pretty_assertions",
"proc-macro2",
"quote",
"syn 2.0.79",
"syn 2.0.85",
]
[[package]]
name = "kcl-test-server"
version = "0.1.14"
version = "0.1.15"
dependencies = [
"anyhow",
"hyper 0.14.30",
@ -1644,9 +1644,9 @@ dependencies = [
[[package]]
name = "kittycad"
version = "0.3.23"
version = "0.3.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "71b6f0c34165939697548dd0c94221200dbb8b5d1c84b5d8e803e70f9f720ea7"
checksum = "f6359cc0a1bbccbcf78775eea17a033cf2aa89d3fe6a9784f8ce94e5f882c185"
dependencies = [
"anyhow",
"async-trait",
@ -1684,9 +1684,9 @@ dependencies = [
[[package]]
name = "kittycad-modeling-cmds"
version = "0.2.70"
version = "0.2.71"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b135696d07a4fab928e5abace4dd05f4976eafab5d73e5747a85dc5a684b936c"
checksum = "c6d2160dcb0e5373b1242a760dbf17fb9c12de523c410c11b145381c852b377b"
dependencies = [
"anyhow",
"chrono",
@ -1716,7 +1716,7 @@ dependencies = [
"kittycad-modeling-cmds-macros-impl",
"proc-macro2",
"quote",
"syn 2.0.79",
"syn 2.0.85",
]
[[package]]
@ -1727,7 +1727,7 @@ checksum = "6607507a8a0e4273b943179f0a3ef8e90712308d1d3095246040c29cfdbf985b"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.79",
"syn 2.0.85",
]
[[package]]
@ -2112,7 +2112,7 @@ dependencies = [
"regex",
"regex-syntax 0.8.5",
"structmeta",
"syn 2.0.79",
"syn 2.0.85",
]
[[package]]
@ -2126,7 +2126,7 @@ dependencies = [
"regex",
"regex-syntax 0.8.5",
"structmeta",
"syn 2.0.79",
"syn 2.0.85",
]
[[package]]
@ -2166,7 +2166,7 @@ dependencies = [
"pest_meta",
"proc-macro2",
"quote",
"syn 2.0.79",
"syn 2.0.85",
]
[[package]]
@ -2224,7 +2224,7 @@ checksum = "2f38a4412a78282e09a2cf38d195ea5420d15ba0602cb375210efbc877243965"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.79",
"syn 2.0.85",
]
[[package]]
@ -2337,9 +2337,9 @@ dependencies = [
[[package]]
name = "proc-macro2"
version = "1.0.88"
version = "1.0.89"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c3a7fc5db1e57d5a779a352c8cdb57b29aa4c40cc69c3a68a7fedc815fbf2f9"
checksum = "f139b0662de085916d1fb67d2b4169d1addddda1919e696f3252b740b629986e"
dependencies = [
"unicode-ident",
]
@ -2391,7 +2391,7 @@ dependencies = [
"proc-macro2",
"pyo3-macros-backend",
"quote",
"syn 2.0.79",
"syn 2.0.85",
]
[[package]]
@ -2404,7 +2404,7 @@ dependencies = [
"proc-macro2",
"pyo3-build-config",
"quote",
"syn 2.0.79",
"syn 2.0.85",
]
[[package]]
@ -2586,9 +2586,9 @@ dependencies = [
[[package]]
name = "regex"
version = "1.11.0"
version = "1.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38200e5ee88914975b69f657f0801b6f6dccafd44fd9326302a4aaeecfacb1d8"
checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191"
dependencies = [
"aho-corasick",
"memchr",
@ -2925,7 +2925,7 @@ dependencies = [
"proc-macro2",
"quote",
"serde_derive_internals",
"syn 2.0.79",
"syn 2.0.85",
]
[[package]]
@ -2965,9 +2965,9 @@ checksum = "61697e0a1c7e512e84a621326239844a24d8207b4669b41bc18b32ea5cbf988b"
[[package]]
name = "serde"
version = "1.0.210"
version = "1.0.213"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8e3592472072e6e22e0a54d5904d9febf8508f65fb8552499a1abc7d1078c3a"
checksum = "3ea7893ff5e2466df8d720bb615088341b295f849602c6956047f8f80f0e9bc1"
dependencies = [
"serde_derive",
]
@ -2983,13 +2983,13 @@ dependencies = [
[[package]]
name = "serde_derive"
version = "1.0.210"
version = "1.0.213"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "243902eda00fad750862fc144cea25caca5e20d615af0a81bee94ca738f1df1f"
checksum = "7e85ad2009c50b58e87caa8cd6dac16bdf511bbfb7af6c33df902396aa480fa5"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.79",
"syn 2.0.85",
]
[[package]]
@ -3000,7 +3000,7 @@ checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.79",
"syn 2.0.85",
]
[[package]]
@ -3024,7 +3024,7 @@ checksum = "6c64451ba24fc7a6a2d60fc75dd9c83c90903b19028d4eff35e88fc1e86564e9"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.79",
"syn 2.0.85",
]
[[package]]
@ -3045,7 +3045,7 @@ dependencies = [
"proc-macro2",
"quote",
"serde",
"syn 2.0.79",
"syn 2.0.85",
]
[[package]]
@ -3182,7 +3182,7 @@ dependencies = [
"proc-macro2",
"quote",
"structmeta-derive",
"syn 2.0.79",
"syn 2.0.85",
]
[[package]]
@ -3193,7 +3193,7 @@ checksum = "152a0b65a590ff6c3da95cabe2353ee04e6167c896b28e3b14478c2636c922fc"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.79",
"syn 2.0.85",
]
[[package]]
@ -3215,7 +3215,7 @@ dependencies = [
"proc-macro2",
"quote",
"rustversion",
"syn 2.0.79",
"syn 2.0.85",
]
[[package]]
@ -3237,9 +3237,9 @@ dependencies = [
[[package]]
name = "syn"
version = "2.0.79"
version = "2.0.85"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89132cd0bf050864e1d38dc3bbc07a0eb8e7530af26344d3d2bbbef83499f590"
checksum = "5023162dfcd14ef8f32034d8bcd4cc5ddc61ef7a247c024a33e24e1f24d21b56"
dependencies = [
"proc-macro2",
"quote",
@ -3263,7 +3263,7 @@ checksum = "c8af7666ab7b6390ab78131fb5b0fce11d6b7a6951602017c35fa82800708971"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.79",
"syn 2.0.85",
]
[[package]]
@ -3326,22 +3326,22 @@ dependencies = [
[[package]]
name = "thiserror"
version = "1.0.64"
version = "1.0.65"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d50af8abc119fb8bb6dbabcfa89656f46f84aa0ac7688088608076ad2b459a84"
checksum = "5d11abd9594d9b38965ef50805c5e469ca9cc6f197f883f717e0269a3057b3d5"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "1.0.64"
version = "1.0.65"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08904e7672f5eb876eaaf87e0ce17857500934f4981c4a0ab2b4aa98baac7fc3"
checksum = "ae71770322cbd277e69d762a16c444af02aa0575ac0d174f0b9562d3b37f8602"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.79",
"syn 2.0.85",
]
[[package]]
@ -3437,7 +3437,7 @@ checksum = "693d596312e88961bc67d7f1f97af8a70227d9f90c31bba5806eec004978d752"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.79",
"syn 2.0.85",
]
[[package]]
@ -3579,7 +3579,7 @@ checksum = "84fd902d4e0b9a4b27f2f440108dc034e1758628a9b702f8ec61ad66355422fa"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.79",
"syn 2.0.85",
]
[[package]]
@ -3607,7 +3607,7 @@ checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.79",
"syn 2.0.85",
]
[[package]]
@ -3689,7 +3689,7 @@ checksum = "0ea0b99e8ec44abd6f94a18f28f7934437809dd062820797c52401298116f70e"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.79",
"syn 2.0.85",
"termcolor",
]
@ -3865,7 +3865,7 @@ dependencies = [
"proc-macro-error",
"proc-macro2",
"quote",
"syn 2.0.79",
"syn 2.0.85",
]
[[package]]
@ -3927,7 +3927,7 @@ dependencies = [
"once_cell",
"proc-macro2",
"quote",
"syn 2.0.79",
"syn 2.0.85",
"wasm-bindgen-shared",
]
@ -3962,7 +3962,7 @@ checksum = "26c6ab57572f7a24a4985830b120de1594465e5d500f24afe89e16b4e833ef68"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.79",
"syn 2.0.85",
"wasm-bindgen-backend",
"wasm-bindgen-shared",
]
@ -4328,7 +4328,7 @@ checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.79",
"syn 2.0.85",
]
[[package]]

View File

@ -71,8 +71,8 @@ members = [
[workspace.dependencies]
http = "1"
kittycad = { version = "0.3.23", default-features = false, features = ["js", "requests"] }
kittycad-modeling-cmds = { version = "0.2.70", features = ["websocket"] }
kittycad = { version = "0.3.25", default-features = false, features = ["js", "requests"] }
kittycad-modeling-cmds = { version = "0.2.71", features = ["websocket"] }
[[test]]
name = "executor"
@ -83,6 +83,6 @@ name = "modify"
path = "tests/modify/main.rs"
# Example: how to point modeling-api at a different repo (e.g. a branch or a local clone)
#[patch."https://github.com/KittyCAD/modeling-api"]
#[patch.crates-io]
#kittycad-modeling-cmds = { path = "../../../modeling-api/modeling-cmds" }
#kittycad-modeling-session = { path = "../../../modeling-api/modeling-session" }

View File

@ -17,13 +17,13 @@ convert_case = "0.6.0"
once_cell = "1.20.2"
proc-macro2 = "1"
quote = "1"
regex = "1.10"
serde = { version = "1.0.210", features = ["derive"] }
regex = "1.11"
serde = { version = "1.0.213", features = ["derive"] }
serde_tokenstream = "0.2"
syn = { version = "2.0.79", features = ["full"] }
syn = { version = "2.0.85", features = ["full"] }
[dev-dependencies]
anyhow = "1.0.89"
anyhow = "1.0.91"
expectorate = "1.1.0"
pretty_assertions = "1.4.1"
rustfmt-wrapper = "0.2.1"

View File

@ -15,7 +15,7 @@ databake = "0.1.8"
kcl-lib = { path = "../kcl" }
proc-macro2 = "1"
quote = "1"
syn = { version = "2.0.79", features = ["full"] }
syn = { version = "2.0.85", features = ["full"] }
[dev-dependencies]
pretty_assertions = "1.4.1"

View File

@ -1,15 +1,15 @@
[package]
name = "kcl-test-server"
description = "A test server for KCL"
version = "0.1.14"
version = "0.1.15"
edition = "2021"
license = "MIT"
[dependencies]
anyhow = "1.0.89"
anyhow = "1.0.91"
hyper = { version = "0.14.29", features = ["http1", "server", "tcp"] }
kcl-lib = { version = "0.2", path = "../kcl" }
pico-args = "0.5.0"
serde = { version = "1.0.210", features = ["derive"] }
serde = { version = "1.0.213", features = ["derive"] }
serde_json = "1.0.128"
tokio = { version = "1.40.0", features = ["macros", "rt-multi-thread"] }

View File

@ -31,7 +31,7 @@ pub struct ServerArgs {
/// Where to find the engine.
/// If none, uses the prod engine.
/// This is useful for testing a local engine instance.
/// Overridden by the $LOCAL_ENGINE_ADDR environment variable.
/// Overridden by the $ZOO_HOST environment variable.
pub engine_address: Option<String>,
}
@ -44,8 +44,8 @@ impl ServerArgs {
num_engine_conns: pargs.opt_value_from_str("--num-engine-conns")?.unwrap_or(1),
engine_address: pargs.opt_value_from_str("--engine-address")?,
};
if let Ok(addr) = std::env::var("LOCAL_ENGINE_ADDR") {
println!("Overriding engine address via $LOCAL_ENGINE_ADDR");
if let Ok(addr) = std::env::var("ZOO_HOST") {
println!("Overriding engine address via $ZOO_HOST");
args.engine_address = Some(addr);
}
println!("Config is {args:?}");

View File

@ -1,7 +1,7 @@
[package]
name = "kcl-lib"
description = "KittyCAD Language implementation and tools"
version = "0.2.22"
version = "0.2.23"
edition = "2021"
license = "MIT"
repository = "https://github.com/KittyCAD/modeling-app"
@ -11,7 +11,7 @@ keywords = ["kcl", "KittyCAD", "CAD"]
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
anyhow = { version = "1.0.89", features = ["backtrace"] }
anyhow = { version = "1.0.91", features = ["backtrace"] }
async-recursion = "1.1.1"
async-trait = "0.1.83"
base64 = "0.22.1"
@ -38,11 +38,11 @@ pyo3 = { version = "0.22.5", optional = true }
reqwest = { version = "0.12", default-features = false, features = ["stream", "rustls-tls"] }
ropey = "1.6.1"
schemars = { version = "0.8.17", features = ["impl_json_schema", "url", "uuid1", "preserve_order"] }
serde = { version = "1.0.210", features = ["derive"] }
serde = { version = "1.0.213", features = ["derive"] }
serde_json = "1.0.128"
sha2 = "0.10.8"
tabled = { version = "0.15.0", optional = true }
thiserror = "1.0.64"
thiserror = "1.0.65"
toml = "0.8.19"
ts-rs = { version = "10.0.0", features = ["uuid-impl", "url-impl", "chrono-impl", "no-serde-warnings", "serde-json-impl"] }
url = { version = "2.5.2", features = ["serde"] }
@ -68,7 +68,7 @@ tokio-tungstenite = { version = "0.24.0", features = ["rustls-tls-native-roots"]
tower-lsp = { version = "0.20.0", features = ["proposed"] }
[features]
default = ["engine"] # add wasm-engine-utils here when we're ready
default = ["engine"]
cli = ["dep:clap"]
# For the lsp server, when run with stdout for rpc we want to disable println.
# This is used for editor extensions that use the lsp server.
@ -77,10 +77,6 @@ engine = []
pyo3 = ["dep:pyo3"]
# Helper functions also used in benchmarks.
lsp-test-util = []
#if enabled, kcl will link directly against a wasm build of the engine utils lib to save latency
wasm-engine-utils = []
#if enabled, kcl will link directly against a native build of the engine utils lib to save latency (not yet functional)
native-engine-utils = []
tabled = ["dep:tabled"]

View File

@ -1,57 +0,0 @@
//! Functions for calling into the engine-utils library (a set of C++ utilities containing various logic for client-side CAD processing)
//! Note that this binary may not be available to all builds of kcl, so fallbacks that call the engine API should be implemented
use crate::{
errors::{KclError, KclErrorDetails},
std::Args,
};
use anyhow::Result;
use std::ffi::{CString, CStr};
use kittycad_modeling_cmds::{length_unit::LengthUnit, shared::Point3d};
mod cpp {
use std::os::raw::c_char;
extern "C" {
pub fn kcEngineUtilsEvaluatePath(sketch: *const c_char, t: f64) -> *const c_char;
}
}
pub fn is_available() -> bool {
true
}
pub async fn get_true_path_end_pos(sketch: String, args: &Args) -> Result<Point3d<LengthUnit>, KclError> {
let c_string = CString::new(sketch).map_err(|e| {
KclError::Internal(KclErrorDetails {
message: format!("{:?}", e),
source_ranges: vec![args.source_range],
})
})?;
let arg = c_string.into_raw();
let result_string: String;
unsafe {
let result = cpp::kcEngineUtilsEvaluatePath(arg, 1.0);
let result_cstr = CStr::from_ptr(result);
let str_slice: &str = result_cstr.to_str().map_err(|e| {
KclError::Internal(KclErrorDetails {
message: format!("{:?}", e),
source_ranges: vec![args.source_range],
})
})?;
let str_buf: String = str_slice.to_owned();
result_string = str_buf.clone();
let _ = CString::from_raw(arg);
}
let point: Point3d<f64> = serde_json::from_str(&result_string).map_err(|e| {
KclError::Type(KclErrorDetails {
message: format!("Failed to path position from json: {}", e),
source_ranges: vec![args.source_range],
})
})?;
Ok(Point3d::<f64>::from(point).map(LengthUnit))
}

View File

@ -1,35 +0,0 @@
//! Functions for calling into the engine-utils library (a set of C++ utilities containing various logic for client-side CAD processing)
//! Note that this binary may not be available to all builds of kcl, so fallbacks that call the engine API should be implemented
use crate::{
errors::{KclError, KclErrorDetails},
std::Args,
};
use crate::engine::kcmc::{each_cmd as mcmd, ModelingCmd};
use anyhow::Result;
use kittycad_modeling_cmds::{length_unit::LengthUnit, ok_response::OkModelingCmdResponse, shared::Point3d, websocket::OkWebSocketResponseData};
pub fn is_available() -> bool {
true
}
pub async fn get_true_path_end_pos(sketch: String, args: &Args) -> Result<Point3d<LengthUnit>, KclError> {
let id = uuid::Uuid::new_v4();
let resp = args.send_modeling_cmd(id, ModelingCmd::from(mcmd::EngineUtilEvaluatePath {
path_json: sketch,
t: 1.0,
})).await?;
let OkWebSocketResponseData::Modeling {
modeling_response: OkModelingCmdResponse::EngineUtilEvaluatePath(point),
} = &resp
else {
return Err(KclError::Engine(KclErrorDetails {
message: format!("mcmd::EngineUtilEvaluatePath response was not as expected: {:?}", resp),
source_ranges: vec![args.source_range],
}));
};
Ok(point.pos)
}

View File

@ -1,56 +0,0 @@
//! Functions for calling into the engine-utils library (a set of C++ utilities containing various logic for client-side CAD processing)
//! Note that this binary may not be available to all builds of kcl, so fallbacks that call the engine API should be implemented
use crate::{
errors::{KclError, KclErrorDetails},
std::Args,
};
use anyhow::Result;
use kittycad_modeling_cmds::{length_unit::LengthUnit, shared::Point3d};
mod cpp {
use wasm_bindgen::prelude::wasm_bindgen;
#[wasm_bindgen(module = "/../../lib/engineUtils.ts")]
extern "C" {
#[wasm_bindgen(js_name = getTruePathEndPos, catch)]
pub fn get_true_path_end_pos(sketch: String) -> Result<js_sys::Promise, js_sys::Error>;
}
}
pub fn is_available() -> bool {
true
}
async fn call_cpp<F>(args: &Args, f: F) -> Result<String, KclError>
where
F: FnOnce() -> Result<js_sys::Promise, js_sys::Error>,
{
let promise = f().map_err(|e| {
KclError::Internal(KclErrorDetails {
message: format!("{:?}", e),
source_ranges: vec![args.source_range],
})
})?;
let result = crate::wasm::JsFuture::from(promise).await.map_err(|e| {
KclError::Internal(KclErrorDetails {
message: format!("{:?}", e),
source_ranges: vec![args.source_range],
})
})?;
Ok(result.as_string().unwrap_or_default())
}
pub async fn get_true_path_end_pos(sketch: String, args: &Args) -> Result<Point3d<LengthUnit>, KclError> {
let result_str = call_cpp(args, || cpp::get_true_path_end_pos(sketch.into())).await?;
let point: Point3d<f64> = serde_json::from_str(&result_str).map_err(|e| {
KclError::Type(KclErrorDetails {
message: format!("Failed to path position from json: {}", e),
source_ranges: vec![args.source_range],
})
})?;
Ok(Point3d::<f64>::from(point).map(LengthUnit))
}

View File

@ -8,17 +8,6 @@ pub mod conn_mock;
#[cfg(feature = "engine")]
pub mod conn_wasm;
#[cfg(not(target_arch = "wasm32"))]
#[cfg(feature = "native-engine-utils")]
pub mod engine_utils;
#[cfg(target_arch = "wasm32")]
#[cfg(feature = "wasm-engine-utils")]
pub mod engine_utils_wasm;
#[cfg(feature = "engine")]
#[cfg(any(not(target_arch = "wasm32"), all(not(feature = "native-engine-utils"), not(feature = "wasm-engine-utils"))))]
pub mod engine_utils_api;
use std::{
collections::HashMap,
sync::{Arc, Mutex},

View File

@ -1127,7 +1127,7 @@ pub struct TagEngineInfo {
/// The sketch the tag is on.
pub sketch: uuid::Uuid,
/// The path the tag is on.
pub path: Option<BasePath>,
pub path: Option<Path>,
/// The surface information for the tag.
pub surface: Option<ExtrudeSurface>,
}
@ -1137,10 +1137,10 @@ pub struct TagEngineInfo {
#[ts(export)]
#[serde(tag = "type", rename_all = "camelCase")]
pub struct Sketch {
/// The id of the sketch (this will change when the engine's reference to it changes.
/// The id of the sketch (this will change when the engine's reference to it changes).
pub id: uuid::Uuid,
/// The paths in the sketch.
pub value: Vec<Path>,
pub paths: Vec<Path>,
/// What the sketch is on (can be a plane or a face).
pub on: SketchSurface,
/// The starting path.
@ -1206,7 +1206,7 @@ impl Sketch {
tag_identifier.info = Some(TagEngineInfo {
id: base.geo_meta.id,
sketch: self.id,
path: Some(base.clone()),
path: Some(current_path.clone()),
surface: None,
});
@ -1215,7 +1215,7 @@ impl Sketch {
/// Get the path most recently sketched.
pub(crate) fn latest_path(&self) -> Option<&Path> {
self.value.last()
self.paths.last()
}
/// The "pen" is an imaginary pen drawing the path.
@ -1601,19 +1601,6 @@ pub enum Path {
#[serde(flatten)]
base: BasePath,
},
/// An arc (only used for engine-utils arg serialization for now)
Arc {
#[serde(flatten)]
base: BasePath,
/// angle range
#[ts(type = "[number, number]")]
angle_range: [f64; 2],
/// center
#[ts(type = "[number, number]")]
center: [f64; 2],
/// the arc's radius
radius: f64,
},
/// A arc that is tangential to the last path segment that goes to a point
TangentialArcTo {
#[serde(flatten)]
@ -1633,10 +1620,6 @@ pub enum Path {
center: [f64; 2],
/// arc's direction
ccw: bool,
/// the arc's radius
radius: f64,
/// the arc's angle offset
offset: f64,
},
// TODO: consolidate segment enums, remove Circle. https://github.com/KittyCAD/modeling-app/issues/3940
/// a complete arc
@ -1673,6 +1656,43 @@ pub enum Path {
#[serde(flatten)]
base: BasePath,
},
/// A circular arc, not necessarily tangential to the current point.
Arc {
#[serde(flatten)]
base: BasePath,
/// Center of the circle that this arc is drawn on.
center: [f64; 2],
/// Radius of the circle that this arc is drawn on.
radius: f64,
},
}
/// What kind of path is this?
#[derive(Display)]
enum PathType {
ToPoint,
Base,
TangentialArc,
TangentialArcTo,
Circle,
Horizontal,
AngledLineTo,
Arc,
}
impl From<&Path> for PathType {
fn from(value: &Path) -> Self {
match value {
Path::ToPoint { .. } => Self::ToPoint,
Path::TangentialArcTo { .. } => Self::TangentialArcTo,
Path::TangentialArc { .. } => Self::TangentialArc,
Path::Circle { .. } => Self::Circle,
Path::Horizontal { .. } => Self::Horizontal,
Path::AngledLineTo { .. } => Self::AngledLineTo,
Path::Base { .. } => Self::Base,
Path::Arc { .. } => Self::Arc,
}
}
}
impl Path {
@ -1715,6 +1735,46 @@ impl Path {
}
}
/// Where does this path segment start?
pub fn get_from(&self) -> &[f64; 2] {
&self.get_base().from
}
/// Where does this path segment end?
pub fn get_to(&self) -> &[f64; 2] {
&self.get_base().to
}
/// Length of this path segment, in cartesian plane.
pub fn length(&self) -> f64 {
match self {
Self::ToPoint { .. } | Self::Base { .. } | Self::Horizontal { .. } | Self::AngledLineTo { .. } => {
linear_distance(self.get_from(), self.get_to())
}
Self::TangentialArc {
base: _,
center,
ccw: _,
}
| Self::TangentialArcTo {
base: _,
center,
ccw: _,
} => {
// The radius can be calculated as the linear distance between `to` and `center`,
// or between `from` and `center`. They should be the same.
let radius = linear_distance(self.get_from(), center);
debug_assert_eq!(radius, linear_distance(self.get_to(), center));
// TODO: Call engine utils to figure this out.
linear_distance(self.get_from(), self.get_to())
}
Self::Circle { radius, .. } => 2.0 * std::f64::consts::PI * radius,
Self::Arc { .. } => {
// TODO: Call engine utils to figure this out.
linear_distance(self.get_from(), self.get_to())
}
}
}
pub fn get_base_mut(&mut self) -> Option<&mut BasePath> {
match self {
Path::ToPoint { base } => Some(base),
@ -1729,6 +1789,17 @@ impl Path {
}
}
/// Compute the straight-line distance between a pair of (2D) points.
#[rustfmt::skip]
fn linear_distance(
[x0, y0]: &[f64; 2],
[x1, y1]: &[f64; 2]
) -> f64 {
let y_sq = (y1 - y0).powi(2);
let x_sq = (x1 - x0).powi(2);
(y_sq + x_sq).sqrt()
}
/// An extrude surface.
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, ts_rs::TS, JsonSchema)]
#[ts(export)]
@ -1909,9 +1980,73 @@ impl From<crate::settings::types::ModelingSettings> for ExecutorSettings {
}
}
/// Create a new zoo api client.
#[cfg(not(target_arch = "wasm32"))]
pub fn new_zoo_client(token: Option<String>, engine_addr: Option<String>) -> Result<kittycad::Client> {
let user_agent = concat!(env!("CARGO_PKG_NAME"), ".rs/", env!("CARGO_PKG_VERSION"),);
let http_client = reqwest::Client::builder()
.user_agent(user_agent)
// For file conversions we need this to be long.
.timeout(std::time::Duration::from_secs(600))
.connect_timeout(std::time::Duration::from_secs(60));
let ws_client = reqwest::Client::builder()
.user_agent(user_agent)
// For file conversions we need this to be long.
.timeout(std::time::Duration::from_secs(600))
.connect_timeout(std::time::Duration::from_secs(60))
.connection_verbose(true)
.tcp_keepalive(std::time::Duration::from_secs(600))
.http1_only();
let zoo_token_env = std::env::var("ZOO_API_TOKEN");
let token = if let Some(token) = token {
token
} else if let Ok(token) = std::env::var("KITTYCAD_API_TOKEN") {
if let Ok(zoo_token) = zoo_token_env {
if zoo_token != token {
return Err(anyhow::anyhow!(
"Both environment variables KITTYCAD_API_TOKEN=`{}` and ZOO_API_TOKEN=`{}` are set. Use only one.",
token,
zoo_token
));
}
}
token
} else if let Ok(token) = zoo_token_env {
token
} else {
return Err(anyhow::anyhow!(
"No API token found in environment variables. Use KITTYCAD_API_TOKEN or ZOO_API_TOKEN"
));
};
// Create the client.
let mut client = kittycad::Client::new_from_reqwest(token, http_client, ws_client);
// Set an engine address if it's set.
let kittycad_host_env = std::env::var("KITTYCAD_HOST");
if let Some(addr) = engine_addr {
client.set_base_url(addr);
} else if let Ok(addr) = std::env::var("ZOO_HOST") {
if let Ok(kittycad_host) = kittycad_host_env {
if kittycad_host != addr {
return Err(anyhow::anyhow!(
"Both environment variables KITTYCAD_HOST=`{}` and ZOO_HOST=`{}` are set. Use only one.",
kittycad_host,
addr
));
}
}
client.set_base_url(addr);
} else if let Ok(addr) = kittycad_host_env {
client.set_base_url(addr);
}
Ok(client)
}
impl ExecutorContext {
/// Create a new default executor context.
/// Also returns the response HTTP headers from the server.
#[cfg(not(target_arch = "wasm32"))]
pub async fn new(client: &kittycad::Client, settings: ExecutorSettings) -> Result<Self> {
let (ws, _headers) = client
@ -1956,6 +2091,35 @@ impl ExecutorContext {
})
}
/// Create a new default executor context.
/// With a kittycad client.
/// This allows for passing in `ZOO_API_TOKEN` and `ZOO_HOST` as environment
/// variables.
/// But also allows for passing in a token and engine address directly.
#[cfg(not(target_arch = "wasm32"))]
pub async fn new_with_client(
settings: ExecutorSettings,
token: Option<String>,
engine_addr: Option<String>,
) -> Result<Self> {
// Create the client.
let client = new_zoo_client(token, engine_addr)?;
let ctx = Self::new(&client, settings).await?;
Ok(ctx)
}
/// Create a new default executor context.
/// With the default kittycad client.
/// This allows for passing in `ZOO_API_TOKEN` and `ZOO_HOST` as environment
/// variables.
#[cfg(not(target_arch = "wasm32"))]
pub async fn new_with_default_client(settings: ExecutorSettings) -> Result<Self> {
// Create the client.
let ctx = Self::new_with_client(settings, None, None).await?;
Ok(ctx)
}
pub fn is_mock(&self) -> bool {
self.context_type == ContextType::Mock || self.context_type == ContextType::MockCustomForwarded
}
@ -1963,35 +2127,7 @@ impl ExecutorContext {
/// For executing unit tests.
#[cfg(not(target_arch = "wasm32"))]
pub async fn new_for_unit_test(units: UnitLength, engine_addr: Option<String>) -> Result<Self> {
let user_agent = concat!(env!("CARGO_PKG_NAME"), ".rs/", env!("CARGO_PKG_VERSION"));
let http_client = reqwest::Client::builder()
.user_agent(user_agent)
// For file conversions we need this to be long.
.timeout(std::time::Duration::from_secs(600))
.connect_timeout(std::time::Duration::from_secs(60));
let ws_client = reqwest::Client::builder()
.user_agent(user_agent)
// For file conversions we need this to be long.
.timeout(std::time::Duration::from_secs(600))
.connect_timeout(std::time::Duration::from_secs(60))
.connection_verbose(true)
.tcp_keepalive(std::time::Duration::from_secs(600))
.http1_only();
let token = std::env::var("KITTYCAD_API_TOKEN").expect("KITTYCAD_API_TOKEN not set");
// Create the client.
let mut client = kittycad::Client::new_from_reqwest(token, http_client, ws_client);
// Set a local engine address if it's set.
if let Ok(addr) = std::env::var("LOCAL_ENGINE_ADDR") {
client.set_base_url(addr);
}
if let Some(addr) = engine_addr {
client.set_base_url(addr);
}
let ctx = ExecutorContext::new(
&client,
let ctx = ExecutorContext::new_with_client(
ExecutorSettings {
units,
highlight_edges: true,
@ -1999,6 +2135,8 @@ impl ExecutorContext {
show_grid: false,
replay: None,
},
None,
engine_addr,
)
.await?;
Ok(ctx)

View File

@ -3,41 +3,13 @@ use std::sync::{Arc, RwLock};
use anyhow::Result;
use tower_lsp::LanguageServer;
fn new_zoo_client() -> kittycad::Client {
let user_agent = concat!(env!("CARGO_PKG_NAME"), ".rs/", env!("CARGO_PKG_VERSION"),);
let http_client = reqwest::Client::builder()
.user_agent(user_agent)
// For file conversions we need this to be long.
.timeout(std::time::Duration::from_secs(600))
.connect_timeout(std::time::Duration::from_secs(60));
let ws_client = reqwest::Client::builder()
.user_agent(user_agent)
// For file conversions we need this to be long.
.timeout(std::time::Duration::from_secs(600))
.connect_timeout(std::time::Duration::from_secs(60))
.connection_verbose(true)
.tcp_keepalive(std::time::Duration::from_secs(600))
.http1_only();
let token = std::env::var("KITTYCAD_API_TOKEN").expect("KITTYCAD_API_TOKEN not set");
// Create the client.
let mut client = kittycad::Client::new_from_reqwest(token, http_client, ws_client);
// Set a local engine address if it's set.
if let Ok(addr) = std::env::var("LOCAL_ENGINE_ADDR") {
client.set_base_url(addr);
}
client
}
// Create a fake kcl lsp server for testing.
pub async fn kcl_lsp_server(execute: bool) -> Result<crate::lsp::kcl::Backend> {
let stdlib = crate::std::StdLib::new();
let stdlib_completions = crate::lsp::kcl::get_completions_from_stdlib(&stdlib)?;
let stdlib_signatures = crate::lsp::kcl::get_signatures_from_stdlib(&stdlib)?;
let zoo_client = new_zoo_client();
let zoo_client = crate::executor::new_zoo_client(None, None)?;
let executor_ctx = if execute {
Some(crate::executor::ExecutorContext::new(&zoo_client, Default::default()).await?)

View File

@ -141,23 +141,13 @@ pub(crate) async fn do_post_extrude(
)
.await?;
if sketch.value.is_empty() {
// The "get extrusion face info" API call requires *any* edge on the sketch being extruded.
// So, let's just use the first one.
let Some(any_edge_id) = sketch.paths.first().map(|edge| edge.get_base().geo_meta.id) else {
return Err(KclError::Type(KclErrorDetails {
message: "Expected a non-empty sketch".to_string(),
source_ranges: vec![args.source_range],
}));
}
let edge_id = sketch.value.iter().find_map(|segment| match segment {
Path::ToPoint { base } | Path::Circle { base, .. } => Some(base.geo_meta.id),
_ => None,
});
let Some(edge_id) = edge_id else {
return Err(KclError::Type(KclErrorDetails {
message: "Expected a Path::ToPoint variant".to_string(),
source_ranges: vec![args.source_range],
}));
};
let mut sketch = sketch.clone();
@ -171,7 +161,7 @@ pub(crate) async fn do_post_extrude(
.send_modeling_cmd(
exec_state.id_generator.next_uuid(),
ModelingCmd::from(mcmd::Solid3dGetExtrusionFaceInfo {
edge_id,
edge_id: any_edge_id,
object_id: sketch.id,
}),
)
@ -229,12 +219,15 @@ pub(crate) async fn do_post_extrude(
} = analyze_faces(exec_state, &args, face_infos);
// Iterate over the sketch.value array and add face_id to GeoMeta
let new_value = sketch
.value
.paths
.iter()
.flat_map(|path| {
if let Some(Some(actual_face_id)) = face_id_map.get(&path.get_base().geo_meta.id) {
match path {
Path::TangentialArc { .. } | Path::TangentialArcTo { .. } | Path::Circle { .. } => {
Path::Arc { .. }
| Path::TangentialArc { .. }
| Path::TangentialArcTo { .. }
| Path::Circle { .. } => {
let extrude_surface = ExtrudeSurface::ExtrudeArc(crate::executor::ExtrudeArc {
face_id: *actual_face_id,
tag: path.get_base().tag.clone(),
@ -256,7 +249,6 @@ pub(crate) async fn do_post_extrude(
});
Some(extrude_surface)
}
Path::Arc { .. } => todo!(),
}
} else if args.ctx.is_mock() {
// Only pre-populate the extrude surface if we are in mock mode.

View File

@ -42,7 +42,7 @@ fn inner_segment_end_x(tag: &TagIdentifier, exec_state: &mut ExecState, args: Ar
})
})?;
Ok(path.to[0])
Ok(path.get_base().to[0])
}
/// Returns the segment end of y.
@ -79,7 +79,7 @@ fn inner_segment_end_y(tag: &TagIdentifier, exec_state: &mut ExecState, args: Ar
})
})?;
Ok(path.to[1])
Ok(path.get_to()[1])
}
/// Returns the last segment of x.
@ -109,7 +109,7 @@ pub async fn last_segment_x(_exec_state: &mut ExecState, args: Args) -> Result<K
}]
fn inner_last_segment_x(sketch: Sketch, args: Args) -> Result<f64, KclError> {
let last_line = sketch
.value
.paths
.last()
.ok_or_else(|| {
KclError::Type(KclErrorDetails {
@ -149,7 +149,7 @@ pub async fn last_segment_y(_exec_state: &mut ExecState, args: Args) -> Result<K
}]
fn inner_last_segment_y(sketch: Sketch, args: Args) -> Result<f64, KclError> {
let last_line = sketch
.value
.paths
.last()
.ok_or_else(|| {
KclError::Type(KclErrorDetails {
@ -202,7 +202,7 @@ fn inner_segment_length(tag: &TagIdentifier, exec_state: &mut ExecState, args: A
})
})?;
let result = ((path.from[1] - path.to[1]).powi(2) + (path.from[0] - path.to[0]).powi(2)).sqrt();
let result = path.length();
Ok(result)
}
@ -242,7 +242,7 @@ fn inner_segment_angle(tag: &TagIdentifier, exec_state: &mut ExecState, args: Ar
})
})?;
let result = between(path.from.into(), path.to.into());
let result = between(path.get_from().into(), path.get_to().into());
Ok(result.to_degrees())
}
@ -286,10 +286,10 @@ fn inner_angle_to_match_length_x(
})
})?;
let length = ((path.from[1] - path.to[1]).powi(2) + (path.from[0] - path.to[0]).powi(2)).sqrt();
let length = path.length();
let last_line = sketch
.value
.paths
.last()
.ok_or_else(|| {
KclError::Type(KclErrorDetails {
@ -350,10 +350,10 @@ fn inner_angle_to_match_length_y(
})
})?;
let length = ((path.from[1] - path.to[1]).powi(2) + (path.from[0] - path.to[0]).powi(2)).sqrt();
let length = path.length();
let last_line = sketch
.value
.paths
.last()
.ok_or_else(|| {
KclError::Type(KclErrorDetails {

View File

@ -134,7 +134,7 @@ async fn inner_circle(
new_sketch.add_tag(tag, &current_path);
}
new_sketch.value.push(current_path);
new_sketch.paths.push(current_path);
args.batch_modeling_cmd(id, ModelingCmd::from(mcmd::ClosePath { path_id: new_sketch.id }))
.await?;

View File

@ -6,7 +6,7 @@ use anyhow::Result;
use derive_docs::stdlib;
use kcmc::shared::Point2d as KPoint2d; // Point2d is already defined in this pkg, to impl ts_rs traits.
use kcmc::{each_cmd as mcmd, length_unit::LengthUnit, shared::Angle, ModelingCmd};
use kittycad_modeling_cmds::{self as kcmc, units};
use kittycad_modeling_cmds as kcmc;
use kittycad_modeling_cmds::shared::PathSegment;
use parse_display::{Display, FromStr};
use schemars::JsonSchema;
@ -21,26 +21,13 @@ use crate::{
},
std::{
utils::{
arc_angles, arc_center_and_end, arc_start_center_and_end, get_tangent_point_from_previous_arc,
get_tangential_arc_to_info, get_x_component, get_y_component, intersection_with_parallel_line,
TangentialArcInfoInput,
arc_angles, arc_center_and_end, get_tangent_point_from_previous_arc, get_tangential_arc_to_info,
get_x_component, get_y_component, intersection_with_parallel_line, TangentialArcInfoInput,
},
Args,
},
};
#[cfg(not(target_arch = "wasm32"))]
#[cfg(feature = "native-engine-utils")]
use crate::engine::engine_utils;
#[cfg(target_arch = "wasm32")]
#[cfg(feature = "wasm-engine-utils")]
use crate::engine::engine_utils_wasm as engine_utils;
#[cfg(feature = "engine")]
#[cfg(any(not(target_arch = "wasm32"), all(not(feature = "native-engine-utils"), not(feature = "wasm-engine-utils"))))]
use crate::engine::engine_utils_api as engine_utils;
/// A tag for a face.
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, ts_rs::TS, JsonSchema)]
#[ts(export)]
@ -167,7 +154,7 @@ async fn inner_line_to(
new_sketch.add_tag(tag, &current_path);
}
new_sketch.value.push(current_path);
new_sketch.paths.push(current_path);
Ok(new_sketch)
}
@ -336,7 +323,7 @@ async fn inner_line(
new_sketch.add_tag(tag, &current_path);
}
new_sketch.value.push(current_path);
new_sketch.paths.push(current_path);
Ok(new_sketch)
}
@ -519,7 +506,7 @@ async fn inner_angled_line(
new_sketch.add_tag(tag, &current_path);
}
new_sketch.value.push(current_path);
new_sketch.paths.push(current_path);
Ok(new_sketch)
}
@ -826,7 +813,7 @@ async fn inner_angled_line_that_intersects(
let from = sketch.current_pen_position()?;
let to = intersection_with_parallel_line(
&[path.from.into(), path.to.into()],
&[path.get_from().into(), path.get_to().into()],
data.offset.unwrap_or_default(),
data.angle,
from,
@ -1250,14 +1237,16 @@ pub(crate) async fn inner_start_profile_at(
id: path_id,
original_id: path_id,
on: sketch_surface.clone(),
value: vec![],
paths: vec![],
meta: vec![args.source_range.into()],
tags: if let Some(tag) = &tag {
let mut tag_identifier: TagIdentifier = tag.into();
tag_identifier.info = Some(TagEngineInfo {
id: current_path.geo_meta.id,
sketch: path_id,
path: Some(current_path.clone()),
path: Some(Path::Base {
base: current_path.clone(),
}),
surface: None,
});
HashMap::from([(tag.name.to_string(), tag_identifier)])
@ -1424,7 +1413,7 @@ pub(crate) async fn inner_close(
new_sketch.add_tag(tag, &current_path);
}
new_sketch.value.push(current_path);
new_sketch.paths.push(current_path);
Ok(new_sketch)
}
@ -1508,45 +1497,7 @@ pub(crate) async fn inner_arc(
} => {
let a_start = Angle::from_degrees(*angle_start);
let a_end = Angle::from_degrees(*angle_end);
let (_, center, mut end) = arc_start_center_and_end(from, a_start, a_end, *radius);
if engine_utils::is_available() {
let mut path_plus_arc = sketch.clone();
let to = [0.0, 0.0];
let arc = Path::Arc {
base: BasePath {
from: from.into(),
to,
tag: None,
geo_meta: GeoMeta {
id: uuid::Uuid::new_v4(),
metadata: args.source_range.into(),
},
},
angle_range: [*angle_start, *angle_end],
center: [center.x, center.y],
radius: *radius,
};
path_plus_arc.value.push(arc);
let sketch_json_value = serde_json::to_string(&path_plus_arc).map_err(|e| {
KclError::Type(KclErrorDetails {
message: format!("Failed to convert sketch to json: {}", e),
source_ranges: vec![args.source_range],
})
})?;
//???? someone double check me on this unit conversion - mike
let units = units::UnitLength::Millimeters;
let result_end = engine_utils::get_true_path_end_pos(sketch_json_value, &args).await?;
end = Point2d {
x: result_end.x.to_millimeters(units),
y: result_end.y.to_millimeters(units),
};
}
let (center, end) = arc_center_and_end(from, a_start, a_end, *radius);
(center, a_start, a_end, *radius, end)
}
ArcData::CenterToRadius { center, to, radius } => {
@ -1579,7 +1530,7 @@ pub(crate) async fn inner_arc(
)
.await?;
let current_path = Path::ToPoint {
let current_path = Path::Arc {
base: BasePath {
from: from.into(),
to: end.into(),
@ -1589,6 +1540,8 @@ pub(crate) async fn inner_arc(
metadata: args.source_range.into(),
},
},
center: center.into(),
radius,
};
let mut new_sketch = sketch.clone();
@ -1596,7 +1549,7 @@ pub(crate) async fn inner_arc(
new_sketch.add_tag(tag, &current_path);
}
new_sketch.value.push(current_path);
new_sketch.paths.push(current_path);
Ok(new_sketch)
}
@ -1667,7 +1620,7 @@ async fn inner_tangential_arc(
let id = exec_state.id_generator.next_uuid();
let (center, to, ccw, radius, offset) = match data {
let (center, to, ccw) = match data {
TangentialArcData::RadiusAndOffset { radius, offset } => {
// KCL stdlib types use degrees.
let offset = Angle::from_degrees(offset);
@ -1705,15 +1658,13 @@ async fn inner_tangential_arc(
}),
)
.await?;
(center, to.into(), ccw, radius, offset)
(center, to.into(), ccw)
}
};
let current_path = Path::TangentialArc {
ccw,
center: center.into(),
radius,
offset: offset.to_degrees(),
base: BasePath {
from: from.into(),
to,
@ -1730,7 +1681,7 @@ async fn inner_tangential_arc(
new_sketch.add_tag(tag, &current_path);
}
new_sketch.value.push(current_path);
new_sketch.paths.push(current_path);
Ok(new_sketch)
}
@ -1826,7 +1777,7 @@ async fn inner_tangential_arc_to(
new_sketch.add_tag(tag, &current_path);
}
new_sketch.value.push(current_path);
new_sketch.paths.push(current_path);
Ok(new_sketch)
}
@ -1911,7 +1862,7 @@ async fn inner_tangential_arc_to_relative(
new_sketch.add_tag(tag, &current_path);
}
new_sketch.value.push(current_path);
new_sketch.paths.push(current_path);
Ok(new_sketch)
}
@ -2004,7 +1955,7 @@ async fn inner_bezier_curve(
new_sketch.add_tag(tag, &current_path);
}
new_sketch.value.push(current_path);
new_sketch.paths.push(current_path);
Ok(new_sketch)
}

View File

@ -21,7 +21,7 @@ pub fn normalize(angle: Angle) -> Angle {
Angle::from_degrees(if result > 180.0 { result - 360.0 } else { result })
}
/// Gives the ▲-angle between from and to angles (shortest path), use radians.
/// Gives the ▲-angle between from and to angles (shortest path)
///
/// Sign of the returned angle denotes direction, positive means counterClockwise 🔄
/// # Examples
@ -221,33 +221,6 @@ pub fn arc_center_and_end(from: Point2d, start_angle: Angle, end_angle: Angle, r
(center, end)
}
pub fn arc_start_center_and_end(
from: Point2d,
start_angle: Angle,
end_angle: Angle,
radius: f64,
) -> (Point2d, Point2d, Point2d) {
let start_angle = start_angle.to_radians();
let end_angle = end_angle.to_radians();
let center = Point2d {
x: -1.0 * (radius * start_angle.cos() - from.x),
y: -1.0 * (radius * start_angle.sin() - from.y),
};
let start = Point2d {
x: center.x + radius * start_angle.cos(),
y: center.y + radius * start_angle.sin(),
};
let end = Point2d {
x: center.x + radius * end_angle.cos(),
y: center.y + radius * end_angle.sin(),
};
(start, center, end)
}
pub fn arc_angles(
from: Point2d,
to: Point2d,

View File

@ -43,38 +43,7 @@ async fn do_execute_and_snapshot(ctx: &ExecutorContext, code: &str) -> anyhow::R
}
async fn new_context(units: UnitLength, with_auth: bool) -> anyhow::Result<ExecutorContext> {
let user_agent = concat!(env!("CARGO_PKG_NAME"), ".rs/", env!("CARGO_PKG_VERSION"),);
let http_client = reqwest::Client::builder()
.user_agent(user_agent)
// For file conversions we need this to be long.
.timeout(std::time::Duration::from_secs(600))
.connect_timeout(std::time::Duration::from_secs(60));
let ws_client = reqwest::Client::builder()
.user_agent(user_agent)
// For file conversions we need this to be long.
.timeout(std::time::Duration::from_secs(600))
.connect_timeout(std::time::Duration::from_secs(60))
.connection_verbose(true)
.tcp_keepalive(std::time::Duration::from_secs(600))
.http1_only();
let token = if with_auth {
std::env::var("KITTYCAD_API_TOKEN").expect("KITTYCAD_API_TOKEN not set")
} else {
"bad_token".to_string()
};
// Create the client.
let mut client = kittycad::Client::new_from_reqwest(token, http_client, ws_client);
// Set a local engine address if it's set.
if let Ok(addr) = std::env::var("LOCAL_ENGINE_ADDR") {
if with_auth {
client.set_base_url(addr);
}
}
let ctx = ExecutorContext::new(
&client,
let ctx = ExecutorContext::new_with_client(
ExecutorSettings {
units,
highlight_edges: true,
@ -82,6 +51,8 @@ async fn new_context(units: UnitLength, with_auth: bool) -> anyhow::Result<Execu
show_grid: false,
replay: None,
},
if with_auth { None } else { Some("bad_token".to_string()) },
None,
)
.await?;
Ok(ctx)

Some files were not shown because too many files have changed in this diff Show More