Compare commits

...

15 Commits

Author SHA1 Message Date
e5a26c42e6 Cut release v0.24.8 (#3263) 2024-08-05 10:32:16 +10:00
9c87b124d9 Jump to error code on badge click (#3262)
* add function to scroll to view

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* scroll into view on click

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* add test for jump to code with error

Signed-off-by: Jess Frazelle <github@jessfraz.com>

---------

Signed-off-by: Jess Frazelle <github@jessfraz.com>
2024-08-04 22:59:04 +00:00
21389c089d apply fillets before a shell (#3261)
* add test for fillet and shell

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* apply fillets before a shell

Signed-off-by: Jess Frazelle <github@jessfraz.com>

---------

Signed-off-by: Jess Frazelle <github@jessfraz.com>
2024-08-04 15:37:40 -07:00
29f57be8c1 editor repaints any errors when rendered (#3260)
* editor repaints any errors when rendered

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* Update src/lang/KclSingleton.ts

* fix test

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* fix typo

---------

Signed-off-by: Jess Frazelle <github@jessfraz.com>
Co-authored-by: Kurt Hutten <k.hutten@protonmail.ch>
2024-08-04 15:16:34 -07:00
cd55f07619 Bump serde_json from 1.0.121 to 1.0.122 in /src/wasm-lib (#3235)
Bumps [serde_json](https://github.com/serde-rs/json) from 1.0.121 to 1.0.122.
- [Release notes](https://github.com/serde-rs/json/releases)
- [Commits](https://github.com/serde-rs/json/compare/v1.0.121...v1.0.122)

---
updated-dependencies:
- dependency-name: serde_json
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-04 12:57:54 -07:00
baf7d3dd9d Add print button (#3133)
* add print button

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* cleanup

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* generate more types

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* add a github action to generate machine api-types

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* fix

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* New machine-api types

* actually print on the real machine

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* A snapshot a day keeps the bugs away! 📷🐛 (OS: ubuntu)

* add more

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* New machine-api types

* get the current machine

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* New machine-api types

* know when error

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* updates

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* updates

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* fmt

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* add fmt

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* New machine-api types

* empty

* empty

* update machine api

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* New machine-api types

* empty

* New machine-api types

* emptuy

* no circular deps

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* New machine-api types

* remove recursive dep

Signed-off-by: Jess Frazelle <github@jessfraz.com>

---------

Signed-off-by: Jess Frazelle <github@jessfraz.com>
Co-authored-by: Jess Frazelle <github@jessfraz.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Jess Frazelle <jessfraz@users.noreply.github.com>
2024-08-04 04:51:30 +00:00
54a9a50969 fix bug when engine returns an error on websocket export (#3256)
Signed-off-by: Jess Frazelle <github@jessfraz.com>
2024-08-04 02:24:06 +00:00
2830c750fa remove unused timeout (#3254) 2024-08-03 23:10:04 +00:00
d3160cd85a Update machine-api spec (#3253)
YOYO NEW API SPEC!

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-08-03 16:07:42 -07:00
fd1b4c3a32 fix snapshot tests, don't let them silently fail (#3228)
fix snapshots, don't let them silently fail
2024-08-03 22:29:28 +00:00
b0a41c31ac Update machine-api spec (#3252)
YOYO NEW API SPEC!

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-08-03 15:25:02 -07:00
5825ba575c test for default planes in empty scene (#3249)
* test for default planes in empty scene

* fmt

* skit if webkit

* fmt
2024-08-03 21:34:56 +10:00
e5bec2140e ArtifactGraph reThink (PART 3) (#3140)
* adjust engine connection to opt out of webRTC connection

* refactor start and test setup

* add env to unit test

* spell config update

* fix beforeAll order bug

* initial integration of new artifact map with tests passing

* remove old artifact map and clean up

* graph artifact map

* have graph commited

* have graph commited

* remove bad file

* install playwright

* fmt

* commit permissions

* typo

* flesh out tests more

* Look at this (photo)Graph *in the voice of Nickelback*

* multi highlight

* redo image logic

* add in solid 2d data into artifactMap

* fix snapshots

* stabiles graph images

* Look at this (photo)Graph *in the voice of Nickelback*

* tweak tests

* rename blend to edgeCut

* Look at this (photo)Graph *in the voice of Nickelback*

* fix playw tests

* start of artifact map rename to graph

* rename file

* rename test

* rename clearup

* comments

* docs

* docs proof read

* few tweaks here and there

* typos

* delete get parent logic

* nit, combine if statements

* remove unused param

* fix silly test bug

* rename surfId to sufaceId

* rename types

* update comments

* add comment

* add extra check

* Look at this (photo)Graph *in the voice of Nickelback*

* pull out merge artifact function

* update comments

* fix test

* fmt

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-08-03 18:08:51 +10:00
7bf6bc3048 Fix computed properties of KCL objects (#3246)
* Fix computed properties of KCL objects

Fixes https://github.com/KittyCAD/modeling-app/issues/3201

* Incorporate Jon's feedback
2024-08-02 22:24:00 -07:00
22f9df73ed Update machine-api spec (#3247)
YOYO NEW API SPEC!

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-08-02 21:08:20 -07:00
81 changed files with 5804 additions and 1221 deletions

View File

@ -1,3 +1,3 @@
[codespell] [codespell]
ignore-words-list: crate,everytime,inout,co-ordinate,ot,nwo,absolutey,atleast,ue ignore-words-list: crate,everytime,inout,co-ordinate,ot,nwo,absolutey,atleast,ue,afterall
skip: **/target,node_modules,build,**/Cargo.lock,./docs/kcl/*.md,./src-tauri/gen/schemas skip: **/target,node_modules,build,**/Cargo.lock,./docs/kcl/*.md,./src-tauri/gen/schemas

View File

@ -20,6 +20,11 @@ concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
cancel-in-progress: true cancel-in-progress: true
permissions:
contents: write
pull-requests: write
actions: read
jobs: jobs:
check-format: check-format:
runs-on: 'ubuntu-latest' runs-on: 'ubuntu-latest'
@ -85,7 +90,38 @@ jobs:
- run: yarn simpleserver:ci - run: yarn simpleserver:ci
- run: yarn test:nowatch - name: Install Chromium Browser
run: yarn playwright install chromium --with-deps
- name: run unit tests
run: yarn test:nowatch
env:
VITE_KC_DEV_TOKEN: ${{ secrets.KITTYCAD_API_TOKEN_DEV }}
- name: check for changes
id: git-check
run: |
git add src/lang/std/artifactMapGraphs
if git status src/lang/std/artifactMapGraphs | grep -q "Changes to be committed"
then echo "modified=true" >> $GITHUB_OUTPUT
else echo "modified=false" >> $GITHUB_OUTPUT
fi
- name: Commit changes, if any
if: steps.git-check.outputs.modified == 'true'
run: |
git config --local user.email "github-actions[bot]@users.noreply.github.com"
git config --local user.name "github-actions[bot]"
git remote set-url origin https://${{ github.actor }}:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }}.git
git fetch origin
echo ${{ github.head_ref }}
git checkout ${{ github.head_ref }}
# TODO when webkit works on ubuntu remove the os part of the commit message
git commit -am "Look at this (photo)Graph *in the voice of Nickelback*" || true
git push
git push origin ${{ github.head_ref }}
prepare-json-files: prepare-json-files:

View File

@ -0,0 +1,49 @@
name: generate machine-api types
on:
pull_request:
paths:
- 'openapi/machine-api.json'
- '.github/workflows/generate-machine-api-types.yml'
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
cancel-in-progress: true
permissions:
contents: write
jobs:
generate:
runs-on: 'ubuntu-latest'
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: '.nvmrc'
cache: 'yarn'
- run: yarn install
- run: yarn generate:machine-api
- run: yarn fmt
- name: check for changes
id: git-check
run: |
git add .
if git status | grep -q "Changes to be committed"
then echo "modified=true" >> $GITHUB_OUTPUT
else echo "modified=false" >> $GITHUB_OUTPUT
fi
- name: Commit changes, if any
if: steps.git-check.outputs.modified == 'true'
run: |
git add .
git config --local user.email "github-actions[bot]@users.noreply.github.com"
git config --local user.name "github-actions[bot]"
git remote set-url origin https://${{ github.actor }}:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }}.git
git fetch origin
echo ${{ github.head_ref }}
git checkout ${{ github.head_ref }}
git commit -am "New machine-api types" || true
git push
git push origin ${{ github.head_ref }}

View File

@ -106,13 +106,19 @@ jobs:
- name: build web - name: build web
run: yarn build:local run: yarn build:local
- name: Run ubuntu/chrome snapshots - name: Run ubuntu/chrome snapshots
continue-on-error: true
run: | run: |
yarn playwright test --project="Google Chrome" --update-snapshots e2e/playwright/snapshot-tests.spec.ts yarn playwright test --project="Google Chrome" --retries="3" --update-snapshots e2e/playwright/snapshot-tests.spec.ts
env: env:
CI: true CI: true
token: ${{ secrets.KITTYCAD_API_TOKEN_DEV }} token: ${{ secrets.KITTYCAD_API_TOKEN_DEV }}
snapshottoken: ${{ secrets.KITTYCAD_API_TOKEN }} snapshottoken: ${{ secrets.KITTYCAD_API_TOKEN }}
- uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report-ubuntu-snapshot-${{ github.sha }}
path: playwright-report/
retention-days: 30
overwrite: true
- name: Clean up test-results - name: Clean up test-results
if: always() if: always()
continue-on-error: true continue-on-error: true

2
.gitignore vendored
View File

@ -39,6 +39,7 @@ src/wasm-lib/grackle/test_json_output
e2e/playwright/playwright-secrets.env e2e/playwright/playwright-secrets.env
e2e/playwright/temp1.png e2e/playwright/temp1.png
e2e/playwright/temp2.png e2e/playwright/temp2.png
e2e/playwright/temp3.png
# exports from snapshot-tests.spec.ts "exports of each format should work" # exports from snapshot-tests.spec.ts "exports of each format should work"
e2e/playwright/export-snapshots/* e2e/playwright/export-snapshots/*
!e2e/playwright/export-snapshots/*.png !e2e/playwright/export-snapshots/*.png
@ -48,6 +49,7 @@ e2e/playwright/export-snapshots/*
/playwright-report/ /playwright-report/
/blob-report/ /blob-report/
/playwright/.cache/ /playwright/.cache/
/src/lang/std/artifactMapCache
## generated files ## generated files

View File

@ -19,6 +19,7 @@ import {
TEST_SETTINGS_ONBOARDING_EXPORT, TEST_SETTINGS_ONBOARDING_EXPORT,
TEST_SETTINGS_ONBOARDING_START, TEST_SETTINGS_ONBOARDING_START,
TEST_CODE_GIZMO, TEST_CODE_GIZMO,
TEST_CODE_LONG_WITH_ERROR_OUT_OF_VIEW,
TEST_SETTINGS_ONBOARDING_USER_MENU, TEST_SETTINGS_ONBOARDING_USER_MENU,
TEST_SETTINGS_ONBOARDING_PARAMETRIC_MODELING, TEST_SETTINGS_ONBOARDING_PARAMETRIC_MODELING,
} from './storageStates' } from './storageStates'
@ -26,7 +27,7 @@ import * as TOML from '@iarna/toml'
import { LineInputsType } from 'lang/std/sketchcombos' import { LineInputsType } from 'lang/std/sketchcombos'
import { Coords2d } from 'lang/std/sketch' import { Coords2d } from 'lang/std/sketch'
import { KCL_DEFAULT_LENGTH } from 'lib/constants' import { KCL_DEFAULT_LENGTH } from 'lib/constants'
import { EngineCommand } from 'lang/std/artifactMap' import { EngineCommand } from 'lang/std/artifactGraph'
import { onboardingPaths } from 'routes/Onboarding/paths' import { onboardingPaths } from 'routes/Onboarding/paths'
import { bracket } from 'lib/exampleKcl' import { bracket } from 'lib/exampleKcl'
@ -466,7 +467,7 @@ test.describe('Testing Camera Movement', () => {
await expect(page.getByTestId('hover-highlight')).not.toBeVisible() await expect(page.getByTestId('hover-highlight')).not.toBeVisible()
await page.waitForTimeout(100) await page.waitForTimeout(200)
// hover over horizontal line // hover over horizontal line
await u.canvasLocator.hover({ position: { x: 800, y } }) await u.canvasLocator.hover({ position: { x: 800, y } })
await expect(page.getByTestId('hover-highlight').first()).toBeVisible({ await expect(page.getByTestId('hover-highlight').first()).toBeVisible({
@ -2623,10 +2624,9 @@ test.describe('Testing selections', () => {
await page.mouse.move(startXPx + PUR * 15, 500 - PUR * 10) await page.mouse.move(startXPx + PUR * 15, 500 - PUR * 10)
await expect(page.getByTestId('hover-highlight').first()).toBeVisible() await expect(page.getByTestId('hover-highlight').first()).toBeVisible()
// bg-yellow-200 is more brittle than hover-highlight, but is closer to the user experience // bg-yellow-300/70 is more brittle than hover-highlight, but is closer to the user experience
// and will be an easy fix if it breaks because we change the colour // and will be an easy fix if it breaks because we change the colour
await expect(page.locator('.bg-yellow-200').first()).toBeVisible() await expect(page.locator('.bg-yellow-300\\/70')).toBeVisible()
// check mousing off, than mousing onto another line // check mousing off, than mousing onto another line
await page.mouse.move(startXPx + PUR * 10, 500 - PUR * 15) // mouse off await page.mouse.move(startXPx + PUR * 10, 500 - PUR * 15) // mouse off
await expect(page.getByTestId('hover-highlight')).not.toBeVisible() await expect(page.getByTestId('hover-highlight')).not.toBeVisible()
@ -3078,7 +3078,7 @@ const sketch002 = startSketchOn(launderExtrudeThroughVar, seg02)
await expect(page.getByTestId('hover-highlight').first()).not.toBeVisible() await expect(page.getByTestId('hover-highlight').first()).not.toBeVisible()
await page.mouse.move(flatExtrusionFace[0], flatExtrusionFace[1]) await page.mouse.move(flatExtrusionFace[0], flatExtrusionFace[1])
await expect(page.getByTestId('hover-highlight')).toHaveCount(5) // multiple lines await expect(page.getByTestId('hover-highlight')).toHaveCount(6) // multiple lines
await page.mouse.move(nothing[0], nothing[1]) await page.mouse.move(nothing[0], nothing[1])
await page.waitForTimeout(100) await page.waitForTimeout(100)
await expect(page.getByTestId('hover-highlight').first()).not.toBeVisible() await expect(page.getByTestId('hover-highlight').first()).not.toBeVisible()
@ -4903,6 +4903,116 @@ const sketch002 = startSketchOn(extrude001, 'END')
`.replace(/\s/g, '') `.replace(/\s/g, '')
) )
}) })
test('empty-scene default-planes act as expected', async ({
page,
browserName,
}) => {
test.skip(
browserName === 'webkit',
'Skip on Safari until `window.tearDown` is working there'
)
/**
* Tests the following things
* 1) The the planes are there on load because the scene is empty
* 2) The planes don't changes color when hovered initially
* 3) Putting something in the scene makes the planes hidden
* 4) Removing everything from the scene shows the plans again
* 3) Once "start sketch" is click, the planes do respond to hovers
* 4) Selecting a plan works as expected, i.e. sketch mode
* 5) Reloading the scene with something already in the scene means the planes are hidden
*/
const u = await getUtils(page)
await page.setViewportSize({ width: 1200, height: 500 })
await u.waitForAuthSkipAppStart()
await u.openDebugPanel()
await u.expectCmdLog('[data-message-type="execution-done"]')
await u.closeDebugPanel()
const XYPlanePoint = { x: 774, y: 116 } as const
const unHoveredColor: [number, number, number] = [47, 47, 93]
expect(
await u.getGreatestPixDiff(XYPlanePoint, unHoveredColor)
).toBeLessThan(8)
await page.mouse.move(XYPlanePoint.x, XYPlanePoint.y)
await page.waitForTimeout(200)
// color should not change for having been hovered
expect(
await u.getGreatestPixDiff(XYPlanePoint, unHoveredColor)
).toBeLessThan(8)
await u.openAndClearDebugPanel()
await u.codeLocator.fill(`const sketch001 = startSketchOn('XY')
|> startProfileAt([-10, -10], %)
|> line([20, 0], %)
|> line([0, 20], %)
|> xLine(-20, %)
`)
await u.expectCmdLog('[data-message-type="execution-done"]')
const noPlanesColor: [number, number, number] = [30, 30, 30]
expect(
await u.getGreatestPixDiff(XYPlanePoint, noPlanesColor)
).toBeLessThan(3)
await u.clearCommandLogs()
await u.removeCurrentCode()
await u.expectCmdLog('[data-message-type="execution-done"]')
await expect
.poll(() => u.getGreatestPixDiff(XYPlanePoint, unHoveredColor), {
timeout: 5_000,
})
.toBeLessThan(8)
// click start Sketch
await page.getByRole('button', { name: 'Start Sketch' }).click()
await page.mouse.move(XYPlanePoint.x, XYPlanePoint.y, { steps: 5 })
const hoveredColor: [number, number, number] = [93, 93, 127]
// now that we're expecting the user to select a plan, it does respond to hover
await expect
.poll(() => u.getGreatestPixDiff(XYPlanePoint, hoveredColor))
.toBeLessThan(8)
await page.mouse.click(XYPlanePoint.x, XYPlanePoint.y)
await page.waitForTimeout(600)
await page.mouse.click(XYPlanePoint.x, XYPlanePoint.y)
await page.waitForTimeout(200)
await page.mouse.click(XYPlanePoint.x + 50, XYPlanePoint.y + 50)
await expect(u.codeLocator)
.toHaveText(`const sketch001 = startSketchOn('XZ')
|> startProfileAt([11.8, 9.09], %)
|> line([3.39, -3.39], %)
`)
await page.addInitScript(async () => {
localStorage.setItem(
'persistCode',
`const sketch001 = startSketchOn('XZ')
|> startProfileAt([11.8, 9.09], %)
|> line([3.39, -3.39], %)
`
)
})
await page.reload()
await u.waitForAuthSkipAppStart()
await u.openDebugPanel()
await u.expectCmdLog('[data-message-type="execution-done"]')
await u.closeDebugPanel()
// expect there to be no planes on load since there's something in the scene
expect(
await u.getGreatestPixDiff(XYPlanePoint, noPlanesColor)
).toBeLessThan(3)
})
}) })
test.describe('Testing constraints', () => { test.describe('Testing constraints', () => {
@ -8098,33 +8208,131 @@ const sketch002 = extrude(${[5, 5]} + 7, sketch002)`
await expect(page.locator('.cm-content')).toHaveText(result2.regExp) await expect(page.locator('.cm-content')).toHaveText(result2.regExp)
}) })
test('Typing KCL errors induces a badge on the error logs pane button', async ({ test.describe('Code pane and errors', () => {
page, test('Typing KCL errors induces a badge on the code pane button', async ({
}) => { page,
const u = await getUtils(page) }) => {
const u = await getUtils(page)
// Load the app with the working starter code // Load the app with the working starter code
await page.addInitScript((code) => { await page.addInitScript((code) => {
localStorage.setItem('persistCode', code) localStorage.setItem('persistCode', code)
}, bracket) }, bracket)
await page.setViewportSize({ width: 1200, height: 500 }) await page.setViewportSize({ width: 1200, height: 500 })
await u.waitForAuthSkipAppStart() await u.waitForAuthSkipAppStart()
// wait for execution done // wait for execution done
await u.openDebugPanel() await u.openDebugPanel()
await u.expectCmdLog('[data-message-type="execution-done"]') await u.expectCmdLog('[data-message-type="execution-done"]')
await u.closeDebugPanel() await u.closeDebugPanel()
// Ensure no badge is present // Ensure no badge is present
const errorLogsButton = page.getByRole('button', { name: 'KCL Code pane' }) const codePaneButtonHolder = page.locator('#code-button-holder')
await expect(errorLogsButton).not.toContainText('notification') await expect(codePaneButtonHolder).not.toContainText('notification')
// Delete a character to break the KCL // Delete a character to break the KCL
await u.openKclCodePanel() await u.openKclCodePanel()
await page.getByText('extrude(').click() await page.getByText('extrude(').click()
await page.keyboard.press('Backspace') await page.keyboard.press('Backspace')
// Ensure that a badge appears on the button // Ensure that a badge appears on the button
await expect(errorLogsButton).toContainText('notification') await expect(codePaneButtonHolder).toContainText('notification')
})
test('Opening and closing the code pane will consistently show error diagnostics', async ({
page,
}) => {
const u = await getUtils(page)
// Load the app with the working starter code
await page.addInitScript((code) => {
localStorage.setItem('persistCode', code)
}, bracket)
await page.setViewportSize({ width: 1200, height: 900 })
await u.waitForAuthSkipAppStart()
// wait for execution done
await u.openDebugPanel()
await u.expectCmdLog('[data-message-type="execution-done"]')
await u.closeDebugPanel()
// Ensure we have no errors in the gutter.
await expect(page.locator('.cm-lint-marker-error')).not.toBeVisible()
// Ensure no badge is present
const codePaneButton = page.getByRole('button', { name: 'KCL Code pane' })
const codePaneButtonHolder = page.locator('#code-button-holder')
await expect(codePaneButtonHolder).not.toContainText('notification')
// Delete a character to break the KCL
await u.openKclCodePanel()
await page.getByText('extrude(').click()
await page.keyboard.press('Backspace')
// Ensure that a badge appears on the button
await expect(codePaneButtonHolder).toContainText('notification')
// Ensure we have an error diagnostic.
await expect(page.locator('.cm-lint-marker-error')).toBeVisible()
// error text on hover
await page.hover('.cm-lint-marker-error')
await expect(page.getByText('Unexpected token').first()).toBeVisible()
// Close the code pane
codePaneButton.click()
await page.waitForTimeout(500)
// Ensure that a badge appears on the button
await expect(codePaneButtonHolder).toContainText('notification')
// Ensure we have no errors in the gutter.
await expect(page.locator('.cm-lint-marker-error')).not.toBeVisible()
// Open the code pane
u.openKclCodePanel()
// Ensure that a badge appears on the button
await expect(codePaneButtonHolder).toContainText('notification')
// Ensure we have an error diagnostic.
await expect(page.locator('.cm-lint-marker-error')).toBeVisible()
// error text on hover
await page.hover('.cm-lint-marker-error')
await expect(page.getByText('Unexpected token').first()).toBeVisible()
})
test('When error is not in view you can click the badge to scroll to it', async ({
page,
}) => {
const u = await getUtils(page)
// Load the app with the working starter code
await page.addInitScript((code) => {
localStorage.setItem('persistCode', code)
}, TEST_CODE_LONG_WITH_ERROR_OUT_OF_VIEW)
await page.setViewportSize({ width: 1200, height: 500 })
await u.waitForAuthSkipAppStart()
await page.waitForTimeout(1000)
// Ensure badge is present
const codePaneButtonHolder = page.locator('#code-button-holder')
await expect(codePaneButtonHolder).toContainText('notification')
// Ensure we have no errors in the gutter, since error out of view.
await expect(page.locator('.cm-lint-marker-error')).not.toBeVisible()
// Click the badge.
const badge = page.locator('#code-badge')
await expect(badge).toBeVisible()
await badge.click()
// Ensure we have an error diagnostic.
await expect(page.locator('.cm-lint-marker-error')).toBeVisible()
})
}) })

View File

@ -65,7 +65,7 @@ const part001 = startSketchOn('-XZ')
angle: topAng, angle: topAng,
to: totalHeightHalf, to: totalHeightHalf,
}, %, $seg04) }, %, $seg04)
|> xLineTo(totalLen, %, $seg03') |> xLineTo(totalLen, %, $seg03)
|> yLine(-armThick, %, $seg01) |> yLine(-armThick, %, $seg01)
|> angledLineThatIntersects({ |> angledLineThatIntersects({
angle: HALF_TURN, angle: HALF_TURN,
@ -689,14 +689,14 @@ test('Sketch on face with none z-up', async ({ page, context }) => {
'persistCode', 'persistCode',
`const part001 = startSketchOn('-XZ') `const part001 = startSketchOn('-XZ')
|> startProfileAt([1.4, 2.47], %) |> startProfileAt([1.4, 2.47], %)
|> line([9.31, 10.55], %, 'seg01') |> line([9.31, 10.55], %, $seg01)
|> line([11.91, -10.42], %) |> line([11.91, -10.42], %)
|> close(%) |> close(%)
|> extrude(${KCL_DEFAULT_LENGTH}, %) |> extrude(${KCL_DEFAULT_LENGTH}, %)
const part002 = startSketchOn(part001, 'seg01') const part002 = startSketchOn(part001, seg01)
|> startProfileAt([8, 8], %) |> startProfileAt([8, 8], %)
|> line([4.68, 3.05], %) |> line([4.68, 3.05], %)
|> line([0, -7.79], %, 'seg02') |> line([0, -7.79], %)
|> close(%) |> close(%)
|> extrude(${KCL_DEFAULT_LENGTH}, %) |> extrude(${KCL_DEFAULT_LENGTH}, %)
` `
@ -711,7 +711,7 @@ const part002 = startSketchOn(part001, 'seg01')
// wait for execution done // wait for execution done
await expect( await expect(
page.locator('[data-message-type="execution-done"]') page.locator('[data-message-type="execution-done"]')
).toHaveCount(2) ).toHaveCount(1, { timeout: 10_000 })
await u.closeDebugPanel() await u.closeDebugPanel()
// Wait for the second extrusion to appear // Wait for the second extrusion to appear
@ -761,7 +761,7 @@ test('Zoom to fit on load - solid 2d', async ({ page, context }) => {
// wait for execution done // wait for execution done
await expect( await expect(
page.locator('[data-message-type="execution-done"]') page.locator('[data-message-type="execution-done"]')
).toHaveCount(2) ).toHaveCount(1)
await u.closeDebugPanel() await u.closeDebugPanel()
// Wait for the second extrusion to appear // Wait for the second extrusion to appear
@ -798,7 +798,7 @@ test('Zoom to fit on load - solid 3d', async ({ page, context }) => {
// wait for execution done // wait for execution done
await expect( await expect(
page.locator('[data-message-type="execution-done"]') page.locator('[data-message-type="execution-done"]')
).toHaveCount(2) ).toHaveCount(1)
await u.closeDebugPanel() await u.closeDebugPanel()
// Wait for the second extrusion to appear // Wait for the second extrusion to appear
@ -829,7 +829,7 @@ test.describe('Grid visibility', () => {
// wait for execution done // wait for execution done
await expect( await expect(
page.locator('[data-message-type="execution-done"]') page.locator('[data-message-type="execution-done"]')
).toHaveCount(2) ).toHaveCount(1)
await u.closeDebugPanel() await u.closeDebugPanel()
await u.closeKclCodePanel() await u.closeKclCodePanel()
// TODO: Find a way to truly know that the objects have finished // TODO: Find a way to truly know that the objects have finished
@ -877,7 +877,7 @@ test.describe('Grid visibility', () => {
// wait for execution done // wait for execution done
await expect( await expect(
page.locator('[data-message-type="execution-done"]') page.locator('[data-message-type="execution-done"]')
).toHaveCount(2) ).toHaveCount(1)
await u.closeDebugPanel() await u.closeDebugPanel()
await u.closeKclCodePanel() await u.closeKclCodePanel()
// TODO: Find a way to truly know that the objects have finished // TODO: Find a way to truly know that the objects have finished

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

View File

@ -86,3 +86,268 @@ export const TEST_CODE_GIZMO = `const part001 = startSketchOn('XZ')
|> close(%) |> close(%)
|> extrude(5 + 7, %) |> extrude(5 + 7, %)
` `
export const TEST_CODE_LONG_WITH_ERROR_OUT_OF_VIEW = `const width = 50.8
const height = 30
const thickness = 2
const keychainHoleSize = 3
const keychain = startSketchOn("XY")
|> startProfileAt([0, 0], %)
|> lineTo([width, 0], %)
|> lineTo([width, height], %)
|> lineTo([0, height], %)
|> close(%)
|> extrude(thickness, %)
// generated from /home/paultag/Downloads/zma-logomark.svg
fn svg = (surface, origin, depth) => {
let a0 = surface |> startProfileAt([origin[0] + 45.430427, origin[1] + -14.627736], %)
|> bezierCurve({
control1: [ 0, 0.764157 ],
control2: [ 0, 1.528314 ],
to: [ 0, 2.292469 ]
}, %)
|> bezierCurve({
control1: [ -3.03202, 0 ],
control2: [ -6.064039, 0 ],
to: [ -9.09606, 0 ]
}, %)
|> bezierCurve({
control1: [ 0, -1.077657 ],
control2: [ 0, -2.155312 ],
to: [ 0, -3.232969 ]
}, %)
|> bezierCurve({
control1: [ 2.741805, 0 ],
control2: [ 5.483613, 0 ],
to: [ 8.225417, 0 ]
}, %)
|> bezierCurve({
control1: [ -2.740682, -2.961815 ],
control2: [ -5.490342, -5.925794 ],
to: [ -8.225417, -8.886255 ]
}, %)
|> bezierCurve({
control1: [ 0, -0.723995 ],
control2: [ 0, -1.447988 ],
to: [ 0, -2.171981 ]
}, %)
|> bezierCurve({
control1: [ 0.712124, 0.05061 ],
control2: [ 1.511636, -0.09877 ],
to: [ 2.172096, 0.07005 ]
}, %)
|> bezierCurve({
control1: [ 0.68573, 0.740811 ],
control2: [ 1.371459, 1.481622 ],
to: [ 2.057187, 2.222436 ]
}, %)
|> bezierCurve({
control1: [ 0, -0.76416 ],
control2: [ 0, -1.52832 ],
to: [ 0, -2.29248 ]
}, %)
|> bezierCurve({
control1: [ 3.032013, 0 ],
control2: [ 6.064026, 0 ],
to: [ 9.096038, 0 ]
}, %)
|> bezierCurve({
control1: [ 0, 1.077657 ],
control2: [ 0, 2.155314 ],
to: [ 0, 3.232973 ]
}, %)
|> bezierCurve({
control1: [ -2.741312, 0 ],
control2: [ -5.482623, 0 ],
to: [ -8.223936, 0 ]
}, %)
|> bezierCurve({
control1: [ 2.741313, 2.961108 ],
control2: [ 5.482624, 5.922216 ],
to: [ 8.223936, 8.883325 ]
}, %)
|> bezierCurve({
control1: [ 0, 0.724968 ],
control2: [ 0, 1.449938 ],
to: [ 0, 2.174907 ]
}, %)
|> bezierCurve({
control1: [ -0.712656, -0.05145 ],
control2: [ -1.512554, 0.09643 ],
to: [ -2.173592, -0.07298 ]
}, %)
|> bezierCurve({
control1: [ -0.685222, -0.739834 ],
control2: [ -1.370445, -1.479669 ],
to: [ -2.055669, -2.219505 ]
}, %)
|> close(%)
|> extrude(depth, %)
let a1 = surface |> startProfileAt([origin[0] + 57.920488, origin[1] + -15.244943], %)
|> bezierCurve({
control1: [ -2.78904, 0.106635 ],
control2: [ -5.052548, -2.969529 ],
to: [ -4.055141, -5.598369 ]
}, %)
|> bezierCurve({
control1: [ 0.841523, -0.918736 ],
control2: [ 0.439412, -1.541892 ],
to: [ -0.368488, -2.214378 ]
}, %)
|> bezierCurve({
control1: [ -0.418245, -0.448461 ],
control2: [ -0.836489, -0.896922 ],
to: [ -1.254732, -1.345384 ]
}, %)
|> bezierCurve({
control1: [ -2.76806, 2.995359 ],
control2: [ -2.32667, 8.18409 ],
to: [ 0.897655, 10.678932 ]
}, %)
|> bezierCurve({
control1: [ 2.562822, 2.186098 ],
control2: [ 6.605111, 2.28043 ],
to: [ 9.271202, 0.226476 ]
}, %)
|> bezierCurve({
control1: [ -0.743744, -0.797465 ],
control2: [ -1.487487, -1.594932 ],
to: [ -2.231232, -2.392397 ]
}, %)
|> bezierCurve({
control1: [ -0.672938, 0.421422 ],
control2: [ -1.465362, 0.646946 ],
to: [ -2.259264, 0.64512 ]
}, %)
|> close(%)
|> extrude(depth, %)
let a2 = surface |> startProfileAt([origin[0] + 62.19406300000001, origin[1] + -19.500698999999997], %)
|> bezierCurve({
control1: [ 0.302938, 1.281141 ],
control2: [ -1.53575, 2.434288 ],
to: [ -0.10908, 3.279477 ]
}, %)
|> bezierCurve({
control1: [ 0.504637, 0.54145 ],
control2: [ 1.009273, 1.082899 ],
to: [ 1.513909, 1.624348 ]
}, %)
|> bezierCurve({
control1: [ 2.767778, -2.995425 ],
control2: [ 2.327135, -8.184384 ],
to: [ -0.897661, -10.679047 ]
}, %)
|> bezierCurve({
control1: [ -2.562947, -2.186022 ],
control2: [ -6.604089, -2.279606 ],
to: [ -9.271196, -0.227813 ]
}, %)
|> bezierCurve({
control1: [ 0.744231, 0.797952 ],
control2: [ 1.488461, 1.595904 ],
to: [ 2.232692, 2.393856 ]
}, %)
|> bezierCurve({
control1: [ 2.302377, -1.564629 ],
control2: [ 5.793126, -0.15358 ],
to: [ 6.396577, 2.547372 ]
}, %)
|> bezierCurve({
control1: [ 0.08981, 0.346302 ],
control2: [ 0.134865, 0.704078 ],
to: [ 0.13476, 1.061807 ]
}, %)
|> close(%)
|> extrude(depth, %)
let a3 = surface |> startProfileAt([origin[0] + 74.124866, origin[1] + -15.244943], %)
|> bezierCurve({
control1: [ -2.78904, 0.106635 ],
control2: [ -5.052549, -2.969529 ],
to: [ -4.055142, -5.598369 ]
}, %)
|> bezierCurve({
control1: [ 0.841527, -0.918738 ],
control2: [ 0.43941, -1.541892 ],
to: [ -0.368497, -2.214367 ]
}, %)
|> bezierCurve({
control1: [ -0.418254, -0.448466 ],
control2: [ -0.836507, -0.896931 ],
to: [ -1.254761, -1.345395 ]
}, %)
|> bezierCurve({
control1: [ -2.768019, 2.995371 ],
control2: [ -2.326624, 8.184088 ],
to: [ 0.897678, 10.678932 ]
}, %)
|> bezierCurve({
control1: [ 2.56289, 2.186191 ],
control2: [ 6.60516, 2.280307 ],
to: [ 9.271371, 0.226476 ]
}, %)
|> bezierCurve({
control1: [ -0.743808, -0.797465 ],
control2: [ -1.487616, -1.594932 ],
to: [ -2.231424, -2.392397 ]
}, %)
|> bezierCurve({
control1: [ -0.672916, 0.421433 ],
control2: [ -1.465344, 0.646926 ],
to: [ -2.259225, 0.64512 ]
}, %)
|> close(%)
|> extrude(depth, %)
let a4 = surface |> startProfileAt([origin[0] + 77.57333899999998, origin[1] + -16.989262999999998], %)
|> bezierCurve({
control1: [ 0.743298, 0.797463 ],
control2: [ 1.486592, 1.594926 ],
to: [ 2.229888, 2.392389 ]
}, %)
|> bezierCurve({
control1: [ 2.767827, -2.995393 ],
control2: [ 2.327103, -8.184396 ],
to: [ -0.897672, -10.679047 ]
}, %)
|> bezierCurve({
control1: [ -2.562939, -2.186037 ],
control2: [ -6.604077, -2.279589 ],
to: [ -9.271185, -0.227813 ]
}, %)
|> bezierCurve({
control1: [ 0.744243, 0.797952 ],
control2: [ 1.488486, 1.595904 ],
to: [ 2.232729, 2.393856 ]
}, %)
|> bezierCurve({
control1: [ 2.302394, -1.564623 ],
control2: [ 5.793201, -0.153598 ],
to: [ 6.396692, 2.547372 ]
}, %)
|> bezierCurve({
control1: [ 0.32074, 1.215468 ],
control2: [ 0.06159, 2.564765 ],
to: [ -0.690452, 3.573243 ]
}, %)
|> close(%)
|> extrude(depth, %)
"thing";kajsnd;akjsnd
return 0
}
svg(startSketchOn(keychain, 'end'), [-33, 32], -thickness)
startSketchOn(keychain, 'end')
|> circle([
width / 2,
height - (keychainHoleSize + 1.5)
], keychainHoleSize, %)
|> extrude(-thickness, %)`

View File

@ -1,5 +1,5 @@
import { expect, Page, Download } from '@playwright/test' import { expect, Page, Download } from '@playwright/test'
import { EngineCommand } from 'lang/std/artifactMap' import { EngineCommand } from 'lang/std/artifactGraph'
import os from 'os' import os from 'os'
import fsp from 'fs/promises' import fsp from 'fs/promises'
import pixelMatch from 'pixelmatch' import pixelMatch from 'pixelmatch'

12
flake.lock generated
View File

@ -2,11 +2,11 @@
"nodes": { "nodes": {
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1718470082, "lastModified": 1721933792,
"narHash": "sha256-u2F0MMYE+Efc+ocruTbtU/wWHuYHWcJafp5zJ++n/YE=", "narHash": "sha256-zYVwABlQnxpbaHMfX6Wt9jhyQstFYwN2XjleOJV3VVg=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "3027ba73dfef68eb555fc2fa97aed4e999e74f97", "rev": "2122a9b35b35719ad9a395fe783eabb092df01b1",
"type": "github" "type": "github"
}, },
"original": { "original": {
@ -43,11 +43,11 @@
"nixpkgs": "nixpkgs_2" "nixpkgs": "nixpkgs_2"
}, },
"locked": { "locked": {
"lastModified": 1718681902, "lastModified": 1721960387,
"narHash": "sha256-E/T7Ge6ayEQe7FVKMJqDBoHyLhRhjc6u9CmU8MyYfy0=", "narHash": "sha256-o21ax+745ETGXrcgc/yUuLw1SI77ymp3xEpJt+w/kks=",
"owner": "oxalica", "owner": "oxalica",
"repo": "rust-overlay", "repo": "rust-overlay",
"rev": "16c8ad83297c278eebe740dea5491c1708960dd1", "rev": "9cbf831c5b20a53354fc12758abd05966f9f1699",
"type": "github" "type": "github"
}, },
"original": { "original": {

View File

@ -57,6 +57,7 @@
pkg-config pkg-config
nodejs_22 nodejs_22
yarn
]) ++ pkgs.lib.optionals pkgs.stdenv.isDarwin (with pkgs; [ ]) ++ pkgs.lib.optionals pkgs.stdenv.isDarwin (with pkgs; [
libiconv libiconv
darwin.apple_sdk.frameworks.Security darwin.apple_sdk.frameworks.Security

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{ {
"name": "untitled-app", "name": "untitled-app",
"version": "0.24.7", "version": "0.24.8",
"private": true, "private": true,
"dependencies": { "dependencies": {
"@codemirror/autocomplete": "^6.17.0", "@codemirror/autocomplete": "^6.17.0",
@ -87,7 +87,8 @@
"bump-jsons": "echo \"$(jq --arg v \"$VERSION\" '.version=$v' package.json --indent 2)\" > package.json && echo \"$(jq --arg v \"$VERSION\" '.version=$v' src-tauri/tauri.conf.json --indent 2)\" > src-tauri/tauri.conf.json", "bump-jsons": "echo \"$(jq --arg v \"$VERSION\" '.version=$v' package.json --indent 2)\" > package.json && echo \"$(jq --arg v \"$VERSION\" '.version=$v' src-tauri/tauri.conf.json --indent 2)\" > src-tauri/tauri.conf.json",
"postinstall": "yarn xstate:typegen", "postinstall": "yarn xstate:typegen",
"xstate:typegen": "yarn xstate typegen \"src/**/*.ts?(x)\"", "xstate:typegen": "yarn xstate typegen \"src/**/*.ts?(x)\"",
"make:dev": "make dev" "make:dev": "make dev",
"generate:machine-api": "npx openapi-typescript ./openapi/machine-api.json -o src/lib/machine-api.d.ts"
}, },
"prettier": { "prettier": {
"trailingComma": "es5", "trailingComma": "es5",
@ -116,6 +117,7 @@
"@tauri-apps/cli": "==2.0.0-beta.13", "@tauri-apps/cli": "==2.0.0-beta.13",
"@testing-library/jest-dom": "^5.14.1", "@testing-library/jest-dom": "^5.14.1",
"@testing-library/react": "^15.0.2", "@testing-library/react": "^15.0.2",
"@types/d3-force": "^3.0.10",
"@types/mocha": "^10.0.6", "@types/mocha": "^10.0.6",
"@types/node": "^18.19.31", "@types/node": "^18.19.31",
"@types/pixelmatch": "^5.2.6", "@types/pixelmatch": "^5.2.6",
@ -138,6 +140,7 @@
"@wdio/spec-reporter": "^8.36.0", "@wdio/spec-reporter": "^8.36.0",
"@xstate/cli": "^0.5.17", "@xstate/cli": "^0.5.17",
"autoprefixer": "^10.4.19", "autoprefixer": "^10.4.19",
"d3-force": "^3.0.0",
"eslint": "^8.57.0", "eslint": "^8.57.0",
"eslint-config-react-app": "^7.0.1", "eslint-config-react-app": "^7.0.1",
"eslint-plugin-css-modules": "^2.12.0", "eslint-plugin-css-modules": "^2.12.0",

61
src-tauri/Cargo.lock generated
View File

@ -172,7 +172,9 @@ dependencies = [
"kcl-lib", "kcl-lib",
"kittycad", "kittycad",
"log", "log",
"mdns-sd",
"oauth2", "oauth2",
"reqwest 0.12.4",
"serde_json", "serde_json",
"tauri", "tauri",
"tauri-build", "tauri-build",
@ -286,7 +288,7 @@ dependencies = [
"futures-io", "futures-io",
"futures-lite", "futures-lite",
"parking", "parking",
"polling", "polling 3.7.0",
"rustix", "rustix",
"slab", "slab",
"tracing", "tracing",
@ -1570,6 +1572,17 @@ dependencies = [
"miniz_oxide", "miniz_oxide",
] ]
[[package]]
name = "flume"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55ac459de2512911e4b674ce33cf20befaba382d05b62b008afc1c8b57cbf181"
dependencies = [
"futures-core",
"futures-sink",
"spin",
]
[[package]] [[package]]
name = "fnv" name = "fnv"
version = "1.0.7" version = "1.0.7"
@ -2405,6 +2418,16 @@ dependencies = [
"unicode-normalization", "unicode-normalization",
] ]
[[package]]
name = "if-addrs"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cabb0019d51a643781ff15c9c8a3e5dedc365c47211270f4e8f82812fedd8f0a"
dependencies = [
"libc",
"windows-sys 0.48.0",
]
[[package]] [[package]]
name = "image" name = "image"
version = "0.25.2" version = "0.25.2"
@ -2896,6 +2919,19 @@ version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94"
[[package]]
name = "mdns-sd"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "807457e493076539ff8f202806f9dc2eaa9f13f69701da7ed38eec7a9afd1616"
dependencies = [
"flume",
"if-addrs",
"log",
"polling 2.8.0",
"socket2",
]
[[package]] [[package]]
name = "memchr" name = "memchr"
version = "2.7.2" version = "2.7.2"
@ -3635,6 +3671,22 @@ dependencies = [
"miniz_oxide", "miniz_oxide",
] ]
[[package]]
name = "polling"
version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b2d323e8ca7996b3e23126511a523f7e62924d93ecd5ae73b333815b0eb3dce"
dependencies = [
"autocfg",
"bitflags 1.3.2",
"cfg-if",
"concurrent-queue",
"libc",
"log",
"pin-project-lite",
"windows-sys 0.48.0",
]
[[package]] [[package]]
name = "polling" name = "polling"
version = "3.7.0" version = "3.7.0"
@ -4586,9 +4638,9 @@ dependencies = [
[[package]] [[package]]
name = "serde_json" name = "serde_json"
version = "1.0.121" version = "1.0.122"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ab380d7d9f22ef3f21ad3e6c1ebe8e4fc7a2000ccba2e4d71fc96f15b2cb609" checksum = "784b6203951c57ff748476b126ccb5e8e2959a5c19e5c617ab1956be3dbc68da"
dependencies = [ dependencies = [
"indexmap 2.2.6", "indexmap 2.2.6",
"itoa 1.0.11", "itoa 1.0.11",
@ -4871,6 +4923,9 @@ name = "spin"
version = "0.9.8" version = "0.9.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
dependencies = [
"lock_api",
]
[[package]] [[package]]
name = "stable_deref_trait" name = "stable_deref_trait"

View File

@ -18,7 +18,9 @@ anyhow = "1"
kcl-lib = { version = "0.2", path = "../src/wasm-lib/kcl" } kcl-lib = { version = "0.2", path = "../src/wasm-lib/kcl" }
kittycad = "0.3.7" kittycad = "0.3.7"
log = "0.4.21" log = "0.4.21"
mdns-sd = "0.11.1"
oauth2 = "4.4.2" oauth2 = "4.4.2"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
serde_json = "1.0" serde_json = "1.0"
tauri = { version = "2.0.0-beta.23", features = [ "devtools", "unstable"] } tauri = { version = "2.0.0-beta.23", features = [ "devtools", "unstable"] }
tauri-plugin-cli = { version = "2.0.0-beta.7" } tauri-plugin-cli = { version = "2.0.0-beta.7" }

View File

@ -370,6 +370,70 @@ fn show_in_folder(app: tauri::AppHandle, path: &str) -> Result<(), InvokeError>
Ok(()) Ok(())
} }
const SERVICE_NAME: &str = "_machine-api._tcp.local.";
async fn find_machine_api() -> Result<Option<String>> {
println!("Looking for machine API...");
// Timeout if no response is received after 5 seconds.
let timeout_duration = std::time::Duration::from_secs(5);
let mdns = mdns_sd::ServiceDaemon::new()?;
// Browse for a service type.
let receiver = mdns.browse(SERVICE_NAME)?;
let resp = tokio::time::timeout(
timeout_duration,
tokio::spawn(async move {
while let Ok(event) = receiver.recv() {
if let mdns_sd::ServiceEvent::ServiceResolved(info) = event {
if let Some(addr) = info.get_addresses().iter().next() {
return Some(format!("{}:{}", addr, info.get_port()));
}
}
}
None
}),
)
.await;
// Shut down.
mdns.shutdown()?;
let Ok(Ok(Some(addr))) = resp else {
return Ok(None);
};
Ok(Some(addr))
}
#[tauri::command]
async fn get_machine_api_ip() -> Result<Option<String>, InvokeError> {
let machine_api = find_machine_api().await.map_err(InvokeError::from_anyhow)?;
Ok(machine_api)
}
#[tauri::command]
async fn list_machines() -> Result<String, InvokeError> {
let machine_api = find_machine_api().await.map_err(InvokeError::from_anyhow)?;
let Some(machine_api) = machine_api else {
// Empty array.
return Ok("[]".to_string());
};
let client = reqwest::Client::new();
let response = client
.get(format!("http://{}/machines", machine_api))
.send()
.await
.map_err(|e| InvokeError::from_anyhow(e.into()))?;
let text = response.text().await.map_err(|e| InvokeError::from_anyhow(e.into()))?;
Ok(text)
}
#[allow(dead_code)] #[allow(dead_code)]
fn open_url_sync(app: &tauri::AppHandle, url: &url::Url) { fn open_url_sync(app: &tauri::AppHandle, url: &url::Url) {
log::debug!("Opening URL: {:?}", url); log::debug!("Opening URL: {:?}", url);
@ -417,6 +481,8 @@ fn main() -> Result<()> {
read_project_settings_file, read_project_settings_file,
write_project_settings_file, write_project_settings_file,
rename_project_directory, rename_project_directory,
get_machine_api_ip,
list_machines
]) ])
.plugin(tauri_plugin_cli::init()) .plugin(tauri_plugin_cli::init())
.plugin(tauri_plugin_deep_link::init()) .plugin(tauri_plugin_deep_link::init())

View File

@ -80,5 +80,5 @@
} }
}, },
"productName": "Zoo Modeling App", "productName": "Zoo Modeling App",
"version": "0.24.7" "version": "0.24.8"
} }

View File

@ -2,7 +2,7 @@ import { MouseEventHandler, useEffect, useMemo, useRef } from 'react'
import { uuidv4 } from 'lib/utils' import { uuidv4 } from 'lib/utils'
import { useHotKeyListener } from './hooks/useHotKeyListener' import { useHotKeyListener } from './hooks/useHotKeyListener'
import { Stream } from './components/Stream' import { Stream } from './components/Stream'
import { EngineCommand } from 'lang/std/artifactMap' import { EngineCommand } from 'lang/std/artifactGraph'
import { throttle } from './lib/utils' import { throttle } from './lib/utils'
import { AppHeader } from './components/AppHeader' import { AppHeader } from './components/AppHeader'
import { useHotkeys } from 'react-hotkeys-hook' import { useHotkeys } from 'react-hotkeys-hook'

View File

@ -40,10 +40,10 @@ export function Toolbar({
return false return false
} }
return isCursorInSketchCommandRange( return isCursorInSketchCommandRange(
engineCommandManager.artifactMap, engineCommandManager.artifactGraph,
context.selectionRanges context.selectionRanges
) )
}, [engineCommandManager.artifactMap, context.selectionRanges]) }, [engineCommandManager.artifactGraph, context.selectionRanges])
const toolbarButtonsRef = useRef<HTMLUListElement>(null) const toolbarButtonsRef = useRef<HTMLUListElement>(null)
const { overallState } = useNetworkContext() const { overallState } = useNetworkContext()

View File

@ -21,7 +21,7 @@ import {
EngineCommandManager, EngineCommandManager,
UnreliableSubscription, UnreliableSubscription,
} from 'lang/std/engineConnection' } from 'lang/std/engineConnection'
import { EngineCommand } from 'lang/std/artifactMap' import { EngineCommand } from 'lang/std/artifactGraph'
import { uuidv4 } from 'lib/utils' import { uuidv4 } from 'lib/utils'
import { deg2Rad } from 'lib/utils2d' import { deg2Rad } from 'lib/utils2d'
import { isReducedMotion, roundOff, throttle } from 'lib/utils' import { isReducedMotion, roundOff, throttle } from 'lib/utils'

View File

@ -575,10 +575,10 @@ const ConstraintSymbol = ({
: 'bg-primary/30 dark:bg-primary text-primary dark:text-chalkboard-10 dark:border-transparent group-hover:bg-primary/40 group-hover:border-primary/50 group-hover:brightness-125' : 'bg-primary/30 dark:bg-primary text-primary dark:text-chalkboard-10 dark:border-transparent group-hover:bg-primary/40 group-hover:border-primary/50 group-hover:brightness-125'
} h-[26px] w-[26px] rounded-sm relative m-0 p-0`} } h-[26px] w-[26px] rounded-sm relative m-0 p-0`}
onMouseEnter={() => { onMouseEnter={() => {
editorManager.setHighlightRange(range) editorManager.setHighlightRange([range])
}} }}
onMouseLeave={() => { onMouseLeave={() => {
editorManager.setHighlightRange([0, 0]) editorManager.setHighlightRange([[0, 0]])
}} }}
// disabled={isConstrained || !convertToVarEnabled} // disabled={isConstrained || !convertToVarEnabled}
// disabled={implicitDesc} TODO why does this change styles that are hard to override? // disabled={implicitDesc} TODO why does this change styles that are hard to override?

View File

@ -84,11 +84,7 @@ import {
createPipeSubstitution, createPipeSubstitution,
findUniqueName, findUniqueName,
} from 'lang/modifyAst' } from 'lang/modifyAst'
import { import { Selections, getEventForSegmentSelection } from 'lib/selections'
Selections,
getEventForSegmentSelection,
sendSelectEventToEngine,
} from 'lib/selections'
import { getTangentPointFromPreviousArc } from 'lib/utils2d' import { getTangentPointFromPreviousArc } from 'lib/utils2d'
import { createGridHelper, orthoScale, perspScale } from './helpers' import { createGridHelper, orthoScale, perspScale } from './helpers'
import { Models } from '@kittycad/lib' import { Models } from '@kittycad/lib'
@ -1524,7 +1520,7 @@ export class SceneEntities {
) )
if (trap(_node, { suppress: true })) return if (trap(_node, { suppress: true })) return
const node = _node.node const node = _node.node
editorManager.setHighlightRange([node.start, node.end]) editorManager.setHighlightRange([[node.start, node.end]])
const yellow = 0xffff00 const yellow = 0xffff00
colorSegment(selected, yellow) colorSegment(selected, yellow)
const extraSegmentGroup = parent.getObjectByName(EXTRA_SEGMENT_HANDLE) const extraSegmentGroup = parent.getObjectByName(EXTRA_SEGMENT_HANDLE)
@ -1560,10 +1556,10 @@ export class SceneEntities {
} }
return return
} }
editorManager.setHighlightRange([0, 0]) editorManager.setHighlightRange([[0, 0]])
}, },
onMouseLeave: ({ selected, ...rest }: OnMouseEnterLeaveArgs) => { onMouseLeave: ({ selected, ...rest }: OnMouseEnterLeaveArgs) => {
editorManager.setHighlightRange([0, 0]) editorManager.setHighlightRange([[0, 0]])
const parent = getParentGroup(selected, [ const parent = getParentGroup(selected, [
STRAIGHT_SEGMENT, STRAIGHT_SEGMENT,
TANGENTIAL_ARC_TO_SEGMENT, TANGENTIAL_ARC_TO_SEGMENT,

View File

@ -44,7 +44,7 @@ export function AstExplorer() {
<div <div
className="h-full relative" className="h-full relative"
onMouseLeave={(e) => { onMouseLeave={(e) => {
editorManager.setHighlightRange([0, 0]) editorManager.setHighlightRange([[0, 0]])
}} }}
> >
<pre className="text-xs"> <pre className="text-xs">
@ -113,12 +113,12 @@ function DisplayObj({
hasCursor ? 'bg-violet-100/80 dark:bg-violet-100/25' : '' hasCursor ? 'bg-violet-100/80 dark:bg-violet-100/25' : ''
}`} }`}
onMouseEnter={(e) => { onMouseEnter={(e) => {
editorManager.setHighlightRange([obj?.start || 0, obj.end]) editorManager.setHighlightRange([[obj?.start || 0, obj.end]])
e.stopPropagation() e.stopPropagation()
}} }}
onMouseMove={(e) => { onMouseMove={(e) => {
e.stopPropagation() e.stopPropagation()
editorManager.setHighlightRange([obj?.start || 0, obj.end]) editorManager.setHighlightRange([[obj?.start || 0, obj.end]])
}} }}
onClick={(e) => { onClick={(e) => {
send({ send({

View File

@ -124,7 +124,11 @@ function CommandBarHeader({ children }: React.PropsWithChildren<{}>) {
4 4
) )
) : typeof argValue === 'object' ? ( ) : typeof argValue === 'object' ? (
JSON.stringify(argValue) arg.valueSummary ? (
arg.valueSummary(argValue)
) : (
JSON.stringify(argValue)
)
) : ( ) : (
<em>{argValue}</em> <em>{argValue}</em>
) )

View File

@ -541,6 +541,16 @@ const CustomIconMap = {
/> />
</svg> </svg>
), ),
printer3d: (
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M16 5H4V7.5H7V7V6H8H9H10V7V7.5H16V5ZM17 7.5V8.5V15V16V17H16V16H15H14H6H5H4V17H3V16V15V8.5V7.5V5V4H4H16H17V5V7.5ZM4 8.5V15H5V13.5V13H5.5H14.5H15V13.5V15H16V8.5H10V9H9V10L8.5 10.5L8 10V9H7V8.5H4ZM14 14V15H6V14H14ZM8 7H9V8H8V7Z"
fill="currentColor"
/>
</svg>
),
polygon: ( polygon: (
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path <path

View File

@ -10,6 +10,7 @@ import { coreDump } from 'lang/wasm'
import toast from 'react-hot-toast' import toast from 'react-hot-toast'
import { CoreDumpManager } from 'lib/coredump' import { CoreDumpManager } from 'lib/coredump'
import openWindow from 'lib/openWindow' import openWindow from 'lib/openWindow'
import { NetworkMachineIndicator } from './NetworkMachineIndicator'
export function LowerRightControls({ export function LowerRightControls({
children, children,
@ -100,6 +101,7 @@ export function LowerRightControls({
Settings Settings
</Tooltip> </Tooltip>
</Link> </Link>
<NetworkMachineIndicator className={linkOverrideClassName} />
<NetworkHealthIndicator /> <NetworkHealthIndicator />
<HelpMenu /> <HelpMenu />
</menu> </menu>

View File

@ -28,6 +28,7 @@ import {
editorManager, editorManager,
sceneEntitiesManager, sceneEntitiesManager,
} from 'lib/singletons' } from 'lib/singletons'
import { machineManager } from 'lib/machineManager'
import { useHotkeys } from 'react-hotkeys-hook' import { useHotkeys } from 'react-hotkeys-hook'
import { applyConstraintHorzVertDistance } from './Toolbar/SetHorzVertDistance' import { applyConstraintHorzVertDistance } from './Toolbar/SetHorzVertDistance'
import { import {
@ -77,6 +78,7 @@ import { err, trap } from 'lib/trap'
import { useCommandsContext } from 'hooks/useCommandsContext' import { useCommandsContext } from 'hooks/useCommandsContext'
import { modelingMachineEvent } from 'editor/manager' import { modelingMachineEvent } from 'editor/manager'
import { hasValidFilletSelection } from 'lang/modifyAst/addFillet' import { hasValidFilletSelection } from 'lang/modifyAst/addFillet'
import { ExportIntent } from 'lang/std/engineConnection'
type MachineContext<T extends AnyStateMachine> = { type MachineContext<T extends AnyStateMachine> = {
state: StateFrom<T> state: StateFrom<T>
@ -351,8 +353,57 @@ export const ModelingMachineProvider = ({
return {} return {}
}), }),
Make: async (_, event) => {
if (event.type !== 'Make' || TEST) return
// Check if we already have an export intent.
if (engineCommandManager.exportIntent) {
toast.error('Already exporting')
return
}
// Set the export intent.
engineCommandManager.exportIntent = ExportIntent.Make
console.log('making', event.data)
// Set the current machine.
machineManager.currentMachine = event.data.machine
const format: Models['OutputFormat_type'] = {
type: 'stl',
coords: {
forward: {
axis: 'y',
direction: 'negative',
},
up: {
axis: 'z',
direction: 'positive',
},
},
storage: 'ascii',
units: defaultUnit.current,
selection: { type: 'default_scene' },
}
toast.promise(
exportFromEngine({
format: format,
}),
{
loading: 'Starting print...',
success: 'Started print successfully',
error: 'Error while starting print',
}
)
},
'Engine export': async (_, event) => { 'Engine export': async (_, event) => {
if (event.type !== 'Export' || TEST) return if (event.type !== 'Export' || TEST) return
if (engineCommandManager.exportIntent) {
toast.error('Already exporting')
return
}
// Set the export intent.
engineCommandManager.exportIntent = ExportIntent.Save
console.log('exporting', event.data) console.log('exporting', event.data)
const format = { const format = {
...event.data, ...event.data,
@ -446,7 +497,7 @@ export const ModelingMachineProvider = ({
if (!isSingleCursorInPipe(selectionRanges, kclManager.ast)) if (!isSingleCursorInPipe(selectionRanges, kclManager.ast))
return false return false
return !!isCursorInSketchCommandRange( return !!isCursorInSketchCommandRange(
engineCommandManager.artifactMap, engineCommandManager.artifactGraph,
selectionRanges selectionRanges
) )
}, },

View File

@ -193,6 +193,10 @@ export const KclEditorPane = () => {
if (_editorView === null) return if (_editorView === null) return
editorManager.setEditorView(_editorView) editorManager.setEditorView(_editorView)
// On first load of this component, ensure we show the current errors
// in the editor.
kclManager.setDiagnosticsForCurrentErrors()
}} }}
/> />
</div> </div>

View File

@ -8,12 +8,13 @@ import {
import { KclEditorMenu } from 'components/ModelingSidebar/ModelingPanes/KclEditorMenu' import { KclEditorMenu } from 'components/ModelingSidebar/ModelingPanes/KclEditorMenu'
import { CustomIconName } from 'components/CustomIcon' import { CustomIconName } from 'components/CustomIcon'
import { KclEditorPane } from 'components/ModelingSidebar/ModelingPanes/KclEditorPane' import { KclEditorPane } from 'components/ModelingSidebar/ModelingPanes/KclEditorPane'
import { ReactNode } from 'react' import { MouseEventHandler, ReactNode } from 'react'
import { MemoryPane, MemoryPaneMenu } from './MemoryPane' import { MemoryPane, MemoryPaneMenu } from './MemoryPane'
import { LogsPane } from './LoggingPanes' import { LogsPane } from './LoggingPanes'
import { DebugPane } from './DebugPane' import { DebugPane } from './DebugPane'
import { FileTreeInner, FileTreeMenu } from 'components/FileTree' import { FileTreeInner, FileTreeMenu } from 'components/FileTree'
import { useKclContext } from 'lang/KclProvider' import { useKclContext } from 'lang/KclProvider'
import { editorManager } from 'lib/singletons'
export type SidebarType = export type SidebarType =
| 'code' | 'code'
@ -24,6 +25,11 @@ export type SidebarType =
| 'lspMessages' | 'lspMessages'
| 'variables' | 'variables'
export interface BadgeInfo {
value: (props: PaneCallbackProps) => boolean | number
onClick?: MouseEventHandler<any>
}
/** /**
* This interface can be extended as more context is needed for the panes * This interface can be extended as more context is needed for the panes
* to determine if they should show their badges or not. * to determine if they should show their badges or not.
@ -40,7 +46,7 @@ export type SidebarPane = {
Content: ReactNode | React.FC Content: ReactNode | React.FC
Menu?: ReactNode | React.FC Menu?: ReactNode | React.FC
hideOnPlatform?: 'desktop' | 'web' hideOnPlatform?: 'desktop' | 'web'
showBadge?: (props: PaneCallbackProps) => boolean | number showBadge?: BadgeInfo
} }
export const sidebarPanes: SidebarPane[] = [ export const sidebarPanes: SidebarPane[] = [
@ -51,7 +57,15 @@ export const sidebarPanes: SidebarPane[] = [
Content: KclEditorPane, Content: KclEditorPane,
keybinding: 'Shift + C', keybinding: 'Shift + C',
Menu: KclEditorMenu, Menu: KclEditorMenu,
showBadge: ({ kclContext }) => kclContext.errors.length, showBadge: {
value: ({ kclContext }) => {
return kclContext.errors.length
},
onClick: (e) => {
e.preventDefault()
editorManager.scrollToFirstDiagnosticIfExists()
},
},
}, },
{ {
id: 'files', id: 'files',

View File

@ -1,6 +1,6 @@
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext' import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
import { Resizable } from 're-resizable' import { Resizable } from 're-resizable'
import { useCallback, useMemo } from 'react' import { MouseEventHandler, useCallback, useMemo } from 'react'
import { useHotkeys } from 'react-hotkeys-hook' import { useHotkeys } from 'react-hotkeys-hook'
import { SidebarType, sidebarPanes } from './ModelingPanes' import { SidebarType, sidebarPanes } from './ModelingPanes'
import Tooltip from 'components/Tooltip' import Tooltip from 'components/Tooltip'
@ -13,11 +13,17 @@ import { CustomIconName } from 'components/CustomIcon'
import { useCommandsContext } from 'hooks/useCommandsContext' import { useCommandsContext } from 'hooks/useCommandsContext'
import { IconDefinition } from '@fortawesome/free-solid-svg-icons' import { IconDefinition } from '@fortawesome/free-solid-svg-icons'
import { useKclContext } from 'lang/KclProvider' import { useKclContext } from 'lang/KclProvider'
import { machineManager } from 'lib/machineManager'
interface ModelingSidebarProps { interface ModelingSidebarProps {
paneOpacity: '' | 'opacity-20' | 'opacity-40' paneOpacity: '' | 'opacity-20' | 'opacity-40'
} }
interface BadgeInfoComputed {
value: number | boolean
onClick?: MouseEventHandler<any>
}
export function ModelingSidebar({ paneOpacity }: ModelingSidebarProps) { export function ModelingSidebar({ paneOpacity }: ModelingSidebarProps) {
const { commandBarSend } = useCommandsContext() const { commandBarSend } = useCommandsContext()
const kclContext = useKclContext() const kclContext = useKclContext()
@ -45,7 +51,30 @@ export function ModelingSidebar({ paneOpacity }: ModelingSidebarProps) {
data: { name: 'Export', groupId: 'modeling' }, data: { name: 'Export', groupId: 'modeling' },
}), }),
}, },
{
id: 'make',
title: 'Make part',
icon: 'printer3d',
iconClassName: '!p-0',
keybinding: 'Ctrl + Shift + M',
action: async () => {
commandBarSend({
type: 'Find and select command',
data: { name: 'Make', groupId: 'modeling' },
})
},
hide: () => machineManager.machineCount() === 0,
hideOnPlatform: 'web',
},
] ]
const filteredActions: SidebarAction[] = sidebarActions.filter(
(action) =>
(!action.hide || (action.hide instanceof Function && !action.hide())) &&
(!action.hideOnPlatform ||
(isTauri()
? action.hideOnPlatform === 'web'
: action.hideOnPlatform === 'desktop'))
)
// // Filter out the debug panel if it's not supposed to be shown // // Filter out the debug panel if it's not supposed to be shown
// // TODO: abstract out for allowing user to configure which panes to show // // TODO: abstract out for allowing user to configure which panes to show
@ -64,13 +93,16 @@ export function ModelingSidebar({ paneOpacity }: ModelingSidebarProps) {
[sidebarPanes, showDebugPanel.current] [sidebarPanes, showDebugPanel.current]
) )
const paneBadgeMap: Record<SidebarType, number | boolean> = useMemo(() => { const paneBadgeMap: Record<SidebarType, BadgeInfoComputed> = useMemo(() => {
return filteredPanes.reduce((acc, pane) => { return filteredPanes.reduce((acc, pane) => {
if (pane.showBadge) { if (pane.showBadge) {
acc[pane.id] = pane.showBadge({ kclContext }) acc[pane.id] = {
value: pane.showBadge.value({ kclContext }),
onClick: pane.showBadge.onClick,
}
} }
return acc return acc
}, {} as Record<SidebarType, number | boolean>) }, {} as Record<SidebarType, BadgeInfoComputed>)
}, [kclContext.errors]) }, [kclContext.errors])
const togglePane = useCallback( const togglePane = useCallback(
@ -135,23 +167,30 @@ export function ModelingSidebar({ paneOpacity }: ModelingSidebarProps) {
/> />
))} ))}
</ul> </ul>
<hr className="w-full border-chalkboard-20 dark:border-chalkboard-80" /> {filteredActions.length > 0 && (
<ul id="sidebar-actions" className="w-fit p-2 flex flex-col gap-2"> <>
{sidebarActions.map((action) => ( <hr className="w-full border-chalkboard-20 dark:border-chalkboard-80" />
<ModelingPaneButton <ul
key={action.id} id="sidebar-actions"
paneConfig={{ className="w-fit p-2 flex flex-col gap-2"
id: action.id, >
title: action.title, {filteredActions.map((action) => (
icon: action.icon, <ModelingPaneButton
keybinding: action.keybinding, key={action.id}
iconClassName: action.iconClassName, paneConfig={{
iconSize: 'md', id: action.id,
}} title: action.title,
onClick={action.action} icon: action.icon,
/> keybinding: action.keybinding,
))} iconClassName: action.iconClassName,
</ul> iconSize: 'md',
}}
onClick={action.action}
/>
))}
</ul>
</>
)}
</ul> </ul>
<ul <ul
id="pane-section" id="pane-section"
@ -198,7 +237,7 @@ interface ModelingPaneButtonProps
} }
onClick: () => void onClick: () => void
paneIsOpen?: boolean paneIsOpen?: boolean
showBadge?: boolean | number showBadge?: BadgeInfoComputed
} }
function ModelingPaneButton({ function ModelingPaneButton({
@ -213,59 +252,68 @@ function ModelingPaneButton({
}) })
return ( return (
<button <div id={paneConfig.id + '-button-holder'}>
className="pointer-events-auto flex items-center justify-center border-transparent dark:border-transparent p-0 m-0 rounded-sm !outline-0 focus-visible:border-primary" <button
onClick={onClick} className="pointer-events-auto flex items-center justify-center border-transparent dark:border-transparent p-0 m-0 rounded-sm !outline-0 focus-visible:border-primary"
name={paneConfig.title} onClick={onClick}
data-testid={paneConfig.id + '-pane-button'} name={paneConfig.title}
{...props} data-testid={paneConfig.id + '-pane-button'}
> {...props}
<ActionIcon >
icon={paneConfig.icon} <ActionIcon
className={'p-1 ' + paneConfig.iconClassName || ''} icon={paneConfig.icon}
size={paneConfig.iconSize || 'sm'} className={'p-1 ' + paneConfig.iconClassName || ''}
iconClassName={ size={paneConfig.iconSize || 'sm'}
paneIsOpen iconClassName={
? ' !text-chalkboard-10' paneIsOpen
: '!text-chalkboard-80 dark:!text-chalkboard-30' ? ' !text-chalkboard-10'
} : '!text-chalkboard-80 dark:!text-chalkboard-30'
bgClassName={
'rounded-sm ' + (paneIsOpen ? '!bg-primary' : '!bg-transparent')
}
/>
<span className="sr-only">
{paneConfig.title}
{paneIsOpen !== undefined ? ` pane` : ''}
</span>
{!!showBadge && (
<p
className={
'absolute m-0 p-0 -top-1 -right-1 w-3 h-3 flex items-center justify-center text-[10px] font-semibold text-white bg-primary hue-rotate-90 rounded-full border border-chalkboard-10 dark:border-chalkboard-80'
} }
bgClassName={
'rounded-sm ' + (paneIsOpen ? '!bg-primary' : '!bg-transparent')
}
/>
<span className="sr-only">
{paneConfig.title}
{paneIsOpen !== undefined ? ` pane` : ''}
</span>
<Tooltip
position="right"
contentClassName="max-w-none flex items-center gap-4"
hoverOnly
>
<span className="flex-1">
{paneConfig.title}
{paneIsOpen !== undefined ? ` pane` : ''}
</span>
<kbd className="hotkey text-xs capitalize">
{paneConfig.keybinding}
</kbd>
</Tooltip>
</button>
{!!showBadge?.value && (
<p
id={`${paneConfig.id}-badge`}
className={
'absolute m-0 p-0 top-1 right-0 w-3 h-3 flex items-center justify-center text-[10px] font-semibold text-white bg-primary hue-rotate-90 rounded-full border border-chalkboard-10 dark:border-chalkboard-80 z-50 hover:cursor-pointer'
}
onClick={showBadge.onClick}
title={`Click to view ${showBadge.value} notification${
Number(showBadge.value) > 1 ? 's' : ''
}`}
> >
<span className="sr-only">&nbsp;has&nbsp;</span> <span className="sr-only">&nbsp;has&nbsp;</span>
{typeof showBadge === 'number' ? ( {typeof showBadge.value === 'number' ? (
<span>{showBadge}</span> <span>{showBadge.value}</span>
) : ( ) : (
<span className="sr-only">a</span> <span className="sr-only">a</span>
)} )}
<span className="sr-only"> <span className="sr-only">
&nbsp;notification{Number(showBadge) > 1 ? 's' : ''} &nbsp;notification{Number(showBadge.value) > 1 ? 's' : ''}
</span> </span>
</p> </p>
)} )}
<Tooltip </div>
position="right"
contentClassName="max-w-none flex items-center gap-4"
hoverOnly
>
<span className="flex-1">
{paneConfig.title}
{paneIsOpen !== undefined ? ` pane` : ''}
</span>
<kbd className="hotkey text-xs capitalize">{paneConfig.keybinding}</kbd>
</Tooltip>
</button>
) )
} }
@ -277,4 +325,5 @@ export type SidebarAction = {
keybinding: string keybinding: string
action: () => void action: () => void
hideOnPlatform?: 'desktop' | 'web' hideOnPlatform?: 'desktop' | 'web'
hide?: boolean | (() => boolean)
} }

View File

@ -103,8 +103,8 @@ export const NetworkHealthIndicator = () => {
'rounded-sm ' + overallConnectionStateColor[overallState].bg 'rounded-sm ' + overallConnectionStateColor[overallState].bg
} }
/> />
<Tooltip position="top-right"> <Tooltip position="top-right" wrapperClassName="ui-open:hidden">
Network Health ({NETWORK_HEALTH_TEXT[overallState]}) Network health ({NETWORK_HEALTH_TEXT[overallState]})
</Tooltip> </Tooltip>
</Popover.Button> </Popover.Button>
<Popover.Panel <Popover.Panel

View File

@ -0,0 +1,62 @@
import { Popover } from '@headlessui/react'
import Tooltip from './Tooltip'
import { machineManager } from 'lib/machineManager'
import { isTauri } from 'lib/isTauri'
import { CustomIcon } from './CustomIcon'
export const NetworkMachineIndicator = ({
className,
}: {
className?: string
}) => {
const machineCount = Object.keys(machineManager.machines).length
return isTauri() ? (
<Popover className="relative">
<Popover.Button
className={
'flex items-center p-0 border-none bg-transparent dark:bg-transparent relative ' +
(className || '')
}
data-testid="network-machine-toggle"
>
<CustomIcon name="printer3d" className="w-5 h-5" />
{machineCount > 0 && (
<p aria-hidden className="flex items-center justify-center text-xs">
{machineCount}
</p>
)}
<Tooltip position="top-right" wrapperClassName="ui-open:hidden">
Network machines ({machineCount})
</Tooltip>
</Popover.Button>
<Popover.Panel
className="absolute right-0 left-auto bottom-full mb-1 w-64 flex flex-col gap-1 align-stretch bg-chalkboard-10 dark:bg-chalkboard-90 rounded shadow-lg border border-solid border-chalkboard-20/50 dark:border-chalkboard-80/50 text-sm"
data-testid="network-popover"
>
<div className="flex items-center justify-between p-2 rounded-t-sm bg-chalkboard-20 dark:bg-chalkboard-80">
<h2 className="text-sm font-sans font-normal">Network machines</h2>
<p
data-testid="network"
className="font-bold text-xs uppercase px-2 py-1 rounded-sm"
>
{machineCount}
</p>
</div>
{machineCount > 0 && (
<ul className="divide-y divide-chalkboard-20 dark:divide-chalkboard-80">
{Object.entries(machineManager.machines).map(
([hostname, machine]) => (
<li key={hostname} className={'px-2 py-4 gap-1 last:mb-0 '}>
<p className="">{machine.model || machine.manufacturer}</p>
<p className="text-chalkboard-60 dark:text-chalkboard-50 text-xs">
Hostname {hostname}
</p>
</li>
)
)}
</ul>
)}
</Popover.Panel>
</Popover>
) : null
}

View File

@ -12,6 +12,7 @@ import { useCommandsContext } from 'hooks/useCommandsContext'
import { CustomIcon } from './CustomIcon' import { CustomIcon } from './CustomIcon'
import { useLspContext } from './LspProvider' import { useLspContext } from './LspProvider'
import { engineCommandManager } from 'lib/singletons' import { engineCommandManager } from 'lib/singletons'
import { machineManager } from 'lib/machineManager'
import usePlatform from 'hooks/usePlatform' import usePlatform from 'hooks/usePlatform'
import { useAbsoluteFilePath } from 'hooks/useAbsoluteFilePath' import { useAbsoluteFilePath } from 'hooks/useAbsoluteFilePath'
import Tooltip from './Tooltip' import Tooltip from './Tooltip'
@ -90,12 +91,14 @@ function ProjectMenuPopover({
const { commandBarState, commandBarSend } = useCommandsContext() const { commandBarState, commandBarSend } = useCommandsContext()
const { onProjectClose } = useLspContext() const { onProjectClose } = useLspContext()
const exportCommandInfo = { name: 'Export', groupId: 'modeling' } const exportCommandInfo = { name: 'Export', groupId: 'modeling' }
const makeCommandInfo = { name: 'Make', groupId: 'modeling' }
const findCommand = (obj: { name: string; groupId: string }) => const findCommand = (obj: { name: string; groupId: string }) =>
Boolean( Boolean(
commandBarState.context.commands.find( commandBarState.context.commands.find(
(c) => c.name === obj.name && c.groupId === obj.groupId (c) => c.name === obj.name && c.groupId === obj.groupId
) )
) )
const machineCount = machineManager.machineCount()
// We filter this memoized list so that no orphan "break" elements are rendered. // We filter this memoized list so that no orphan "break" elements are rendered.
const projectMenuItems = useMemo<(ActionButtonProps | 'break')[]>( const projectMenuItems = useMemo<(ActionButtonProps | 'break')[]>(
@ -144,6 +147,32 @@ function ProjectMenuPopover({
}), }),
}, },
'break', 'break',
{
id: 'make',
Element: 'button',
className: !isTauri() ? 'hidden' : '',
children: (
<>
<span>Make current part</span>
{!findCommand(makeCommandInfo) && (
<Tooltip
position="right"
wrapperClassName="!max-w-none min-w-fit"
>
Awaiting engine connection
</Tooltip>
)}
</>
),
disabled: !findCommand(makeCommandInfo) || machineCount === 0,
onClick: () => {
commandBarSend({
type: 'Find and select command',
data: makeCommandInfo,
})
},
},
'break',
{ {
id: 'go-home', id: 'go-home',
Element: 'button', Element: 'button',

View File

@ -3,7 +3,7 @@ import { EditorView, Decoration } from '@codemirror/view'
export { EditorView } export { EditorView }
export const addLineHighlight = StateEffect.define<[number, number]>() export const addLineHighlight = StateEffect.define<Array<[number, number]>>()
const addLineHighlightAnnotation = Annotation.define<null>() const addLineHighlightAnnotation = Annotation.define<null>()
export const addLineHighlightEvent = addLineHighlightAnnotation.of(null) export const addLineHighlightEvent = addLineHighlightAnnotation.of(null)
@ -24,10 +24,18 @@ export const lineHighlightField = StateField.define({
for (let e of tr.effects) { for (let e of tr.effects) {
if (e.is(addLineHighlight)) { if (e.is(addLineHighlight)) {
lines = Decoration.none lines = Decoration.none
const [from, to] = e.value || [0, 0] for (let index = 0; index < e.value.length; index++) {
if (from && to && !(from === to && from === 0)) { const highlightRange = e.value[index]
lines = lines.update({ add: [matchDeco.range(from, to)] }) const [from, to] = highlightRange || [0, 0]
deco.push(matchDeco.range(from, to)) if (from && to && !(from === to && from === 0)) {
if (index === 0) {
lines = lines.update({ add: [matchDeco.range(from, to)] })
deco.push(matchDeco.range(from, to))
} else {
lines = lines.update({ add: [matchDeco2.range(from, to)] })
deco.push(matchDeco2.range(from, to))
}
}
} }
} }
} }
@ -37,6 +45,10 @@ export const lineHighlightField = StateField.define({
}) })
const matchDeco = Decoration.mark({ const matchDeco = Decoration.mark({
class: 'bg-yellow-200', class: 'bg-yellow-300/70',
attributes: { 'data-testid': 'hover-highlight' },
})
const matchDeco2 = Decoration.mark({
class: 'bg-yellow-200/40',
attributes: { 'data-testid': 'hover-highlight' }, attributes: { 'data-testid': 'hover-highlight' },
}) })

View File

@ -6,7 +6,11 @@ import { Selections, processCodeMirrorRanges, Selection } from 'lib/selections'
import { undo, redo } from '@codemirror/commands' import { undo, redo } from '@codemirror/commands'
import { CommandBarMachineEvent } from 'machines/commandBarMachine' import { CommandBarMachineEvent } from 'machines/commandBarMachine'
import { addLineHighlight, addLineHighlightEvent } from './highlightextension' import { addLineHighlight, addLineHighlightEvent } from './highlightextension'
import { Diagnostic, setDiagnosticsEffect } from '@codemirror/lint' import {
Diagnostic,
forEachDiagnostic,
setDiagnosticsEffect,
} from '@codemirror/lint'
const updateOutsideEditorAnnotation = Annotation.define<boolean>() const updateOutsideEditorAnnotation = Annotation.define<boolean>()
export const updateOutsideEditorEvent = updateOutsideEditorAnnotation.of(true) export const updateOutsideEditorEvent = updateOutsideEditorAnnotation.of(true)
@ -42,7 +46,7 @@ export default class EditorManager {
private _convertToVariableEnabled: boolean = false private _convertToVariableEnabled: boolean = false
private _convertToVariableCallback: () => void = () => {} private _convertToVariableCallback: () => void = () => {}
private _highlightRange: [number, number] = [0, 0] private _highlightRange: Array<[number, number]> = [[0, 0]]
setCopilotEnabled(enabled: boolean) { setCopilotEnabled(enabled: boolean) {
this._copilotEnabled = enabled this._copilotEnabled = enabled
@ -88,19 +92,21 @@ export default class EditorManager {
return this._commandBarSend(eventInfo) return this._commandBarSend(eventInfo)
} }
get highlightRange(): [number, number] { get highlightRange(): Array<[number, number]> {
return this._highlightRange return this._highlightRange
} }
setHighlightRange(selection: Selection['range']): void { setHighlightRange(selections: Array<Selection['range']>): void {
this._highlightRange = selection this._highlightRange = selections
const safeEnd = Math.min(
selection[1], const selectionsWithSafeEnds = selections.map((s): [number, number] => {
this._editorView?.state.doc.length || selection[1] const safeEnd = Math.min(s[1], this._editorView?.state.doc.length || s[1])
) return [s[0], safeEnd]
})
if (this._editorView) { if (this._editorView) {
this._editorView.dispatch({ this._editorView.dispatch({
effects: addLineHighlight.of([selection[0], safeEnd]), effects: addLineHighlight.of(selectionsWithSafeEnds),
annotations: [ annotations: [
updateOutsideEditorEvent, updateOutsideEditorEvent,
addLineHighlightEvent, addLineHighlightEvent,
@ -135,6 +141,34 @@ export default class EditorManager {
}) })
} }
scrollToFirstDiagnosticIfExists() {
if (!this._editorView) return
let firstDiagnosticPos: [number, number] | null = null
forEachDiagnostic(
this._editorView.state,
(d: Diagnostic, from: number, to: number) => {
if (!firstDiagnosticPos) {
firstDiagnosticPos = [from, to]
}
}
)
if (!firstDiagnosticPos) return
this._editorView.focus()
this._editorView.dispatch({
selection: EditorSelection.create([
EditorSelection.cursor(firstDiagnosticPos[0]),
]),
effects: [EditorView.scrollIntoView(firstDiagnosticPos[0])],
annotations: [
updateOutsideEditorEvent,
Transaction.addToHistory.of(false),
],
})
}
undo() { undo() {
if (this._editorView) { if (this._editorView) {
undo(this._editorView) undo(this._editorView)

View File

@ -12,3 +12,4 @@ export const VITE_KC_DEV_TOKEN = import.meta.env.VITE_KC_DEV_TOKEN as
| undefined | undefined
export const TEST = import.meta.env.TEST export const TEST = import.meta.env.TEST
export const DEV = import.meta.env.DEV export const DEV = import.meta.env.DEV
export const CI = import.meta.env.CI

View File

@ -7,6 +7,13 @@ import {
} from 'lib/singletons' } from 'lib/singletons'
import { useModelingContext } from './useModelingContext' import { useModelingContext } from './useModelingContext'
import { getEventForSelectWithPoint } from 'lib/selections' import { getEventForSelectWithPoint } from 'lib/selections'
import {
getCapCodeRef,
getExtrusionFromSuspectedExtrudeSurface,
getSolid2dCodeRef,
getWallCodeRef,
} from 'lang/std/artifactGraph'
import { err } from 'lib/trap'
import { DefaultPlaneStr, getFaceDetails } from 'clientSideScene/sceneEntities' import { DefaultPlaneStr, getFaceDetails } from 'clientSideScene/sceneEntities'
import { getNodePathFromSourceRange } from 'lang/queryAst' import { getNodePathFromSourceRange } from 'lang/queryAst'
@ -21,24 +28,58 @@ export function useEngineConnectionSubscriptions() {
event: 'highlight_set_entity', event: 'highlight_set_entity',
callback: ({ data }) => { callback: ({ data }) => {
if (data?.entity_id) { if (data?.entity_id) {
const sourceRange = engineCommandManager.artifactMap?.[data.entity_id] const artifact = engineCommandManager.artifactGraph.get(
?.range || [0, 0] data.entity_id
editorManager.setHighlightRange(sourceRange) )
if (artifact?.type === 'solid2D') {
const codeRef = getSolid2dCodeRef(
artifact,
engineCommandManager.artifactGraph
)
if (err(codeRef)) return
editorManager.setHighlightRange([codeRef.range])
} else if (artifact?.type === 'cap') {
const codeRef = getCapCodeRef(
artifact,
engineCommandManager.artifactGraph
)
if (err(codeRef)) return
editorManager.setHighlightRange([codeRef.range])
} else if (artifact?.type === 'wall') {
const extrusion = getExtrusionFromSuspectedExtrudeSurface(
data.entity_id,
engineCommandManager.artifactGraph
)
const codeRef = getWallCodeRef(
artifact,
engineCommandManager.artifactGraph
)
if (err(codeRef)) return
editorManager.setHighlightRange(
err(extrusion)
? [codeRef.range]
: [codeRef.range, extrusion.codeRef.range]
)
} else if (artifact?.type === 'segment') {
editorManager.setHighlightRange([
artifact?.codeRef?.range || [0, 0],
])
} else {
editorManager.setHighlightRange([[0, 0]])
}
} else if ( } else if (
!editorManager.highlightRange || !editorManager.highlightRange ||
(editorManager.highlightRange[0] !== 0 && (editorManager.highlightRange[0][0] !== 0 &&
editorManager.highlightRange[1] !== 0) editorManager.highlightRange[0][1] !== 0)
) { ) {
editorManager.setHighlightRange([0, 0]) editorManager.setHighlightRange([[0, 0]])
} }
}, },
}) })
const unSubClick = engineCommandManager.subscribeTo({ const unSubClick = engineCommandManager.subscribeTo({
event: 'select_with_point', event: 'select_with_point',
callback: async (engineEvent) => { callback: async (engineEvent) => {
const event = await getEventForSelectWithPoint(engineEvent, { const event = await getEventForSelectWithPoint(engineEvent)
sketchEnginePathId: context.sketchEnginePathId,
})
event && send(event) event && send(event)
}, },
}) })
@ -53,16 +94,17 @@ export function useEngineConnectionSubscriptions() {
event: 'select_with_point', event: 'select_with_point',
callback: state.matches('Sketch no face') callback: state.matches('Sketch no face')
? async ({ data }) => { ? async ({ data }) => {
let planeId = data.entity_id let planeOrFaceId = data.entity_id
if (!planeId) return if (!planeOrFaceId) return
if ( if (
engineCommandManager.defaultPlanes?.xy === planeId || engineCommandManager.defaultPlanes?.xy === planeOrFaceId ||
engineCommandManager.defaultPlanes?.xz === planeId || engineCommandManager.defaultPlanes?.xz === planeOrFaceId ||
engineCommandManager.defaultPlanes?.yz === planeId || engineCommandManager.defaultPlanes?.yz === planeOrFaceId ||
engineCommandManager.defaultPlanes?.negXy === planeId || engineCommandManager.defaultPlanes?.negXy === planeOrFaceId ||
engineCommandManager.defaultPlanes?.negXz === planeId || engineCommandManager.defaultPlanes?.negXz === planeOrFaceId ||
engineCommandManager.defaultPlanes?.negYz === planeId engineCommandManager.defaultPlanes?.negYz === planeOrFaceId
) { ) {
let planeId = planeOrFaceId
const defaultPlaneStrMap: Record<string, DefaultPlaneStr> = { const defaultPlaneStrMap: Record<string, DefaultPlaneStr> = {
[engineCommandManager.defaultPlanes.xy]: 'XY', [engineCommandManager.defaultPlanes.xy]: 'XY',
[engineCommandManager.defaultPlanes.xz]: 'XZ', [engineCommandManager.defaultPlanes.xz]: 'XZ',
@ -117,44 +159,34 @@ export function useEngineConnectionSubscriptions() {
}) })
return return
} }
const artifact = engineCommandManager.artifactMap[planeId] const faceId = planeOrFaceId
console.log('artifact', artifact) const artifact = engineCommandManager.artifactGraph.get(faceId)
// If we clicked on an extrude wall, we climb up the parent Id const extrusion = getExtrusionFromSuspectedExtrudeSurface(
// to get the sketch profile's face ID. If we clicked on an endcap, faceId,
// we already have it. engineCommandManager.artifactGraph
const pathId =
artifact?.type === 'extrudeWall' ||
artifact?.type === 'extrudeCap'
? artifact.pathId
: ''
const path = engineCommandManager.artifactMap?.[pathId || '']
const extrusionId =
path?.type === 'startPath' ? path.extrusionIds[0] : ''
// TODO: We get the first extrusion command ID,
// which is fine while backend systems only support one extrusion.
// but we need to more robustly handle resolving to the correct extrusion
// if there are multiple.
const extrusions = engineCommandManager.artifactMap?.[extrusionId]
if (
artifact?.type !== 'extrudeCap' &&
artifact?.type !== 'extrudeWall'
) )
return
const faceInfo = await getFaceDetails(planeId) if (artifact?.type !== 'cap' && artifact?.type !== 'wall') return
const codeRef =
artifact.type === 'cap'
? getCapCodeRef(artifact, engineCommandManager.artifactGraph)
: getWallCodeRef(artifact, engineCommandManager.artifactGraph)
const faceInfo = await getFaceDetails(faceId)
if (!faceInfo?.origin || !faceInfo?.z_axis || !faceInfo?.y_axis) if (!faceInfo?.origin || !faceInfo?.z_axis || !faceInfo?.y_axis)
return return
const { z_axis, y_axis, origin } = faceInfo const { z_axis, y_axis, origin } = faceInfo
const sketchPathToNode = getNodePathFromSourceRange( const sketchPathToNode = getNodePathFromSourceRange(
kclManager.ast, kclManager.ast,
artifact.range err(codeRef) ? [0, 0] : codeRef.range
) )
const extrudePathToNode = extrusions?.range const extrudePathToNode = !err(extrusion)
? getNodePathFromSourceRange(kclManager.ast, extrusions.range) ? getNodePathFromSourceRange(
kclManager.ast,
extrusion.codeRef.range
)
: [] : []
sceneInfra.modelingSend({ sceneInfra.modelingSend({
@ -168,8 +200,8 @@ export function useEngineConnectionSubscriptions() {
) as [number, number, number], ) as [number, number, number],
sketchPathToNode, sketchPathToNode,
extrudePathToNode, extrudePathToNode,
cap: artifact.type === 'extrudeCap' ? artifact.cap : 'none', cap: artifact.type === 'cap' ? artifact.subType : 'none',
faceId: planeId, faceId: faceId,
}, },
}) })
return return

View File

@ -46,7 +46,6 @@ export function useSetupEngineManager(
streamRef?.current?.offsetHeight ?? 0 streamRef?.current?.offsetHeight ?? 0
) )
engineCommandManager.start({ engineCommandManager.start({
restart,
setMediaStream: (mediaStream) => setMediaStream(mediaStream), setMediaStream: (mediaStream) => setMediaStream(mediaStream),
setIsStreamReady: (isStreamReady) => setAppState({ isStreamReady }), setIsStreamReady: (isStreamReady) => setAppState({ isStreamReady }),
width: quadWidth, width: quadWidth,

View File

@ -91,12 +91,16 @@ export class KclManager {
set kclErrors(kclErrors) { set kclErrors(kclErrors) {
if (kclErrors === this._kclErrors && this.lints.length === 0) return if (kclErrors === this._kclErrors && this.lints.length === 0) return
this._kclErrors = kclErrors this._kclErrors = kclErrors
let diagnostics = kclErrorsToDiagnostics(kclErrors) this.setDiagnosticsForCurrentErrors()
this._kclErrorsCallBack(kclErrors)
}
setDiagnosticsForCurrentErrors() {
let diagnostics = kclErrorsToDiagnostics(this.kclErrors)
if (this.lints.length > 0) { if (this.lints.length > 0) {
diagnostics = diagnostics.concat(this.lints) diagnostics = diagnostics.concat(this.lints)
} }
editorManager.setDiagnostics(diagnostics) editorManager.setDiagnostics(diagnostics)
this._kclErrorsCallBack(kclErrors)
} }
addKclErrors(kclErrors: KCLError[]) { addKclErrors(kclErrors: KCLError[]) {
@ -310,24 +314,30 @@ export class KclManager {
this._kclErrors = errors this._kclErrors = errors
this._programMemory = programMemory this._programMemory = programMemory
if (updates !== 'artifactRanges') return if (updates !== 'artifactRanges') return
Object.entries(this.engineCommandManager.artifactMap).forEach(
// TODO the below seems like a work around, I wish there's a comment explaining exactly what
// problem this solves, but either way we should strive to remove it.
Array.from(this.engineCommandManager.artifactGraph).forEach(
([commandId, artifact]) => { ([commandId, artifact]) => {
if (!artifact.pathToNode) return if (!('codeRef' in artifact)) return
const _node1 = getNodeFromPath<CallExpression>( const _node1 = getNodeFromPath<CallExpression>(
this.ast, this.ast,
artifact.pathToNode, artifact.codeRef.pathToNode,
'CallExpression' 'CallExpression'
) )
if (err(_node1)) return if (err(_node1)) return
const { node } = _node1 const { node } = _node1
if (node.type !== 'CallExpression') return if (node.type !== 'CallExpression') return
const [oldStart, oldEnd] = artifact.range const [oldStart, oldEnd] = artifact.codeRef.range
if (oldStart === 0 && oldEnd === 0) return if (oldStart === 0 && oldEnd === 0) return
if (oldStart === node.start && oldEnd === node.end) return if (oldStart === node.start && oldEnd === node.end) return
this.engineCommandManager.artifactMap[commandId].range = [ this.engineCommandManager.artifactGraph.set(commandId, {
node.start, ...artifact,
node.end, codeRef: {
] ...artifact.codeRef,
range: [node.start, node.end],
},
})
} }
) )
} }

View File

@ -0,0 +1,388 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`testing createArtifactGraph > code with an extrusion, fillet and sketch of face: > snapshot of the artifactGraph 1`] = `
Map {
"UUID-0" => {
"codeRef": {
"pathToNode": [
[
"body",
"",
],
],
"range": [
43,
70,
],
},
"pathIds": [
"UUID",
],
"type": "plane",
},
"UUID-1" => {
"codeRef": {
"pathToNode": [
[
"body",
"",
],
],
"range": [
43,
70,
],
},
"extrusionId": "UUID",
"planeId": "UUID",
"segIds": [
"UUID",
"UUID",
"UUID",
"UUID",
"UUID",
],
"solid2dId": "UUID",
"type": "path",
},
"UUID-2" => {
"codeRef": {
"pathToNode": [
[
"body",
"",
],
],
"range": [
76,
92,
],
},
"edgeIds": [],
"pathId": "UUID",
"surfaceId": "UUID",
"type": "segment",
},
"UUID-3" => {
"codeRef": {
"pathToNode": [
[
"body",
"",
],
],
"range": [
98,
125,
],
},
"edgeCutId": "UUID",
"edgeIds": [],
"pathId": "UUID",
"surfaceId": "UUID",
"type": "segment",
},
"UUID-4" => {
"codeRef": {
"pathToNode": [
[
"body",
"",
],
],
"range": [
131,
156,
],
},
"edgeIds": [],
"pathId": "UUID",
"surfaceId": "UUID",
"type": "segment",
},
"UUID-5" => {
"codeRef": {
"pathToNode": [
[
"body",
"",
],
],
"range": [
162,
209,
],
},
"edgeIds": [],
"pathId": "UUID",
"surfaceId": "UUID",
"type": "segment",
},
"UUID-6" => {
"codeRef": {
"pathToNode": [
[
"body",
"",
],
],
"range": [
215,
223,
],
},
"edgeIds": [],
"pathId": "UUID",
"type": "segment",
},
"UUID-7" => {
"pathId": "UUID",
"type": "solid2D",
},
"UUID-8" => {
"codeRef": {
"pathToNode": [
[
"body",
"",
],
],
"range": [
243,
266,
],
},
"edgeIds": [],
"pathId": "UUID",
"surfaceIds": [
"UUID",
"UUID",
"UUID",
"UUID",
"UUID",
"UUID",
],
"type": "extrusion",
},
"UUID-9" => {
"edgeCutEdgeIds": [],
"extrusionId": "UUID",
"pathIds": [],
"segId": "UUID",
"type": "wall",
},
"UUID-10" => {
"edgeCutEdgeIds": [],
"extrusionId": "UUID",
"pathIds": [
"UUID",
],
"segId": "UUID",
"type": "wall",
},
"UUID-11" => {
"edgeCutEdgeIds": [],
"extrusionId": "UUID",
"pathIds": [],
"segId": "UUID",
"type": "wall",
},
"UUID-12" => {
"edgeCutEdgeIds": [],
"extrusionId": "UUID",
"pathIds": [],
"segId": "UUID",
"type": "wall",
},
"UUID-13" => {
"edgeCutEdgeIds": [],
"extrusionId": "UUID",
"pathIds": [],
"subType": "start",
"type": "cap",
},
"UUID-14" => {
"edgeCutEdgeIds": [],
"extrusionId": "UUID",
"pathIds": [],
"subType": "end",
"type": "cap",
},
"UUID-15" => {
"codeRef": {
"pathToNode": [
[
"body",
"",
],
],
"range": [
272,
311,
],
},
"consumedEdgeId": "UUID",
"edgeIds": [],
"subType": "fillet",
"type": "edgeCut",
},
"UUID-16" => {
"codeRef": {
"pathToNode": [
[
"body",
"",
],
],
"range": [
368,
395,
],
},
"extrusionId": "UUID",
"planeId": "UUID",
"segIds": [
"UUID",
"UUID",
"UUID",
"UUID",
],
"solid2dId": "UUID",
"type": "path",
},
"UUID-17" => {
"codeRef": {
"pathToNode": [
[
"body",
"",
],
],
"range": [
401,
416,
],
},
"edgeIds": [],
"pathId": "UUID",
"surfaceId": "UUID",
"type": "segment",
},
"UUID-18" => {
"codeRef": {
"pathToNode": [
[
"body",
"",
],
],
"range": [
422,
438,
],
},
"edgeIds": [],
"pathId": "UUID",
"surfaceId": "UUID",
"type": "segment",
},
"UUID-19" => {
"codeRef": {
"pathToNode": [
[
"body",
"",
],
],
"range": [
444,
491,
],
},
"edgeIds": [],
"pathId": "UUID",
"surfaceId": "UUID",
"type": "segment",
},
"UUID-20" => {
"codeRef": {
"pathToNode": [
[
"body",
"",
],
],
"range": [
497,
505,
],
},
"edgeIds": [],
"pathId": "UUID",
"type": "segment",
},
"UUID-21" => {
"pathId": "UUID",
"type": "solid2D",
},
"UUID-22" => {
"codeRef": {
"pathToNode": [
[
"body",
"",
],
],
"range": [
525,
546,
],
},
"edgeIds": [],
"pathId": "UUID",
"surfaceIds": [
"UUID",
"UUID",
"UUID",
"UUID",
"UUID",
],
"type": "extrusion",
},
"UUID-23" => {
"edgeCutEdgeIds": [],
"extrusionId": "UUID",
"pathIds": [],
"segId": "UUID",
"type": "wall",
},
"UUID-24" => {
"edgeCutEdgeIds": [],
"extrusionId": "UUID",
"pathIds": [],
"segId": "UUID",
"type": "wall",
},
"UUID-25" => {
"edgeCutEdgeIds": [],
"extrusionId": "UUID",
"pathIds": [],
"segId": "UUID",
"type": "wall",
},
"UUID-26" => {
"edgeCutEdgeIds": [],
"extrusionId": "UUID",
"pathIds": [],
"subType": "start",
"type": "cap",
},
"UUID-27" => {
"edgeCutEdgeIds": [],
"extrusionId": "UUID",
"pathIds": [],
"subType": "end",
"type": "cap",
},
}
`;

View File

@ -0,0 +1,48 @@
## Artifact Graph
#### What it does
The artifact graph's primary role is to map geometry artifacts in the 3d-scene/engine, to the code/AST such that when the engine sends the FE an id of some piece of geometry (say because the user clicked on something) then we know both what it is, and how it relates to the user's code.
Relating it to a user's code is important because this is how we drive our AST-mods, say a user clicks a segment and wants to constrain it horizontally, because of the artifact graph we know that their selection was in fact a specific `line(...)` callExpression, and now we're able to transform this to `xLine(...)` in order to constrain it.
#### How to reason about the graph
Here is what roughly what the artifact graph looks like
![image of the artifact map](artifactMapGraphs/grokable-graph.png)
The best way to read this is starting with the plane at the bottom and going upwards, as this is roughly the command order (which the graph is based on).
Here's an explanation:
- plane is created (kcl:`startSketchOn`, command: `enable_sketch_mode`)
- path is created, needs to refer to the plane that the sketch is on (kcl:`startProfileAt`, command: `start_path`)
- each segment that is created (kcl: `line`, command: `extend_path`) must refer back to the path.
- Once we're read to extrude (kcl: `extrude`, command: `extrude`) it much refer to the path.
- The extrude created a bunch of faces, edges etc, each of these relates back to the extrude command and the segment call expression, but there's no direct bit of kcl to refer to.
The above is probably enough to give more examples of how the graph is used.
- When a user hovers over a segment, the engine sends us the id of the segment, we can look it up directly in the graph, and we store pointers to the code in the graph, This allows use to highlight the `line(...)` call expression in the code.
- Same as above but the user hovers over a extrude wall-face, the engine sends us this id, we look it up in the graph, but there's no pointer to the code in this node. We can then traverse to both the segment and the extrude nodes to get source ranges for `line(...)` and `extrude(...)` and highlight them both.
Other things to point out is that a new path can be created directly on a wall-face, i.e. this is sketch on face, and more than one path can point to the same plane, that is multiple profiles on the same plane.
#### Generated Graphs
The image above is hand drawn for grokablitiy, but it's useful to look at a real graph, take this bit of geometry
![demo geometry](artifactMapGraphs/demoGeometry.png)
In `src/lang/std/artifactGraph.test.ts` we generate the graph for it
![demo geometry](artifactMapGraphs/exampleCode1.png)
It's definitely harder to read, if you start at roughly the bottom center of the page and find the node `plane-0` and visually traverse from there you can see it has the same structure, plane is connected to a path, which is connected to multiple segments and an extrusion etc.
Generating the graph here serves a couple of purposes
1. Allows us to sanity check the graph, in development or as a debug tool.
2. Is a form of test and regression check. The code that creates the node and edges would error if we tried to create an edge to a node that didn't exist, this gives us some confidence that the graph is correct. Also because we want want to be able to traverse the graph in both directions, checking each edge has an arrowhead going both directions is a good check. Lastly this images are generated and committed as part of CI, if something changes in the graph, we'll notice.
We'll need to add more sample code to `src/lang/std/artifactGraph.test.ts` to generate more graphs, to test more kcl API as the app continues development.

View File

@ -0,0 +1,743 @@
import { makeDefaultPlanes, parse, initPromise, Program } from 'lang/wasm'
import { Models } from '@kittycad/lib'
import {
OrderedCommand,
ResponseMap,
createArtifactGraph,
filterArtifacts,
expandPlane,
expandPath,
expandExtrusion,
ArtifactGraph,
expandSegment,
getArtifactsToUpdate,
} from './artifactGraph'
import { err } from 'lib/trap'
import { engineCommandManager, kclManager } from 'lib/singletons'
import { CI, VITE_KC_DEV_TOKEN } from 'env'
import fsp from 'fs/promises'
import fs from 'fs'
import { chromium } from 'playwright'
import * as d3 from 'd3-force'
import path from 'path'
import pixelmatch from 'pixelmatch'
import { PNG } from 'pngjs'
/*
Note this is an integration test, these tests connect to our real dev server and make websocket commands.
It's needed for testing the artifactGraph, as it is tied to the websocket commands.
*/
const pathStart = 'src/lang/std/artifactMapCache'
const fullPath = `${pathStart}/artifactMapCache.json`
const exampleCode1 = `const sketch001 = startSketchOn('XY')
|> startProfileAt([-5, -5], %)
|> line([0, 10], %)
|> line([10.55, 0], %, $seg01)
|> line([0, -10], %, $seg02)
|> lineTo([profileStartX(%), profileStartY(%)], %)
|> close(%)
const extrude001 = extrude(-10, sketch001)
|> fillet({ radius: 5, tags: [seg01] }, %)
const sketch002 = startSketchOn(extrude001, seg02)
|> startProfileAt([-2, -6], %)
|> line([2, 3], %)
|> line([2, -3], %)
|> lineTo([profileStartX(%), profileStartY(%)], %)
|> close(%)
const extrude002 = extrude(5, sketch002)
`
const sketchOnFaceOnFaceEtc = `const sketch001 = startSketchOn('XZ')
|> startProfileAt([0, 0], %)
|> line([4, 8], %)
|> line([5, -8], %, $seg01)
|> lineTo([profileStartX(%), profileStartY(%)], %)
|> close(%)
const extrude001 = extrude(6, sketch001)
const sketch002 = startSketchOn(extrude001, seg01)
|> startProfileAt([-0.5, 0.5], %)
|> line([2, 5], %)
|> line([2, -5], %)
|> lineTo([profileStartX(%), profileStartY(%)], %)
|> close(%)
const extrude002 = extrude(5, sketch002)
const sketch003 = startSketchOn(extrude002, 'END')
|> startProfileAt([1, 1.5], %)
|> line([0.5, 2], %, $seg02)
|> line([1, -2], %)
|> lineTo([profileStartX(%), profileStartY(%)], %)
|> close(%)
const extrude003 = extrude(4, sketch003)
const sketch004 = startSketchOn(extrude003, seg02)
|> startProfileAt([-3, 14], %)
|> line([0.5, 1], %)
|> line([0.5, -2], %)
|> lineTo([profileStartX(%), profileStartY(%)], %)
|> close(%)
const extrude004 = extrude(3, sketch004)
`
// add more code snippets here and use `getCommands` to get the orderedCommands and responseMap for more tests
const codeToWriteCacheFor = {
exampleCode1,
sketchOnFaceOnFaceEtc,
} as const
type CodeKey = keyof typeof codeToWriteCacheFor
type CacheShape = {
[key in CodeKey]: {
orderedCommands: OrderedCommand[]
responseMap: ResponseMap
}
}
beforeAll(async () => {
await initPromise
let parsed
try {
const file = await fsp.readFile(fullPath, 'utf-8')
parsed = JSON.parse(file)
} catch (e) {
parsed = false
}
if (!CI && parsed) {
// caching the results of the websocket commands makes testing this locally much faster
// real calls to the engine are needed to test the artifact map
// bust the cache with: `rm -rf src/lang/std/artifactGraphCache`
return
}
// THESE TEST WILL FAIL without VITE_KC_DEV_TOKEN set in .env.development.local
engineCommandManager.start({
disableWebRTC: true,
token: VITE_KC_DEV_TOKEN,
// there does seem to be a minimum resolution, not sure what it is but 256 works ok.
width: 256,
height: 256,
executeCode: () => {},
makeDefaultPlanes: () => makeDefaultPlanes(engineCommandManager),
setMediaStream: () => {},
setIsStreamReady: () => {},
modifyGrid: async () => {},
})
await engineCommandManager.waitForReady
const cacheEntries = Object.entries(codeToWriteCacheFor) as [
CodeKey,
string
][]
const cacheToWriteToFileTemp: Partial<CacheShape> = {}
for (const [codeKey, code] of cacheEntries) {
const ast = parse(code)
if (err(ast)) {
console.error(ast)
throw ast
}
await kclManager.executeAst(ast)
cacheToWriteToFileTemp[codeKey] = {
orderedCommands: engineCommandManager.orderedCommands,
responseMap: engineCommandManager.responseMap,
}
}
const cache = JSON.stringify(cacheToWriteToFileTemp)
await fsp.mkdir(pathStart, { recursive: true })
await fsp.writeFile(fullPath, cache)
}, 20_000)
afterAll(() => {
engineCommandManager.tearDown()
})
describe('testing createArtifactGraph', () => {
describe('code with an extrusion, fillet and sketch of face:', () => {
let ast: Program
let theMap: ReturnType<typeof createArtifactGraph>
it('setup', () => {
// putting this logic in here because describe blocks runs before beforeAll has finished
const {
orderedCommands,
responseMap,
ast: _ast,
} = getCommands('exampleCode1')
ast = _ast
theMap = createArtifactGraph({ orderedCommands, responseMap, ast })
})
it('there should be two planes for the extrusion and the sketch on face', () => {
const planes = [...filterArtifacts({ types: ['plane'] }, theMap)].map(
(plane) => expandPlane(plane[1], theMap)
)
expect(planes).toHaveLength(1)
planes.forEach((path) => {
expect(path.type).toBe('plane')
})
})
it('there should be two paths for the extrusion and the sketch on face', () => {
const paths = [...filterArtifacts({ types: ['path'] }, theMap)].map(
(path) => expandPath(path[1], theMap)
)
expect(paths).toHaveLength(2)
paths.forEach((path) => {
if (err(path)) throw path
expect(path.type).toBe('path')
})
})
it('there should be two extrusions, for the original and the sketchOnFace, the first extrusion should have 6 sides of the cube', () => {
const extrusions = [
...filterArtifacts({ types: ['extrusion'] }, theMap),
].map((extrusion) => expandExtrusion(extrusion[1], theMap))
expect(extrusions).toHaveLength(2)
extrusions.forEach((extrusion, index) => {
if (err(extrusion)) throw extrusion
expect(extrusion.type).toBe('extrusion')
const firstExtrusionIsACubeIE6Sides = 6
const secondExtrusionIsATriangularPrismIE5Sides = 5
expect(extrusion.surfaces.length).toBe(
!index
? firstExtrusionIsACubeIE6Sides
: secondExtrusionIsATriangularPrismIE5Sides
)
})
})
it('there should be 5 + 4 segments, 4 (+close) from the first extrusion and 3 (+close) from the second', () => {
const segments = [...filterArtifacts({ types: ['segment'] }, theMap)].map(
(segment) => expandSegment(segment[1], theMap)
)
expect(segments).toHaveLength(9)
})
it('snapshot of the artifactGraph', () => {
const stableMap = new Map(
[...theMap].map(([, artifact], index): [string, any] => {
const stableValue: any = {}
Object.entries(artifact).forEach(([propName, value]) => {
if (
propName === 'type' ||
propName === 'codeRef' ||
propName === 'subType'
) {
stableValue[propName] = value
return
}
if (Array.isArray(value))
stableValue[propName] = value.map(() => 'UUID')
if (typeof value === 'string' && value)
stableValue[propName] = 'UUID'
})
return [`UUID-${index}`, stableValue]
})
)
expect(stableMap).toMatchSnapshot()
})
it('screenshot graph', async () => {
// Ostensibly this takes a screen shot of the graph of the artifactGraph
// but it's it also tests that all of the id links are correct because if one
// of the edges refers to a non-existent node, the graph will throw.
// further more we can check that each edge is bi-directional, if it's not
// by checking the arrow heads going both ways, on the graph.
await GraphTheGraph(theMap, 1400, 1400, 'exampleCode1.png')
}, 20000)
})
})
describe('capture graph of sketchOnFaceOnFace...', () => {
describe('code with an extrusion, fillet and sketch of face:', () => {
let ast: Program
let theMap: ReturnType<typeof createArtifactGraph>
it('setup', async () => {
// putting this logic in here because describe blocks runs before beforeAll has finished
const {
orderedCommands,
responseMap,
ast: _ast,
} = getCommands('sketchOnFaceOnFaceEtc')
ast = _ast
theMap = createArtifactGraph({ orderedCommands, responseMap, ast })
// Ostensibly this takes a screen shot of the graph of the artifactGraph
// but it's it also tests that all of the id links are correct because if one
// of the edges refers to a non-existent node, the graph will throw.
// further more we can check that each edge is bi-directional, if it's not
// by checking the arrow heads going both ways, on the graph.
await GraphTheGraph(theMap, 2500, 2500, 'sketchOnFaceOnFaceEtc.png')
}, 20000)
})
})
function getCommands(codeKey: CodeKey): CacheShape[CodeKey] & { ast: Program } {
const ast = parse(codeKey)
if (err(ast)) {
console.error(ast)
throw ast
}
const file = fs.readFileSync(fullPath, 'utf-8')
const parsed: CacheShape = JSON.parse(file)
// these either already exist from the last run, or were created in
const orderedCommands = parsed[codeKey].orderedCommands
const responseMap = parsed[codeKey].responseMap
return {
orderedCommands,
responseMap,
ast,
}
}
async function GraphTheGraph(
theMap: ArtifactGraph,
sizeX: number,
sizeY: number,
imageName: string
) {
const nodes: Array<{ id: string; label: string }> = []
const edges: Array<{ source: string; target: string; label: string }> = []
let index = 0
for (const [commandId, artifact] of theMap) {
nodes.push({
id: commandId,
label: `${artifact.type}-${index++}`,
})
Object.entries(artifact).forEach(([propName, value]) => {
if (
propName === 'type' ||
propName === 'codeRef' ||
propName === 'subType'
)
return
if (Array.isArray(value))
value.forEach((v) => {
v && edges.push({ source: commandId, target: v, label: propName })
})
if (typeof value === 'string' && value)
edges.push({ source: commandId, target: value, label: propName })
})
}
// Create a force simulation to calculate node positions
const simulation = d3
.forceSimulation(nodes as any)
.force(
'link',
d3
.forceLink(edges)
.id((d: any) => d.id)
.distance(100)
)
.force('charge', d3.forceManyBody().strength(-300))
.force('center', d3.forceCenter(300, 200))
.stop()
// Run the simulation
for (let i = 0; i < 300; ++i) simulation.tick()
// Create traces for Plotly
const nodeTrace = {
x: nodes.map((node: any) => node.x),
y: nodes.map((node: any) => node.y),
text: nodes.map((node) => node.label), // Use the custom label
mode: 'markers+text',
type: 'scatter',
marker: { size: 20, color: 'gray' }, // Nodes in gray
textfont: { size: 14, color: 'black' }, // Labels in black
textposition: 'top center', // Position text on top
}
const edgeTrace = {
x: [],
y: [],
mode: 'lines',
type: 'scatter',
line: { width: 2, color: 'lightgray' }, // Edges in light gray
}
const annotations: any[] = []
edges.forEach((edge) => {
const sourceNode = nodes.find(
(node: any) => node.id === (edge as any).source.id
)
const targetNode = nodes.find(
(node: any) => node.id === (edge as any).target.id
)
// Check if nodes are found
if (!sourceNode || !targetNode) {
throw new Error(
// @ts-ignore
`Node not found: ${!sourceNode ? edge.source.id : edge.target.id}`
)
}
// @ts-ignore
edgeTrace.x.push(sourceNode.x, targetNode.x, null)
// @ts-ignore
edgeTrace.y.push(sourceNode.y, targetNode.y, null)
// Calculate offset for arrowhead
const offsetFactor = 0.9 // Adjust this factor to control the offset distance
// @ts-ignore
const offsetX = (targetNode.x - sourceNode.x) * offsetFactor
// @ts-ignore
const offsetY = (targetNode.y - sourceNode.y) * offsetFactor
// Add arrowhead annotation with offset
annotations.push({
// @ts-ignore
ax: sourceNode.x,
// @ts-ignore
ay: sourceNode.y,
// @ts-ignore
x: targetNode.x - offsetX,
// @ts-ignore
y: targetNode.y - offsetY,
xref: 'x',
yref: 'y',
axref: 'x',
ayref: 'y',
showarrow: true,
arrowhead: 2,
arrowsize: 1,
arrowwidth: 2,
arrowcolor: 'darkgray', // Arrowheads in dark gray
})
// Add edge label annotation closer to the edge tail (25% of the length)
// @ts-ignore
const labelX = sourceNode.x * 0.75 + targetNode.x * 0.25
// @ts-ignore
const labelY = sourceNode.y * 0.75 + targetNode.y * 0.25
annotations.push({
x: labelX,
y: labelY,
xref: 'x',
yref: 'y',
text: edge.label,
showarrow: false,
font: { size: 12, color: 'black' }, // Edge labels in black
align: 'center',
})
})
const data = [edgeTrace, nodeTrace]
const layout = {
// title: 'Force-Directed Graph with Nodes and Edges',
xaxis: { showgrid: false, zeroline: false, showticklabels: false },
yaxis: { showgrid: false, zeroline: false, showticklabels: false },
showlegend: false,
annotations: annotations,
}
// Export to PNG using Playwright
const browser = await chromium.launch()
const page = await browser.newPage()
await page.setContent(`
<html>
<head>
<script src="https://cdn.plot.ly/plotly-latest.min.js"></script>
</head>
<body>
<div id="plotly-graph" style="width:${sizeX}px;height:${sizeY}px;"></div>
<script>
Plotly.newPlot('plotly-graph', ${JSON.stringify(
data
)}, ${JSON.stringify(layout)})
</script>
</body>
</html>
`)
await page.waitForSelector('#plotly-graph')
const element = await page.$('#plotly-graph')
// @ts-ignore
await element.screenshot({
path: `./e2e/playwright/temp3.png`,
})
await browser.close()
const img1Path = path.resolve(`./src/lang/std/artifactMapGraphs/${imageName}`)
const img2Path = path.resolve('./e2e/playwright/temp3.png')
const img1 = PNG.sync.read(fs.readFileSync(img1Path))
const img2 = PNG.sync.read(fs.readFileSync(img2Path))
const { width, height } = img1
const diff = new PNG({ width, height })
const numDiffPixels = pixelmatch(
img1.data,
img2.data,
diff.data,
width,
height,
{ threshold: 0.1 }
)
if (numDiffPixels > 10) {
console.warn('numDiffPixels', numDiffPixels)
// write file out to final place
fs.writeFileSync(
`src/lang/std/artifactMapGraphs/${imageName}`,
PNG.sync.write(img2)
)
}
}
describe('testing getArtifactsToUpdate', () => {
it('should return an array of artifacts to update', () => {
const { orderedCommands, responseMap, ast } = getCommands('exampleCode1')
const map = createArtifactGraph({ orderedCommands, responseMap, ast })
const getArtifact = (id: string) => map.get(id)
const currentPlaneId = 'UUID-1'
const getUpdateObjects = (type: Models['ModelingCmd_type']['type']) => {
const artifactsToUpdate = getArtifactsToUpdate({
orderedCommand: orderedCommands.find(
(a) =>
a.command.type === 'modeling_cmd_req' && a.command.cmd.type === type
)!,
responseMap,
getArtifact,
currentPlaneId,
ast,
})
return artifactsToUpdate.map(({ artifact }) => artifact)
}
expect(getUpdateObjects('start_path')).toEqual([
{
type: 'path',
segIds: [],
planeId: 'UUID-1',
extrusionId: '',
codeRef: {
pathToNode: [['body', '']],
range: [43, 70],
},
},
])
expect(getUpdateObjects('extrude')).toEqual([
{
type: 'extrusion',
pathId: expect.any(String),
surfaceIds: [],
edgeIds: [],
codeRef: {
range: [243, 266],
pathToNode: [['body', '']],
},
},
{
type: 'path',
segIds: expect.any(Array),
planeId: expect.any(String),
extrusionId: expect.any(String),
codeRef: {
range: [43, 70],
pathToNode: [['body', '']],
},
solid2dId: expect.any(String),
},
])
expect(getUpdateObjects('extend_path')).toEqual([
{
type: 'segment',
pathId: expect.any(String),
surfaceId: '',
edgeIds: [],
codeRef: {
range: [76, 92],
pathToNode: [['body', '']],
},
},
{
type: 'path',
segIds: expect.any(Array),
planeId: expect.any(String),
extrusionId: expect.any(String),
codeRef: {
range: [43, 70],
pathToNode: [['body', '']],
},
solid2dId: expect.any(String),
},
])
expect(getUpdateObjects('solid3d_fillet_edge')).toEqual([
{
type: 'edgeCut',
subType: 'fillet',
consumedEdgeId: expect.any(String),
edgeIds: [],
surfaceId: '',
codeRef: {
range: [272, 311],
pathToNode: [['body', '']],
},
},
{
type: 'segment',
pathId: expect.any(String),
surfaceId: expect.any(String),
edgeIds: [],
codeRef: {
range: [98, 125],
pathToNode: [['body', '']],
},
edgeCutId: expect.any(String),
},
])
expect(getUpdateObjects('solid3d_get_extrusion_face_info')).toEqual([
{
type: 'wall',
segId: expect.any(String),
edgeCutEdgeIds: [],
extrusionId: expect.any(String),
pathIds: [],
},
{
type: 'segment',
pathId: expect.any(String),
surfaceId: expect.any(String),
edgeIds: [],
codeRef: {
range: [162, 209],
pathToNode: [['body', '']],
},
},
{
type: 'extrusion',
pathId: expect.any(String),
surfaceIds: expect.any(Array),
edgeIds: [],
codeRef: {
range: [243, 266],
pathToNode: [['body', '']],
},
},
{
type: 'wall',
segId: expect.any(String),
edgeCutEdgeIds: [],
extrusionId: expect.any(String),
pathIds: [],
},
{
type: 'segment',
pathId: expect.any(String),
surfaceId: expect.any(String),
edgeIds: [],
codeRef: {
range: [131, 156],
pathToNode: [['body', '']],
},
},
{
type: 'extrusion',
pathId: expect.any(String),
surfaceIds: expect.any(Array),
edgeIds: [],
codeRef: {
range: [243, 266],
pathToNode: [['body', '']],
},
},
{
type: 'wall',
segId: expect.any(String),
edgeCutEdgeIds: [],
extrusionId: expect.any(String),
pathIds: [],
},
{
type: 'segment',
pathId: expect.any(String),
surfaceId: expect.any(String),
edgeIds: [],
codeRef: {
range: [98, 125],
pathToNode: [['body', '']],
},
edgeCutId: expect.any(String),
},
{
type: 'extrusion',
pathId: expect.any(String),
surfaceIds: expect.any(Array),
edgeIds: [],
codeRef: {
range: [243, 266],
pathToNode: [['body', '']],
},
},
{
type: 'wall',
segId: expect.any(String),
edgeCutEdgeIds: [],
extrusionId: expect.any(String),
pathIds: [],
},
{
type: 'segment',
pathId: expect.any(String),
surfaceId: expect.any(String),
edgeIds: [],
codeRef: {
range: [76, 92],
pathToNode: [['body', '']],
},
},
{
type: 'extrusion',
pathId: expect.any(String),
surfaceIds: expect.any(Array),
edgeIds: [],
codeRef: {
range: [243, 266],
pathToNode: [['body', '']],
},
},
{
type: 'cap',
subType: 'start',
edgeCutEdgeIds: [],
extrusionId: expect.any(String),
pathIds: [],
},
{
type: 'extrusion',
pathId: expect.any(String),
surfaceIds: expect.any(Array),
edgeIds: [],
codeRef: {
range: [243, 266],
pathToNode: [['body', '']],
},
},
{
type: 'cap',
subType: 'end',
edgeCutEdgeIds: [],
extrusionId: expect.any(String),
pathIds: [],
},
{
type: 'extrusion',
pathId: expect.any(String),
surfaceIds: expect.any(Array),
edgeIds: [],
codeRef: {
range: [243, 266],
pathToNode: [['body', '']],
},
},
])
})
})

View File

@ -0,0 +1,682 @@
import { PathToNode, Program, SourceRange } from 'lang/wasm'
import { Models } from '@kittycad/lib'
import { getNodePathFromSourceRange } from 'lang/queryAst'
import { err } from 'lib/trap'
interface CommonCommandProperties {
range: SourceRange
pathToNode: PathToNode
}
export interface PlaneArtifact {
type: 'plane'
pathIds: Array<string>
codeRef: CommonCommandProperties
}
export interface PlaneArtifactRich {
type: 'plane'
paths: Array<PathArtifact>
codeRef: CommonCommandProperties
}
export interface PathArtifact {
type: 'path'
planeId: string
segIds: Array<string>
extrusionId: string
solid2dId?: string
codeRef: CommonCommandProperties
}
interface solid2D {
type: 'solid2D'
pathId: string
}
export interface PathArtifactRich {
type: 'path'
plane: PlaneArtifact | WallArtifact
segments: Array<SegmentArtifact>
extrusion: ExtrusionArtifact
codeRef: CommonCommandProperties
}
interface SegmentArtifact {
type: 'segment'
pathId: string
surfaceId: string
edgeIds: Array<string>
edgeCutId?: string
codeRef: CommonCommandProperties
}
interface SegmentArtifactRich {
type: 'segment'
path: PathArtifact
surf: WallArtifact
edges: Array<ExtrudeEdge>
edgeCut?: EdgeCut
codeRef: CommonCommandProperties
}
interface ExtrusionArtifact {
type: 'extrusion'
pathId: string
surfaceIds: Array<string>
edgeIds: Array<string>
codeRef: CommonCommandProperties
}
interface ExtrusionArtifactRich {
type: 'extrusion'
path: PathArtifact
surfaces: Array<WallArtifact | CapArtifact>
edges: Array<ExtrudeEdge>
codeRef: CommonCommandProperties
}
interface WallArtifact {
type: 'wall'
segId: string
edgeCutEdgeIds: Array<string>
extrusionId: string
pathIds: Array<string>
}
interface CapArtifact {
type: 'cap'
subType: 'start' | 'end'
edgeCutEdgeIds: Array<string>
extrusionId: string
pathIds: Array<string>
}
interface ExtrudeEdge {
type: 'extrudeEdge'
segId: string
extrusionId: string
edgeId: string
}
/** A edgeCut is a more generic term for both fillet or chamfer */
interface EdgeCut {
type: 'edgeCut'
subType: 'fillet' | 'chamfer'
consumedEdgeId: string
edgeIds: Array<string>
surfaceId: string
codeRef: CommonCommandProperties
}
interface EdgeCutEdge {
type: 'edgeCutEdge'
edgeCutId: string
surfaceId: string
}
export type Artifact =
| PlaneArtifact
| PathArtifact
| SegmentArtifact
| ExtrusionArtifact
| WallArtifact
| CapArtifact
| ExtrudeEdge
| EdgeCut
| EdgeCutEdge
| solid2D
export type ArtifactGraph = Map<string, Artifact>
export type EngineCommand = Models['WebSocketRequest_type']
type OkWebSocketResponseData = Models['OkWebSocketResponseData_type']
export interface ResponseMap {
[commandId: string]: OkWebSocketResponseData
}
export interface OrderedCommand {
command: EngineCommand
range: SourceRange
}
/** Creates a graph of artifacts from a list of ordered commands and their responses
* muting the Map should happen entirely this function, other functions called within
* should return data on how to update the map, and not do so directly.
*/
export function createArtifactGraph({
orderedCommands,
responseMap,
ast,
}: {
orderedCommands: Array<OrderedCommand>
responseMap: ResponseMap
ast: Program
}) {
const myMap = new Map<string, Artifact>()
/** see docstring for {@link getArtifactsToUpdate} as to why this is needed */
let currentPlaneId = ''
orderedCommands.forEach((orderedCommand) => {
if (orderedCommand.command?.type === 'modeling_cmd_req') {
if (orderedCommand.command.cmd.type === 'enable_sketch_mode') {
currentPlaneId = orderedCommand.command.cmd.entity_id
}
if (orderedCommand.command.cmd.type === 'sketch_mode_disable') {
currentPlaneId = ''
}
}
const artifactsToUpdate = getArtifactsToUpdate({
orderedCommand,
responseMap,
getArtifact: (id: string) => myMap.get(id),
currentPlaneId,
ast,
})
artifactsToUpdate.forEach(({ id, artifact }) => {
const mergedArtifact = mergeArtifacts(myMap.get(id), artifact)
myMap.set(id, mergedArtifact)
})
})
return myMap
}
/** Merges two artifacts, since our artifacts only contain strings and arrays of string for values we coerce that
* but maybe types can be improved here.
*/
function mergeArtifacts(
oldArtifact: Artifact | undefined,
newArtifact: Artifact
): Artifact {
// only has string and array of strings
interface GenericArtifact {
[key: string]: string | Array<string>
}
if (!oldArtifact) return newArtifact
// merging artifacts of different types should never happen, but if it does, just return the new artifact
if (oldArtifact.type !== newArtifact.type) return newArtifact
const _oldArtifact = oldArtifact as any as GenericArtifact
const mergedArtifact = { ...oldArtifact, ...newArtifact } as GenericArtifact
Object.entries(newArtifact as any as GenericArtifact).forEach(
([propName, value]) => {
const otherValue = _oldArtifact[propName]
if (Array.isArray(value) && Array.isArray(otherValue)) {
mergedArtifact[propName] = [...new Set([...otherValue, ...value])]
}
}
)
return mergedArtifact as any as Artifact
}
/**
* Processes a single command and it's response in order to populate the artifact map
* It does not mutate the map directly, but returns an array of artifacts to update
*
* @param currentPlaneId is only needed for `start_path` commands because this command does not have a pathId
* instead it relies on the id used with the `enable_sketch_mode` command, so this much be kept track of
* outside of this function. It would be good to update the `start_path` command to include the planeId so we
* can remove this.
*/
export function getArtifactsToUpdate({
orderedCommand: { command, range },
getArtifact,
responseMap,
currentPlaneId,
ast,
}: {
orderedCommand: OrderedCommand
responseMap: ResponseMap
/** Passing in a getter because we don't wan this function to update the map directly */
getArtifact: (id: string) => Artifact | undefined
currentPlaneId: string
ast: Program
}): Array<{
id: string
artifact: Artifact
}> {
const pathToNode = getNodePathFromSourceRange(ast, range)
// expect all to be `modeling_cmd_req` as batch commands have
// already been expanded before being added to orderedCommands
if (command.type !== 'modeling_cmd_req') return []
const id = command.cmd_id
const response = responseMap[id]
const cmd = command.cmd
const returnArr: ReturnType<typeof getArtifactsToUpdate> = []
if (cmd.type === 'enable_sketch_mode') {
const plane = getArtifact(currentPlaneId)
const pathIds = plane?.type === 'plane' ? plane?.pathIds : []
const codeRef =
plane?.type === 'plane' ? plane?.codeRef : { range, pathToNode }
const existingPlane = getArtifact(currentPlaneId)
if (existingPlane?.type === 'wall') {
return [
{
id: currentPlaneId,
artifact: {
type: 'wall',
segId: existingPlane.segId,
edgeCutEdgeIds: existingPlane.edgeCutEdgeIds,
extrusionId: existingPlane.extrusionId,
pathIds: existingPlane.pathIds,
},
},
]
} else {
return [
{ id: currentPlaneId, artifact: { type: 'plane', pathIds, codeRef } },
]
}
} else if (cmd.type === 'start_path') {
returnArr.push({
id,
artifact: {
type: 'path',
segIds: [],
planeId: currentPlaneId,
extrusionId: '',
codeRef: { range, pathToNode },
},
})
const plane = getArtifact(currentPlaneId)
const codeRef =
plane?.type === 'plane' ? plane?.codeRef : { range, pathToNode }
if (plane?.type === 'plane') {
returnArr.push({
id: currentPlaneId,
artifact: { type: 'plane', pathIds: [id], codeRef },
})
}
if (plane?.type === 'wall') {
returnArr.push({
id: currentPlaneId,
artifact: {
type: 'wall',
segId: plane.segId,
edgeCutEdgeIds: plane.edgeCutEdgeIds,
extrusionId: plane.extrusionId,
pathIds: [id],
},
})
}
return returnArr
} else if (cmd.type === 'extend_path' || cmd.type === 'close_path') {
const pathId = cmd.type === 'extend_path' ? cmd.path : cmd.path_id
returnArr.push({
id,
artifact: {
type: 'segment',
pathId,
surfaceId: '',
edgeIds: [],
codeRef: { range, pathToNode },
},
})
const path = getArtifact(pathId)
if (path?.type === 'path')
returnArr.push({
id: pathId,
artifact: { ...path, segIds: [id] },
})
if (
response.type === 'modeling' &&
response.data.modeling_response.type === 'close_path'
) {
returnArr.push({
id: response.data.modeling_response.data.face_id,
artifact: { type: 'solid2D', pathId },
})
const path = getArtifact(pathId)
if (path?.type === 'path')
returnArr.push({
id: pathId,
artifact: {
...path,
solid2dId: response.data.modeling_response.data.face_id,
},
})
}
return returnArr
} else if (cmd.type === 'extrude') {
returnArr.push({
id,
artifact: {
type: 'extrusion',
pathId: cmd.target,
surfaceIds: [],
edgeIds: [],
codeRef: { range, pathToNode },
},
})
const path = getArtifact(cmd.target)
if (path?.type === 'path')
returnArr.push({
id: cmd.target,
artifact: { ...path, extrusionId: id },
})
return returnArr
} else if (
cmd.type === 'solid3d_get_extrusion_face_info' &&
response?.type === 'modeling' &&
response.data.modeling_response.type === 'solid3d_get_extrusion_face_info'
) {
let lastPath: PathArtifact
response.data.modeling_response.data.faces.forEach(
({ curve_id, cap, face_id }) => {
if (cap === 'none' && curve_id && face_id) {
const seg = getArtifact(curve_id)
if (seg?.type !== 'segment') return
const path = getArtifact(seg.pathId)
if (path?.type === 'path' && seg?.type === 'segment') {
lastPath = path
returnArr.push({
id: face_id,
artifact: {
type: 'wall',
segId: curve_id,
edgeCutEdgeIds: [],
extrusionId: path.extrusionId,
pathIds: [],
},
})
returnArr.push({
id: curve_id,
artifact: { ...seg, surfaceId: face_id },
})
const extrusion = getArtifact(path.extrusionId)
if (extrusion?.type === 'extrusion') {
returnArr.push({
id: path.extrusionId,
artifact: {
...extrusion,
surfaceIds: [face_id],
},
})
}
}
}
}
)
response.data.modeling_response.data.faces.forEach(({ cap, face_id }) => {
if ((cap === 'top' || cap === 'bottom') && face_id) {
const path = lastPath
if (path?.type === 'path') {
returnArr.push({
id: face_id,
artifact: {
type: 'cap',
subType: cap === 'bottom' ? 'start' : 'end',
edgeCutEdgeIds: [],
extrusionId: path.extrusionId,
pathIds: [],
},
})
const extrusion = getArtifact(path.extrusionId)
if (extrusion?.type !== 'extrusion') return
returnArr.push({
id: path.extrusionId,
artifact: {
...extrusion,
surfaceIds: [face_id],
},
})
}
}
})
return returnArr
} else if (cmd.type === 'solid3d_fillet_edge') {
returnArr.push({
id,
artifact: {
type: 'edgeCut',
subType: cmd.cut_type,
consumedEdgeId: cmd.edge_id,
edgeIds: [],
surfaceId: '',
codeRef: { range, pathToNode },
},
})
const consumedEdge = getArtifact(cmd.edge_id)
if (consumedEdge?.type === 'segment') {
returnArr.push({
id: cmd.edge_id,
artifact: { ...consumedEdge, edgeCutId: id },
})
}
return returnArr
}
return []
}
/** filter map items of a specific type */
export function filterArtifacts<T extends Artifact['type'][]>(
{
types,
predicate,
}: {
types: T
predicate?: (value: Extract<Artifact, { type: T[number] }>) => boolean
},
map: ArtifactGraph
) {
return new Map(
Array.from(map).filter(
([_, value]) =>
types.includes(value.type) &&
(!predicate ||
predicate(value as Extract<Artifact, { type: T[number] }>))
)
) as Map<string, Extract<Artifact, { type: T[number] }>>
}
export function getArtifactsOfTypes<T extends Artifact['type'][]>(
{
keys,
types,
predicate,
}: {
keys: string[]
types: T
predicate?: (value: Extract<Artifact, { type: T[number] }>) => boolean
},
map: ArtifactGraph
): Map<string, Extract<Artifact, { type: T[number] }>> {
return new Map(
[...map].filter(
([key, value]) =>
keys.includes(key) &&
types.includes(value.type) &&
(!predicate ||
predicate(value as Extract<Artifact, { type: T[number] }>))
)
) as Map<string, Extract<Artifact, { type: T[number] }>>
}
export function getArtifactOfTypes<T extends Artifact['type'][]>(
{
key,
types,
}: {
key: string
types: T
},
map: ArtifactGraph
): Extract<Artifact, { type: T[number] }> | Error {
const artifact = map.get(key)
if (!artifact) return new Error(`No artifact found with key ${key}`)
if (!types.includes(artifact?.type))
return new Error(`Expected ${types} but got ${artifact?.type}`)
return artifact as Extract<Artifact, { type: T[number] }>
}
export function expandPlane(
plane: PlaneArtifact,
artifactGraph: ArtifactGraph
): PlaneArtifactRich {
const paths = getArtifactsOfTypes(
{ keys: plane.pathIds, types: ['path'] },
artifactGraph
)
return {
type: 'plane',
paths: Array.from(paths.values()),
codeRef: plane.codeRef,
}
}
export function expandPath(
path: PathArtifact,
artifactGraph: ArtifactGraph
): PathArtifactRich | Error {
const segs = getArtifactsOfTypes(
{ keys: path.segIds, types: ['segment'] },
artifactGraph
)
const extrusion = getArtifactOfTypes(
{
key: path.extrusionId,
types: ['extrusion'],
},
artifactGraph
)
const plane = getArtifactOfTypes(
{ key: path.planeId, types: ['plane', 'wall'] },
artifactGraph
)
if (err(extrusion)) return extrusion
if (err(plane)) return plane
return {
type: 'path',
segments: Array.from(segs.values()),
extrusion,
plane,
codeRef: path.codeRef,
}
}
export function expandExtrusion(
extrusion: ExtrusionArtifact,
artifactGraph: ArtifactGraph
): ExtrusionArtifactRich | Error {
const surfs = getArtifactsOfTypes(
{ keys: extrusion.surfaceIds, types: ['wall', 'cap'] },
artifactGraph
)
const edges = getArtifactsOfTypes(
{ keys: extrusion.edgeIds, types: ['extrudeEdge'] },
artifactGraph
)
const path = getArtifactOfTypes(
{ key: extrusion.pathId, types: ['path'] },
artifactGraph
)
if (err(path)) return path
return {
type: 'extrusion',
surfaces: Array.from(surfs.values()),
edges: Array.from(edges.values()),
path,
codeRef: extrusion.codeRef,
}
}
export function expandSegment(
segment: SegmentArtifact,
artifactGraph: ArtifactGraph
): SegmentArtifactRich | Error {
const path = getArtifactOfTypes(
{ key: segment.pathId, types: ['path'] },
artifactGraph
)
const surf = getArtifactOfTypes(
{ key: segment.surfaceId, types: ['wall'] },
artifactGraph
)
const edges = getArtifactsOfTypes(
{ keys: segment.edgeIds, types: ['extrudeEdge'] },
artifactGraph
)
const edgeCut = segment.edgeCutId
? getArtifactOfTypes(
{ key: segment.edgeCutId, types: ['edgeCut'] },
artifactGraph
)
: undefined
if (err(path)) return path
if (err(surf)) return surf
if (err(edgeCut)) return edgeCut
return {
type: 'segment',
path,
surf,
edges: Array.from(edges.values()),
edgeCut: edgeCut,
codeRef: segment.codeRef,
}
}
export function getCapCodeRef(
cap: CapArtifact,
artifactGraph: ArtifactGraph
): CommonCommandProperties | Error {
const extrusion = getArtifactOfTypes(
{ key: cap.extrusionId, types: ['extrusion'] },
artifactGraph
)
if (err(extrusion)) return extrusion
const path = getArtifactOfTypes(
{ key: extrusion.pathId, types: ['path'] },
artifactGraph
)
if (err(path)) return path
return path.codeRef
}
export function getSolid2dCodeRef(
solid2D: solid2D,
artifactGraph: ArtifactGraph
): CommonCommandProperties | Error {
const path = getArtifactOfTypes(
{ key: solid2D.pathId, types: ['path'] },
artifactGraph
)
if (err(path)) return path
return path.codeRef
}
export function getWallCodeRef(
wall: WallArtifact,
artifactGraph: ArtifactGraph
): CommonCommandProperties | Error {
const seg = getArtifactOfTypes(
{ key: wall.segId, types: ['segment'] },
artifactGraph
)
if (err(seg)) return seg
return seg.codeRef
}
export function getExtrusionFromSuspectedExtrudeSurface(
id: string,
artifactGraph: ArtifactGraph
): ExtrusionArtifact | Error {
const artifact = getArtifactOfTypes(
{ key: id, types: ['wall', 'cap'] },
artifactGraph
)
if (err(artifact)) return artifact
return getArtifactOfTypes(
{ key: artifact.extrusionId, types: ['extrusion'] },
artifactGraph
)
}
export function getExtrusionFromSuspectedPath(
id: string,
artifactGraph: ArtifactGraph
): ExtrusionArtifact | Error {
const path = getArtifactOfTypes({ key: id, types: ['path'] }, artifactGraph)
if (err(path)) return path
return getArtifactOfTypes(
{ key: path.extrusionId, types: ['extrusion'] },
artifactGraph
)
}

View File

@ -1,272 +0,0 @@
import { PathToNode, Program, SourceRange } from 'lang/wasm'
import { Models } from '@kittycad/lib'
import { getNodePathFromSourceRange } from 'lang/queryAst'
interface CommonCommandProperties {
range: SourceRange
pathToNode: PathToNode
}
interface ExtrudeArtifact extends CommonCommandProperties {
type: 'extrude'
pathId: string
}
export interface StartPathArtifact extends CommonCommandProperties {
type: 'startPath'
extrusionIds: string[]
}
export interface SegmentArtifact extends CommonCommandProperties {
type: 'segment'
subType: 'segment' | 'closeSegment'
pathId: string
}
interface ExtrudeCapArtifact extends CommonCommandProperties {
type: 'extrudeCap'
cap: 'start' | 'end'
pathId: string
}
interface ExtrudeWallArtifact extends CommonCommandProperties {
type: 'extrudeWall'
pathId: string
}
interface PatternInstance extends CommonCommandProperties {
type: 'patternInstance'
}
export type ArtifactMapCommand =
| ExtrudeArtifact
| StartPathArtifact
| ExtrudeCapArtifact
| ExtrudeWallArtifact
| SegmentArtifact
| PatternInstance
export type EngineCommand = Models['WebSocketRequest_type']
type OkWebSocketResponseData = Models['OkWebSocketResponseData_type']
/**
* The ArtifactMap is a client-side representation of the artifacts that
* have been sent to the server-side engine. It is used to keep track of
* the state of each command, and to resolve the promise that was returned.
* It is also used to keep track of what entities are in the engine scene,
* so that we can associate IDs returned from the engine with the
* lines of KCL code that generated them.
*/
export interface ArtifactMap {
[commandId: string]: ArtifactMapCommand
}
export interface ResponseMap {
[commandId: string]: OkWebSocketResponseData
}
export interface OrderedCommand {
command: EngineCommand
range: SourceRange
}
export function createArtifactMap({
orderedCommands,
responseMap,
ast,
}: {
orderedCommands: Array<OrderedCommand>
responseMap: ResponseMap
ast: Program
}): ArtifactMap {
const artifactMap: ArtifactMap = {}
orderedCommands.forEach(({ command, range }) => {
// expect all to be `modeling_cmd_req` as batch commands have
// already been expanded before being added to orderedCommands
if (command.type !== 'modeling_cmd_req') return
const id = command.cmd_id
const response = responseMap[id]
const artifacts = handleIndividualResponse({
id,
pendingMsg: {
command,
range,
},
response,
ast,
prevArtifactMap: artifactMap,
})
artifacts.forEach(({ commandId, artifact }) => {
artifactMap[commandId] = artifact
})
})
return artifactMap
}
function handleIndividualResponse({
id,
pendingMsg,
response,
ast,
prevArtifactMap,
}: {
id: string
pendingMsg: {
command: EngineCommand
range: SourceRange
}
response: OkWebSocketResponseData
ast: Program
prevArtifactMap: ArtifactMap
}): Array<{
commandId: string
artifact: ArtifactMapCommand
}> {
const command = pendingMsg
if (command?.command?.type !== 'modeling_cmd_req') return []
if (response?.type !== 'modeling') return []
const command2 = command.command.cmd
const range = command.range
const pathToNode = getNodePathFromSourceRange(ast, range)
const modelingResponse = response.data.modeling_response
const artifacts: Array<{
commandId: string
artifact: ArtifactMapCommand
}> = []
if (command) {
if (
command2.type !== 'extrude' &&
command2.type !== 'extend_path' &&
command2.type !== 'solid3d_get_extrusion_face_info' &&
command2.type !== 'start_path' &&
command2.type !== 'close_path'
) {
}
if (command2.type === 'extrude') {
artifacts.push({
commandId: id,
artifact: {
type: 'extrude',
range,
pathToNode,
pathId: command2.target,
},
})
const targetArtifact = { ...prevArtifactMap[command2.target] }
if (targetArtifact?.type === 'startPath') {
artifacts.push({
commandId: command2.target,
artifact: {
...targetArtifact,
type: 'startPath',
range: targetArtifact.range,
pathToNode: targetArtifact.pathToNode,
extrusionIds: targetArtifact?.extrusionIds
? [...targetArtifact?.extrusionIds, id]
: [id],
},
})
}
}
if (command2.type === 'extend_path') {
artifacts.push({
commandId: id,
artifact: {
type: 'segment',
subType: 'segment',
range,
pathToNode,
pathId: command2.path,
},
})
}
if (command2.type === 'close_path')
artifacts.push({
commandId: id,
artifact: {
type: 'segment',
subType: 'closeSegment',
range,
pathToNode,
pathId: command2.path_id,
},
})
if (command2.type === 'start_path') {
artifacts.push({
commandId: id,
artifact: {
type: 'startPath',
range,
pathToNode,
extrusionIds: [],
},
})
}
if (
(command2.type === 'entity_linear_pattern' &&
modelingResponse.type === 'entity_linear_pattern') ||
(command2.type === 'entity_circular_pattern' &&
modelingResponse.type === 'entity_circular_pattern')
) {
// TODO this is not working perfectly, maybe it's like a selection filter issue
// but when clicking on a instance it does put the cursor somewhat relevant but
// edges and what not do not highlight the correct segment.
const entities = modelingResponse.data.entity_ids
entities?.forEach((entity: string) => {
artifacts.push({
commandId: entity,
artifact: {
range: range,
pathToNode,
type: 'patternInstance',
},
})
})
}
if (
command2.type === 'solid3d_get_extrusion_face_info' &&
modelingResponse.type === 'solid3d_get_extrusion_face_info'
) {
const edgeArtifact = prevArtifactMap[command2.edge_id]
const parent =
edgeArtifact?.type === 'segment'
? prevArtifactMap[edgeArtifact.pathId]
: null
modelingResponse.data.faces.forEach((face) => {
if (
face.cap !== 'none' &&
face.face_id &&
parent?.type === 'startPath'
) {
artifacts.push({
commandId: face.face_id,
artifact: {
type: 'extrudeCap',
cap: face.cap === 'bottom' ? 'start' : 'end',
range: parent.range,
pathToNode: parent.pathToNode,
pathId:
edgeArtifact?.type === 'segment' ? edgeArtifact.pathId : '',
},
})
}
const curveArtifact = prevArtifactMap[face?.curve_id || '']
if (curveArtifact?.type === 'segment' && face?.face_id) {
artifacts.push({
commandId: face.face_id,
artifact: {
type: 'extrudeWall',
range: curveArtifact.range,
pathToNode: curveArtifact.pathToNode,
pathId: curveArtifact.pathId,
},
})
}
})
}
}
return artifacts
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 222 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 374 KiB

View File

@ -6,13 +6,15 @@ import { deferExecution, uuidv4 } from 'lib/utils'
import { Themes, getThemeColorForEngine, getOppositeTheme } from 'lib/theme' import { Themes, getThemeColorForEngine, getOppositeTheme } from 'lib/theme'
import { DefaultPlanes } from 'wasm-lib/kcl/bindings/DefaultPlanes' import { DefaultPlanes } from 'wasm-lib/kcl/bindings/DefaultPlanes'
import { import {
ArtifactMap, ArtifactGraph,
EngineCommand, EngineCommand,
OrderedCommand, OrderedCommand,
ResponseMap, ResponseMap,
createArtifactMap, createArtifactGraph,
} from 'lang/std/artifactMap' } from 'lang/std/artifactGraph'
import { useModelingContext } from 'hooks/useModelingContext' import { useModelingContext } from 'hooks/useModelingContext'
import { exportMake } from 'lib/exportMake'
import toast from 'react-hot-toast'
// TODO(paultag): This ought to be tweakable. // TODO(paultag): This ought to be tweakable.
const pingIntervalMs = 10000 const pingIntervalMs = 10000
@ -30,11 +32,10 @@ interface NewTrackArgs {
mediaStream: MediaStream mediaStream: MediaStream
} }
/** This looks funny, I know. This is needed because node and the browser export enum ExportIntent {
* disagree as to the type. In a browser it's a number, but in node it's a Save = 'save',
* "Timeout". Make = 'make',
*/ }
type IsomorphicTimeout = ReturnType<typeof setTimeout>
type ClientMetrics = Models['ClientMetrics_type'] type ClientMetrics = Models['ClientMetrics_type']
@ -286,8 +287,6 @@ class EngineConnection extends EventTarget {
) )
} }
private failedConnTimeout: IsomorphicTimeout | null
readonly url: string readonly url: string
private readonly token?: string private readonly token?: string
@ -312,7 +311,6 @@ class EngineConnection extends EventTarget {
this.engineCommandManager = engineCommandManager this.engineCommandManager = engineCommandManager
this.url = url this.url = url
this.token = token this.token = token
this.failedConnTimeout = null
this.pingPongSpan = { ping: undefined, pong: undefined } this.pingPongSpan = { ping: undefined, pong: undefined }
@ -451,9 +449,11 @@ class EngineConnection extends EventTarget {
} }
const createPeerConnection = () => { const createPeerConnection = () => {
this.pc = new RTCPeerConnection({ if (!this.engineCommandManager.disableWebRTC) {
bundlePolicy: 'max-bundle', this.pc = new RTCPeerConnection({
}) bundlePolicy: 'max-bundle',
})
}
// Other parts of the application expect pc to be initialized when firing. // Other parts of the application expect pc to be initialized when firing.
this.dispatchEvent( this.dispatchEvent(
@ -465,7 +465,7 @@ class EngineConnection extends EventTarget {
// Data channels MUST BE specified before SDP offers because requesting // Data channels MUST BE specified before SDP offers because requesting
// them affects what our needs are! // them affects what our needs are!
const DATACHANNEL_NAME_UMC = 'unreliable_modeling_cmds' const DATACHANNEL_NAME_UMC = 'unreliable_modeling_cmds'
this.pc.createDataChannel(DATACHANNEL_NAME_UMC) this.pc?.createDataChannel?.(DATACHANNEL_NAME_UMC)
this.state = { this.state = {
type: EngineConnectionStateType.Connecting, type: EngineConnectionStateType.Connecting,
@ -498,7 +498,7 @@ class EngineConnection extends EventTarget {
}, },
}) })
} }
this.pc.addEventListener('icecandidate', this.onIceCandidate) this.pc?.addEventListener?.('icecandidate', this.onIceCandidate)
this.onIceCandidateError = (_event: Event) => { this.onIceCandidateError = (_event: Event) => {
const event = _event as RTCPeerConnectionIceErrorEvent const event = _event as RTCPeerConnectionIceErrorEvent
@ -506,7 +506,7 @@ class EngineConnection extends EventTarget {
`ICE candidate returned an error: ${event.errorCode}: ${event.errorText} for ${event.url}` `ICE candidate returned an error: ${event.errorCode}: ${event.errorText} for ${event.url}`
) )
} }
this.pc.addEventListener('icecandidateerror', this.onIceCandidateError) this.pc?.addEventListener?.('icecandidateerror', this.onIceCandidateError)
// https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection/connectionstatechange_event // https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection/connectionstatechange_event
// Event type: generic Event type... // Event type: generic Event type...
@ -540,7 +540,7 @@ class EngineConnection extends EventTarget {
break break
} }
} }
this.pc.addEventListener( this.pc?.addEventListener?.(
'connectionstatechange', 'connectionstatechange',
this.onConnectionStateChange this.onConnectionStateChange
) )
@ -630,7 +630,7 @@ class EngineConnection extends EventTarget {
this.mediaStream = mediaStream this.mediaStream = mediaStream
} }
this.pc.addEventListener('track', this.onTrack) this.pc?.addEventListener?.('track', this.onTrack)
this.onDataChannel = (event) => { this.onDataChannel = (event) => {
this.unreliableDataChannel = event.channel this.unreliableDataChannel = event.channel
@ -721,7 +721,7 @@ class EngineConnection extends EventTarget {
this.onDataChannelMessage this.onDataChannelMessage
) )
} }
this.pc.addEventListener('datachannel', this.onDataChannel) this.pc?.addEventListener?.('datachannel', this.onDataChannel)
} }
const createWebSocketConnection = () => { const createWebSocketConnection = () => {
@ -756,6 +756,11 @@ class EngineConnection extends EventTarget {
// Send an initial ping // Send an initial ping
this.send({ type: 'ping' }) this.send({ type: 'ping' })
this.pingPongSpan.ping = new Date() this.pingPongSpan.ping = new Date()
if (this.engineCommandManager.disableWebRTC) {
this.engineCommandManager
.initPlanes()
.then(() => this.engineCommandManager.resolveReady())
}
} }
this.websocket.addEventListener('open', this.onWebSocketOpen) this.websocket.addEventListener('open', this.onWebSocketOpen)
@ -803,11 +808,20 @@ class EngineConnection extends EventTarget {
.join('\n') .join('\n')
if (message.request_id) { if (message.request_id) {
const artifactThatFailed = const artifactThatFailed =
this.engineCommandManager.artifactMap[message.request_id] this.engineCommandManager.artifactGraph.get(message.request_id)
console.error( console.error(
`Error in response to request ${message.request_id}:\n${errorsString} `Error in response to request ${message.request_id}:\n${errorsString}
failed cmd type was ${artifactThatFailed?.type}` failed cmd type was ${artifactThatFailed?.type}`
) )
// Check if this was a pending export command.
if (
this.engineCommandManager.pendingExport?.commandId ===
message.request_id
) {
// Reject the promise with the error.
this.engineCommandManager.pendingExport.reject(errorsString)
this.engineCommandManager.pendingExport = undefined
}
} else { } else {
console.error(`Error from server:\n${errorsString}`) console.error(`Error from server:\n${errorsString}`)
} }
@ -1089,8 +1103,10 @@ export enum EngineCommandManagerEvents {
* of those commands. It also sets up and tears down the connection to the Engine * of those commands. It also sets up and tears down the connection to the Engine
* through the {@link EngineConnection} class. * through the {@link EngineConnection} class.
* *
* It also maintains an {@link artifactMap} that keeps track of the state of each * As commands are send their state is tracked in {@link pendingCommands} and clear as soon as we receive a response.
* command, and the artifacts that have been generated by those commands. *
* Also all commands that are sent are kept track of in {@link orderedCommands} and their responses are kept in {@link responseMap}
* Both of these data structures are used to process the {@link artifactGraph}.
*/ */
interface PendingMessage { interface PendingMessage {
@ -1103,17 +1119,10 @@ interface PendingMessage {
} }
export class EngineCommandManager extends EventTarget { export class EngineCommandManager extends EventTarget {
/** /**
* The artifactMap is a client-side representation of the commands that have been sent * The artifactGraph is a client-side representation of the commands that have been sent
* to the server-side geometry engine, and the state of their resulting artifacts. * see: src/lang/std/artifactGraph-README.md for a full explanation.
*
* It is used to keep track of the state of each command, which can fail, succeed, or be
* pending.
*
* It is also used to keep track of our client's understanding of what is in the engine scene
* so that we can map to and from KCL code. Each artifact maintains a source range to the part
* of the KCL code that generated it.
*/ */
artifactMap: ArtifactMap = {} artifactGraph: ArtifactGraph = new Map()
/** /**
* The pendingCommands object is a map of the commands that have been sent to the engine that are still waiting on a reply * The pendingCommands object is a map of the commands that have been sent to the engine that are still waiting on a reply
*/ */
@ -1122,21 +1131,14 @@ export class EngineCommandManager extends EventTarget {
} = {} } = {}
/** /**
* The orderedCommands array of all the the commands sent to the engine, un-folded from batches, and made into one long * The orderedCommands array of all the the commands sent to the engine, un-folded from batches, and made into one long
* list of the individual commands, this is used to process all the commands into the artifactMap * list of the individual commands, this is used to process all the commands into the artifactGraph
*/ */
orderedCommands: Array<OrderedCommand> = [] orderedCommands: Array<OrderedCommand> = []
/** /**
* A map of the responses to the @this.orderedCommands, when processing the commands into the artifactMap, this response map allow * A map of the responses to the {@link orderedCommands}, when processing the commands into the artifactGraph, this response map allow
* us to look up the response by command id * us to look up the response by command id
*/ */
responseMap: ResponseMap = {} responseMap: ResponseMap = {}
/**
* The client-side representation of the scene command artifacts that have been sent to the server;
* that is, the *non-modeling* commands and corresponding artifacts.
*
* For modeling commands, see {@link artifactMap}.
*/
sceneCommandArtifacts: ArtifactMap = {}
/** /**
* A counter that is incremented with each command sent over the *unreliable* channel to the engine. * A counter that is incremented with each command sent over the *unreliable* channel to the engine.
* This is compared to the latest received {@link inSequence} number to determine if we should ignore * This is compared to the latest received {@link inSequence} number to determine if we should ignore
@ -1156,9 +1158,16 @@ export class EngineCommandManager extends EventTarget {
pendingExport?: { pendingExport?: {
resolve: (a: null) => void resolve: (a: null) => void
reject: (reason: any) => void reject: (reason: any) => void
commandId: string
} }
/**
* Export intent traxcks the intent of the export. If it is null there is no
* export in progress. Otherwise it is an enum value of the intent.
* Another export cannot be started if one is already in progress.
*/
private _exportIntent: ExportIntent | null = null
_commandLogCallBack: (command: CommandLog[]) => void = () => {} _commandLogCallBack: (command: CommandLog[]) => void = () => {}
private resolveReady = () => {} resolveReady = () => {}
/** Folks should realize that wait for ready does not get called _everytime_ /** Folks should realize that wait for ready does not get called _everytime_
* the connection resets and restarts, it only gets called the first time. * the connection resets and restarts, it only gets called the first time.
* *
@ -1205,11 +1214,20 @@ export class EngineCommandManager extends EventTarget {
private onEngineConnectionNewTrack = ({ private onEngineConnectionNewTrack = ({
detail, detail,
}: CustomEvent<NewTrackArgs>) => {} }: CustomEvent<NewTrackArgs>) => {}
disableWebRTC = false
modelingSend: ReturnType<typeof useModelingContext>['send'] = modelingSend: ReturnType<typeof useModelingContext>['send'] =
(() => {}) as any (() => {}) as any
set exportIntent(intent: ExportIntent | null) {
this._exportIntent = intent
}
get exportIntent() {
return this._exportIntent
}
start({ start({
restart, disableWebRTC = false,
setMediaStream, setMediaStream,
setIsStreamReady, setIsStreamReady,
width, width,
@ -1225,7 +1243,7 @@ export class EngineCommandManager extends EventTarget {
showScaleGrid: false, showScaleGrid: false,
}, },
}: { }: {
restart?: boolean disableWebRTC?: boolean
setMediaStream: (stream: MediaStream) => void setMediaStream: (stream: MediaStream) => void
setIsStreamReady: (isStreamReady: boolean) => void setIsStreamReady: (isStreamReady: boolean) => void
width: number width: number
@ -1242,6 +1260,7 @@ export class EngineCommandManager extends EventTarget {
} }
}) { }) {
this.makeDefaultPlanes = makeDefaultPlanes this.makeDefaultPlanes = makeDefaultPlanes
this.disableWebRTC = disableWebRTC
this.modifyGrid = modifyGrid this.modifyGrid = modifyGrid
if (width === 0 || height === 0) { if (width === 0 || height === 0) {
return return
@ -1384,9 +1403,36 @@ export class EngineCommandManager extends EventTarget {
// because in all other cases we send JSON strings. But in the case of // because in all other cases we send JSON strings. But in the case of
// export we send a binary blob. // export we send a binary blob.
// Pass this to our export function. // Pass this to our export function.
exportSave(event.data).then(() => { if (this.exportIntent === null) {
this.pendingExport?.resolve(null) toast.error(
}, this.pendingExport?.reject) 'Export intent was not set, but export data was received'
)
console.error(
'Export intent was not set, but export data was received'
)
return
}
switch (this.exportIntent) {
case ExportIntent.Save: {
exportSave(event.data).then(() => {
this.pendingExport?.resolve(null)
}, this.pendingExport?.reject)
break
}
case ExportIntent.Make: {
exportMake(event.data).then((result) => {
if (result) {
this.pendingExport?.resolve(null)
} else {
this.pendingExport?.reject('Failed to make export')
}
}, this.pendingExport?.reject)
break
}
}
// Set the export intent back to null.
this.exportIntent = null
return return
} }
@ -1690,7 +1736,13 @@ export class EngineCommandManager extends EventTarget {
return Promise.resolve(null) return Promise.resolve(null)
} else if (cmd.type === 'export') { } else if (cmd.type === 'export') {
const promise = new Promise<null>((resolve, reject) => { const promise = new Promise<null>((resolve, reject) => {
this.pendingExport = { resolve, reject } this.pendingExport = {
resolve,
reject: () => {
this.exportIntent = null
},
commandId: command.cmd_id,
}
}) })
this.engineConnection?.send(command) this.engineConnection?.send(command)
return promise return promise
@ -1720,15 +1772,11 @@ export class EngineCommandManager extends EventTarget {
if (this.engineConnection === undefined) { if (this.engineConnection === undefined) {
return Promise.resolve() return Promise.resolve()
} }
if (!this.engineConnection?.isReady()) { if (!this.engineConnection?.isReady() && !this.disableWebRTC)
return Promise.resolve() return Promise.resolve()
} if (id === undefined) return Promise.reject(new Error('id is undefined'))
if (id === undefined) { if (rangeStr === undefined)
return Promise.reject(new Error('id is undefined'))
}
if (rangeStr === undefined) {
return Promise.reject(new Error('rangeStr is undefined')) return Promise.reject(new Error('rangeStr is undefined'))
}
if (commandStr === undefined) { if (commandStr === undefined) {
return Promise.reject(new Error('commandStr is undefined')) return Promise.reject(new Error('commandStr is undefined'))
} }
@ -1800,18 +1848,18 @@ export class EngineCommandManager extends EventTarget {
*/ */
async waitForAllCommands() { async waitForAllCommands() {
await Promise.all(Object.values(this.pendingCommands).map((a) => a.promise)) await Promise.all(Object.values(this.pendingCommands).map((a) => a.promise))
this.artifactMap = createArtifactMap({ this.artifactGraph = createArtifactGraph({
orderedCommands: this.orderedCommands, orderedCommands: this.orderedCommands,
responseMap: this.responseMap, responseMap: this.responseMap,
ast: this.getAst(), ast: this.getAst(),
}) })
if (Object.values(this.artifactMap).length) { if (this.artifactGraph.size) {
this.deferredArtifactEmptied(null) this.deferredArtifactEmptied(null)
} else { } else {
this.deferredArtifactPopulated(null) this.deferredArtifactPopulated(null)
} }
} }
private async initPlanes() { async initPlanes() {
if (this.planesInitialized()) return if (this.planesInitialized()) return
const planes = await this.makeDefaultPlanes() const planes = await this.makeDefaultPlanes()
this.defaultPlanes = planes this.defaultPlanes = planes
@ -1851,7 +1899,7 @@ export class EngineCommandManager extends EventTarget {
range: SourceRange, range: SourceRange,
commandTypeToTarget: string commandTypeToTarget: string
): string | undefined { ): string | undefined {
const values = Object.entries(this.artifactMap) const values = Object.entries(this.artifactGraph)
for (const [id, data] of values) { for (const [id, data] of values) {
// // Our range selection seems to just select the cursor position, so either // // Our range selection seems to just select the cursor position, so either
// // of these can be right... // // of these can be right...

View File

@ -1,12 +1,7 @@
import { Selections } from 'lib/selections' import { Selections } from 'lib/selections'
import { Program, PathToNode } from './wasm' import { Program, PathToNode } from './wasm'
import { getNodeFromPath } from './queryAst' import { getNodeFromPath } from './queryAst'
import { import { ArtifactGraph, filterArtifacts } from 'lang/std/artifactGraph'
ArtifactMap,
ArtifactMapCommand,
SegmentArtifact,
StartPathArtifact,
} from 'lang/std/artifactMap'
import { isOverlap } from 'lib/utils' import { isOverlap } from 'lib/utils'
import { err } from 'lib/trap' import { err } from 'lib/trap'
@ -51,25 +46,29 @@ export function updatePathToNodeFromMap(
} }
export function isCursorInSketchCommandRange( export function isCursorInSketchCommandRange(
artifactMap: ArtifactMap, artifactGraph: ArtifactGraph,
selectionRanges: Selections selectionRanges: Selections
): string | false { ): string | false {
const overlappingEntries = Object.entries(artifactMap).filter( const overlappingEntries = filterArtifacts(
([id, artifact]: [string, ArtifactMapCommand]) => {
selectionRanges.codeBasedSelections.some( types: ['segment', 'path'],
(selection) => predicate: (artifact) => {
Array.isArray(selection?.range) && return selectionRanges.codeBasedSelections.some(
Array.isArray(artifact?.range) && (selection) =>
isOverlap(selection.range, artifact.range) && Array.isArray(selection?.range) &&
(artifact.type === 'startPath' || artifact.type === 'segment') Array.isArray(artifact?.codeRef?.range) &&
) isOverlap(selection.range, artifact.codeRef.range)
) as [string, StartPathArtifact | SegmentArtifact][] )
const secondEntry = overlappingEntries?.[0]?.[1] },
const parentId = secondEntry?.type === 'segment' ? secondEntry.pathId : false },
let result = parentId artifactGraph
)
const firstEntry = [...overlappingEntries.values()]?.[0]
const parentId = firstEntry?.type === 'segment' ? firstEntry.pathId : false
return parentId
? parentId ? parentId
: overlappingEntries.find( : [...overlappingEntries].find(
([, artifact]) => artifact.type === 'startPath' ([, artifact]) => artifact.type === 'path'
)?.[0] || false )?.[0] || false
return result
} }

View File

@ -1,7 +1,9 @@
import { Models } from '@kittycad/lib' import { Models } from '@kittycad/lib'
import { StateMachineCommandSetConfig, KclCommandValue } from 'lib/commandTypes' import { StateMachineCommandSetConfig, KclCommandValue } from 'lib/commandTypes'
import { KCL_DEFAULT_LENGTH } from 'lib/constants' import { KCL_DEFAULT_LENGTH } from 'lib/constants'
import { components } from 'lib/machine-api'
import { Selections } from 'lib/selections' import { Selections } from 'lib/selections'
import { machineManager } from 'lib/machineManager'
import { modelingMachine, SketchTool } from 'machines/modelingMachine' import { modelingMachine, SketchTool } from 'machines/modelingMachine'
type OutputFormat = Models['OutputFormat_type'] type OutputFormat = Models['OutputFormat_type']
@ -22,6 +24,9 @@ export type ModelingCommandSchema = {
type: OutputTypeKey type: OutputTypeKey
storage?: StorageUnion storage?: StorageUnion
} }
Make: {
machine: components['schemas']['Machine']
}
Extrude: { Extrude: {
selection: Selections // & { type: 'face' } would be cool to lock that down selection: Selections // & { type: 'face' } would be cool to lock that down
// result: (typeof EXTRUSION_RESULTS)[number] // result: (typeof EXTRUSION_RESULTS)[number]
@ -160,6 +165,36 @@ export const modelingMachineCommandConfig: StateMachineCommandSetConfig<
}, },
}, },
}, },
Make: {
hide: 'web',
displayName: 'Make',
description:
'Export the current part and send to a 3D printer on the network.',
icon: 'printer3d',
needsReview: true,
args: {
machine: {
inputType: 'options',
required: true,
valueSummary: (machine: components['schemas']['Machine']) =>
machine.model || machine.manufacturer,
options: () => {
return Object.entries(machineManager.machines).map(
([hostname, machine]) => ({
name: `${machine.model || machine.manufacturer}, ${hostname}`,
isCurrent: false,
value: machine as components['schemas']['Machine'],
})
)
},
defaultValue: () => {
return Object.values(
machineManager.machines
)[0] as components['schemas']['Machine']
},
},
},
},
Extrude: { Extrude: {
description: 'Pull a sketch into 3D along its normal or perpendicular.', description: 'Pull a sketch into 3D along its normal or perpendicular.',
icon: 'extrude', icon: 'extrude',
@ -202,7 +237,8 @@ export const modelingMachineCommandConfig: StateMachineCommandSetConfig<
'default', 'default',
'line-end', 'line-end',
'line-mid', 'line-mid',
'extrude-wall', // to fix: accespts only this selection type 'extrude-wall', // to fix: accepts only this selection type
'solid2D',
'start-cap', 'start-cap',
'end-cap', 'end-cap',
'point', 'point',

View File

@ -111,6 +111,10 @@ export type CommandArgumentConfig<
machineContext?: C machineContext?: C
) => boolean) ) => boolean)
skip?: boolean skip?: boolean
/** For showing a summary display of the current value, such as in
* the command bar's header
*/
valueSummary?: (value: OutputType) => string
} & ( } & (
| { | {
inputType: 'options' inputType: 'options'
@ -172,6 +176,10 @@ export type CommandArgument<
) => boolean) ) => boolean)
skip?: boolean skip?: boolean
machineActor: InterpreterFrom<T> machineActor: InterpreterFrom<T>
/** For showing a summary display of the current value, such as in
* the command bar's header
*/
valueSummary?: (value: OutputType) => string
} & ( } & (
| { | {
inputType: Extract<CommandInputType, 'options'> inputType: Extract<CommandInputType, 'options'>

View File

@ -192,14 +192,14 @@ export class CoreDumpManager {
// engine_command_manager // engine_command_manager
debugLog('CoreDump: engineCommandManager', this.engineCommandManager) debugLog('CoreDump: engineCommandManager', this.engineCommandManager)
// artifact map - this.engineCommandManager.artifactMap // artifact map - this.engineCommandManager.artifactGraph
if (this.engineCommandManager?.artifactMap) { if (this.engineCommandManager?.artifactGraph) {
debugLog( debugLog(
'CoreDump: Engine Command Manager artifact map', 'CoreDump: Engine Command Manager artifact map',
this.engineCommandManager.artifactMap this.engineCommandManager.artifactGraph
) )
clientState.engine_command_manager.artifact_map = structuredClone( clientState.engine_command_manager.artifact_map = structuredClone(
this.engineCommandManager.artifactMap this.engineCommandManager.artifactGraph
) )
} }
@ -255,16 +255,6 @@ export class CoreDumpManager {
this.engineCommandManager.outSequence this.engineCommandManager.outSequence
} }
// scene command artifacts - this.engineCommandManager.sceneCommandArtifacts
if (this.engineCommandManager?.sceneCommandArtifacts) {
debugLog(
'CoreDump: Engine Command Manager scene command artifacts',
this.engineCommandManager.sceneCommandArtifacts
)
clientState.engine_command_manager.scene_command_artifacts =
structuredClone(this.engineCommandManager.sceneCommandArtifacts)
}
// KCL Manager - globalThis?.window?.kclManager // KCL Manager - globalThis?.window?.kclManager
const kclManager = (globalThis?.window as any)?.kclManager const kclManager = (globalThis?.window as any)?.kclManager
debugLog('CoreDump: kclManager', kclManager) debugLog('CoreDump: kclManager', kclManager)

View File

@ -52,17 +52,22 @@ export function createMachineCommand<
return null return null
} else if (commandConfig instanceof Array) { } else if (commandConfig instanceof Array) {
return commandConfig return commandConfig
.map((config) => .map((config) => {
createMachineCommand({ const recursiveCommandBarConfig: Partial<
StateMachineCommandSetConfig<T, S>
> = {
[type]: config,
}
return createMachineCommand({
groupId, groupId,
type, type,
state, state,
send, send,
actor, actor,
commandBarConfig: { [type]: config }, commandBarConfig: recursiveCommandBarConfig,
onCancel, onCancel,
}) })
) })
.filter((c) => c !== null) as Command<T, typeof type, S[typeof type]>[] .filter((c) => c !== null) as Command<T, typeof type, S[typeof type]>[]
} }
@ -145,6 +150,7 @@ export function buildCommandArgument<
required: arg.required, required: arg.required,
skip: arg.skip, skip: arg.skip,
machineActor, machineActor,
valueSummary: arg.valueSummary,
} satisfies Omit<CommandArgument<O, T>, 'inputType'> } satisfies Omit<CommandArgument<O, T>, 'inputType'>
if (arg.inputType === 'options') { if (arg.inputType === 'options') {

74
src/lib/exportMake.ts Normal file
View File

@ -0,0 +1,74 @@
import { deserialize_files } from 'wasm-lib/pkg/wasm_lib'
import { machineManager } from './machineManager'
import toast from 'react-hot-toast'
import { components } from './machine-api'
import ModelingAppFile from './modelingAppFile'
// Make files locally from an export call.
export async function exportMake(data: ArrayBuffer): Promise<Response | null> {
if (machineManager.machineCount() === 0) {
console.error('No machines available')
toast.error('No machines available')
return null
}
const machineApiIp = machineManager.machineApiIp
if (!machineApiIp) {
console.error('No machine api ip available')
toast.error('No machine api ip available')
return null
}
const currentMachine = machineManager.currentMachine
if (!currentMachine) {
console.error('No current machine available')
toast.error('No current machine available')
return null
}
let machineId = null
if ('id' in currentMachine) {
machineId = currentMachine.id
} else if ('hostname' in currentMachine && currentMachine.hostname) {
machineId = currentMachine.hostname
} else if ('ip' in currentMachine && currentMachine.ip) {
machineId = currentMachine.ip
}
if (!machineId) {
console.error('No machine id available', currentMachine)
toast.error('No machine id available')
return null
}
const params: components['schemas']['PrintParameters'] = {
machine_id: machineId,
job_name: 'Exported Job', // TODO: make this the project name.
}
try {
console.log('params', params)
const formData = new FormData()
formData.append('params', JSON.stringify(params))
let files: ModelingAppFile[] = deserialize_files(new Uint8Array(data))
let file = files[0]
const fileBlob = new Blob([new Uint8Array(file.contents)], {
type: 'text/plain',
})
formData.append('file', fileBlob, file.name)
console.log('formData', formData)
const response = await fetch('http://' + machineApiIp + '/print', {
mode: 'no-cors',
method: 'POST',
body: formData,
})
console.log('response', response)
return response
} catch (error) {
console.error('Error exporting', error)
toast.error('Error exporting')
return null
}
}

View File

@ -5,11 +5,7 @@ import { save } from '@tauri-apps/plugin-dialog'
import { writeFile } from '@tauri-apps/plugin-fs' import { writeFile } from '@tauri-apps/plugin-fs'
import JSZip from 'jszip' import JSZip from 'jszip'
import ModelingAppFile from './modelingAppFile'
interface ModelingAppFile {
name: string
contents: number[]
}
const save_ = async (file: ModelingAppFile) => { const save_ = async (file: ModelingAppFile) => {
try { try {
@ -51,7 +47,7 @@ const save_ = async (file: ModelingAppFile) => {
} }
} catch (e) { } catch (e) {
// TODO: do something real with the error. // TODO: do something real with the error.
console.log('export error', e) console.error('export error', e)
} }
} }

925
src/lib/machine-api.d.ts vendored Normal file
View File

@ -0,0 +1,925 @@
/**
* This file was auto-generated by openapi-typescript.
* Do not make direct changes to the file.
*/
export interface paths {
'/': {
parameters: {
query?: never
header?: never
path?: never
cookie?: never
}
/** Return the OpenAPI schema in JSON format. */
get: operations['api_get_schema']
put?: never
post?: never
delete?: never
options?: never
head?: never
patch?: never
trace?: never
}
'/machines': {
parameters: {
query?: never
header?: never
path?: never
cookie?: never
}
/** List available machines and their statuses */
get: operations['get_machines']
put?: never
post?: never
delete?: never
options?: never
head?: never
patch?: never
trace?: never
}
'/machines/{id}': {
parameters: {
query?: never
header?: never
path?: never
cookie?: never
}
/** Get the status of a specific machine */
get: operations['get_machine']
put?: never
post?: never
delete?: never
options?: never
head?: never
patch?: never
trace?: never
}
'/ping': {
parameters: {
query?: never
header?: never
path?: never
cookie?: never
}
/** Return pong. */
get: operations['ping']
put?: never
post?: never
delete?: never
options?: never
head?: never
patch?: never
trace?: never
}
'/print': {
parameters: {
query?: never
header?: never
path?: never
cookie?: never
}
get?: never
put?: never
/** Print a given file. File must be a sliceable 3D model. */
post: operations['print_file']
delete?: never
options?: never
head?: never
patch?: never
trace?: never
}
}
export type webhooks = Record<string, never>
export interface components {
schemas: {
/** @description The type of accessory. */
AccessoryType: 'none'
/** @description Error information from a response. */
Error: {
error_code?: string
message: string
request_id: string
}
/** @description An info command. */
Info: {
/** @enum {string} */
command: 'get_version'
/** @description The info module. */
module: components['schemas']['InfoModule'][]
/** @description The reason of the info command. */
reason?: components['schemas']['Reason'] | null
/** @description The result of the info command. */
result: components['schemas']['Result']
/** @description The sequence id. */
sequence_id: components['schemas']['SequenceId']
} & {
[key: string]: unknown
}
/** @description An info module. */
InfoModule: {
/** @description The hardware version. */
hw_ver: string
/** @description The loader version. */
loader_ver?: string | null
/** @description The module name. */
name: string
/** @description The ota version. */
ota_ver?: string | null
/** @description The project name. */
project_name?: string | null
/** @description The serial number. */
sn: string
/** @description The software version. */
sw_ver: string
}
/** @description The mode for the led. */
LedMode: 'on' | 'off' | 'flashing'
/** @description The node for the led. */
LedNode: 'chamber_light' | 'work_light'
/** @description Details for a 3d printer connected over USB. */
Machine:
| {
id: string
manufacturer: string
model: string
port: string
/** @enum {string} */
type: 'UsbPrinter'
}
| {
/** @description The hostname of the printer. */
hostname?: string | null
/**
* Format: ip
* @description The IP address of the printer.
*/
ip: string
/** @description The manufacturer of the printer. */
manufacturer: components['schemas']['NetworkPrinterManufacturer']
/** @description The model of the printer. */
model?: string | null
/**
* Format: uint16
* @description The port of the printer.
*/
port?: number | null
/** @description The serial number of the printer. */
serial?: string | null
/** @enum {string} */
type: 'NetworkPrinter'
}
/** @description A message from a machine. */
Message:
| {
UsbPrinter: components['schemas']['Message2']
}
| {
NetworkPrinter: components['schemas']['Message3']
}
/**
* @description A message from the printer.
* @enum {string}
*/
Message2: 'ok'
/** @description A message from the printer. */
Message3:
| {
Bambu: components['schemas']['Message4']
}
| {
Formlabs: Record<string, never>
}
/** @description A message from/to the printer. */
Message4:
| {
print: components['schemas']['Print']
}
| {
info: components['schemas']['Info']
}
| {
system: components['schemas']['System']
}
| {
json: unknown
}
| {
unknown: string | null
}
/** @description Network printer manufacturer. */
NetworkPrinterManufacturer: 'Bambu' | 'Formlabs'
/** @description A nozzle type. */
NozzleType: 'hardened_steel' | 'stainless_steel'
/** @description The response from the `/ping` endpoint. */
Pong: {
/** @description The pong response. */
message: string
}
/** @description A print command. */
Print:
| ({
/** @enum {string} */
command: 'ams_control'
/** @description The param. */
param?: string | null
/** @description The reason for the message. */
reason: components['schemas']['Reason']
/** @description The result of the command. */
result: components['schemas']['Result']
/** @description The sequence id. */
sequence_id: components['schemas']['SequenceId']
} & {
[key: string]: unknown
})
| ({
/** @description The ams. */
ams?: components['schemas']['PrintAms'] | null
/**
* Format: int64
* @description The ams rfid status.
*/
ams_rfid_status?: number | null
/**
* Format: int64
* @description The ams status.
*/
ams_status?: number | null
/** @description The aux part fan. */
aux_part_fan?: boolean | null
/**
* Format: double
* @description The target bed temperature.
*/
bed_target_temper?: number | null
/**
* Format: double
* @description The bed temperature.
*/
bed_temper?: number | null
/** @description The big fan 1 speed. */
big_fan1_speed?: string | null
/** @description The big fan 2 speed. */
big_fan2_speed?: string | null
/**
* Format: double
* @description The chamber temperature.
*/
chamber_temper?: number | null
/** @enum {string} */
command: 'push_status'
/** @description The cooling fan speed. */
cooling_fan_speed?: string | null
/**
* Format: int64
* @description The fan gear.
*/
fan_gear?: number | null
/** @description Force upgrade? */
force_upgrade?: boolean | null
/** @description The gcode file. */
gcode_file?: string | null
/** @description The gcode file prepare percent. */
gcode_file_prepare_percent?: string | null
/** @description The gcode state. */
gcode_state?: string | null
/** @description The heatbreak fan speed. */
heatbreak_fan_speed?: string | null
/** @description The hms. */
hms?: unknown[] | null
/**
* Format: int64
* @description The home flag.
*/
home_flag?: number | null
/**
* Format: int64
* @description The hw switch state.
*/
hw_switch_state?: number | null
/** @description The ipcam. */
ipcam?: components['schemas']['PrintIpcam'] | null
/**
* Format: int64
* @description The layer num.
*/
layer_num?: number | null
/** @description The lifecycle. */
lifecycle?: string | null
/** @description The lights report. */
lights_report?: components['schemas']['PrintLightsReport'][] | null
/**
* Format: int64
* @description The percentage of the print completed.
*/
mc_percent?: number | null
/** @description The mc print line number. */
mc_print_line_number?: string | null
/** @description The print stage. */
mc_print_stage?: string | null
/**
* Format: int64
* @description The mc print sub stage.
*/
mc_print_sub_stage?: number | null
/**
* Format: int64
* @description The remaining time of the print.
*/
mc_remaining_time?: number | null
/** @description The mess production state. */
mess_production_state?: string | null
/**
* Format: int64
* @description The message.
*/
msg?: number | null
/** @description The nozzle diameter. */
nozzle_diameter?: string | null
/**
* Format: double
* @description The target nozzle temperature.
*/
nozzle_target_temper?: number | null
/**
* Format: double
* @description The nozzle temperature.
*/
nozzle_temper?: number | null
/** @description The nozzle type. */
nozzle_type?: components['schemas']['NozzleType'] | null
/** @description Online status. */
online?: components['schemas']['PrintOnline'] | null
/**
* Format: int64
* @description The print error.
*/
print_error?: number | null
/** @description The print type. */
print_type?: string | null
/** @description The profile id. */
profile_id?: string | null
/** @description The project id. */
project_id?: string | null
/**
* Format: int64
* @description The queue est.
*/
queue_est?: number | null
/**
* Format: int64
* @description The queue number.
*/
queue_number?: number | null
/**
* Format: int64
* @description The queue sts.
*/
queue_sts?: number | null
/**
* Format: int64
* @description The queue total.
*/
queue_total?: number | null
/** @description The s obj. */
s_obj?: unknown[] | null
/** @description Sdcard? */
sdcard?: boolean | null
/** @description The sequence id. */
sequence_id: components['schemas']['SequenceId']
/**
* Format: int64
* @description The spd lvl.
*/
spd_lvl?: number | null
/**
* Format: int64
* @description The spd mag.
*/
spd_mag?: number | null
/** @description The stg. */
stg?: unknown[] | null
/**
* Format: int64
* @description The stg cur.
*/
stg_cur?: number | null
/** @description The subtask id. */
subtask_id?: string | null
/** @description The subtask name. */
subtask_name?: string | null
/** @description The task id. */
task_id?: string | null
/**
* Format: int64
* @description The total layer num.
*/
total_layer_num?: number | null
/** @description The upgrade state. */
upgrade_state?: components['schemas']['PrintUpgradeState'] | null
/** @description The upload. */
upload?: components['schemas']['PrintUpload'] | null
/** @description The tray. */
vt_tray?: components['schemas']['PrintTray'] | null
/** @description The wifi signal. */
wifi_signal?: string | null
} & {
[key: string]: unknown
})
| ({
/** @enum {string} */
command: 'gcode_line'
/** @description The gcode line. */
param?: string | null
/** @description The reason for the message. */
reason: components['schemas']['Reason']
/** @description The result of the command. */
result: components['schemas']['Result']
/** @description The return code. */
return_code?: string | null
/** @description The sequence id. */
sequence_id: components['schemas']['SequenceId']
/**
* Format: int64
* @description The source.
*/
source?: number | null
} & {
[key: string]: unknown
})
| ({
/** @enum {string} */
command: 'project_file'
/** @description The gcode file. */
gcode_file?: string | null
/** @description The profile id. */
profile_id: string
/** @description The project id. */
project_id: string
/** @description The sequence id. */
sequence_id: components['schemas']['SequenceId']
/** @description The subtask id. */
subtask_id: string
/** @description The subtask name. */
subtask_name: string
/** @description The task id. */
task_id: string
} & {
[key: string]: unknown
})
| ({
/** @enum {string} */
command: 'pause'
/** @description The reason for the message. */
reason: components['schemas']['Reason']
/** @description The result of the command. */
result: components['schemas']['Result']
/** @description The sequence id. */
sequence_id: components['schemas']['SequenceId']
} & {
[key: string]: unknown
})
| ({
/** @enum {string} */
command: 'resume'
/** @description The reason for the message. */
reason: components['schemas']['Reason']
/** @description The result of the command. */
result: components['schemas']['Result']
/** @description The sequence id. */
sequence_id: components['schemas']['SequenceId']
} & {
[key: string]: unknown
})
| ({
/** @enum {string} */
command: 'stop'
/** @description The sequence id. */
sequence_id: components['schemas']['SequenceId']
} & {
[key: string]: unknown
})
| ({
/** @enum {string} */
command: 'extrusion_cali_get'
/** @description The sequence id. */
sequence_id: components['schemas']['SequenceId']
} & {
[key: string]: unknown
})
/** @description The print ams. */
PrintAms: {
/** @description The ams. */
ams?: components['schemas']['PrintAmsData'][] | null
/** @description The ams exist bits. */
ams_exist_bits?: string | null
/** @description The insert flag. */
insert_flag?: boolean | null
/** @description The power on flag. */
power_on_flag?: boolean | null
/** @description The tray exist bits. */
tray_exist_bits?: string | null
/** @description The tray is bbl bits. */
tray_is_bbl_bits?: string | null
/** @description The tray now. */
tray_now?: string | null
/** @description The tray pre. */
tray_pre?: string | null
/** @description The tray read done bits. */
tray_read_done_bits?: string | null
/** @description The tray reading bits. */
tray_reading_bits?: string | null
/** @description The tray tar. */
tray_tar?: string | null
/**
* Format: int64
* @description The version.
*/
version?: number | null
} & {
[key: string]: unknown
}
/** @description The print ams data. */
PrintAmsData: {
/** @description The humidity. */
humidity: string
/** @description The id. */
id: string
/** @description The temperature. */
temp: string
/** @description The tray. */
tray: components['schemas']['PrintTray'][]
} & {
[key: string]: unknown
}
/** @description The print ipcam. */
PrintIpcam: {
/** @description The ipcam dev. */
ipcam_dev?: string | null
/** @description The ipcam record. */
ipcam_record?: string | null
/**
* Format: int64
* @description The mode bits.
*/
mode_bits?: number | null
/** @description The timelapse. */
timelapse?: string | null
} & {
[key: string]: unknown
}
/** @description The response from the `/print` endpoint. */
PrintJobResponse: {
/** @description The job id used for this print. */
job_id: string
/** @description The parameters used for this print. */
parameters: components['schemas']['PrintParameters']
}
/** @description A print lights report. */
PrintLightsReport: {
/** @description The mode. */
mode: components['schemas']['LedMode']
/** @description The node. */
node: components['schemas']['LedNode']
} & {
[key: string]: unknown
}
/** @description The print online. */
PrintOnline: {
/** @description The ahb. */
ahb: boolean
/** @description The rfid. */
rfid?: boolean | null
/**
* Format: int64
* @description The version.
*/
version: number
} & {
[key: string]: unknown
}
/** @description Parameters for printing. */
PrintParameters: {
/** @description The name for the job. */
job_name: string
/** @description The machine id to print to. */
machine_id: string
}
/** @description The print tray. */
PrintTray: {
/** @description The bed temperature. */
bed_temp?: string | null
/** @description The bed temperature type. */
bed_temp_type?: string | null
/** @description The id. */
id: string
/**
* Format: double
* @description The tray k.
*/
k?: number | null
/**
* Format: int64
* @description The tray n.
*/
n?: number | null
/** @description The nozzle temperature max. */
nozzle_temp_max?: string | null
/** @description The nozzle temperature min. */
nozzle_temp_min?: string | null
/**
* Format: int64
* @description The tray remain.
*/
remain?: number | null
/** @description The tag uid. */
tag_uid?: string | null
/** @description The tray color. */
tray_color?: string | null
/** @description The tray diameter. */
tray_diameter?: string | null
/** @description The tray id name. */
tray_id_name?: string | null
/** @description The tray info index. */
tray_info_idx?: string | null
/** @description The tray sub brands. */
tray_sub_brands?: string | null
/** @description The tray temperature. */
tray_temp?: string | null
/** @description The tray time. */
tray_time?: string | null
/** @description The tray type. */
tray_type?: string | null
/** @description The tray uuid. */
tray_uuid?: string | null
/** @description The tray weight. */
tray_weight?: string | null
/** @description The xcam info. */
xcam_info?: string | null
} & {
[key: string]: unknown
}
/** @description A print upgrade state. */
PrintUpgradeState: {
/** @description The consistency request. */
consistency_request?: boolean | null
/**
* Format: int64
* @description The dis state.
*/
dis_state?: number | null
/**
* Format: int64
* @description The error code.
*/
err_code?: number | null
/** @description Force upgrade? */
force_upgrade?: boolean | null
/** @description The message. */
message?: string | null
/** @description The module. */
module?: string | null
/** @description The new version list. */
new_ver_list?: unknown[] | null
/**
* Format: int64
* @description The new version state.
*/
new_version_state?: number | null
/** @description The progress. */
progress?: string | null
/**
* Format: int64
* @description The sequence id.
*/
sequence_id?: number | null
/** @description The status. */
status?: string | null
} & {
[key: string]: unknown
}
/** @description The print upload. */
PrintUpload: {
/** @description The message. */
message: string
/**
* Format: int64
* @description The progress.
*/
progress: number
/** @description The status. */
status: string
} & {
[key: string]: unknown
}
/** @description A reason for a message. */
Reason:
| 'SUCCESS'
| 'FAIL'
| {
UNKNOWN: string
}
/** @description The result of a message. */
Result: 'SUCCESS' | 'FAIL'
/** @description The sequence id type. */
SequenceId: string | number
/** @description A system command. */
System:
| ({
/** @enum {string} */
command: 'ledctrl'
/**
* Format: uint32
* @description The interval time.
*/
interval_time: number
/** @description The LED mode. */
led_mode: components['schemas']['LedMode']
/** @description The LED node. */
led_node: components['schemas']['LedNode']
/**
* Format: uint32
* @description The LED off time.
*/
led_off_time: number
/**
* Format: uint32
* @description The LED on time.
*/
led_on_time: number
/**
* Format: uint32
* @description The loop times.
*/
loop_times: number
/** @description The reason for the message. */
reason?: components['schemas']['Reason'] | null
/** @description The result of the command. */
result: components['schemas']['Result']
/** @description The sequence id. */
sequence_id: components['schemas']['SequenceId']
} & {
[key: string]: unknown
})
| ({
/** @description The accessory type. */
accessory_type: components['schemas']['AccessoryType']
/** @description The aux part fan. */
aux_part_fan: boolean
/** @enum {string} */
command: 'get_accessories'
/**
* Format: double
* @description The nozzle diameter.
*/
nozzle_diameter: number
/** @description The nozzle type. */
nozzle_type: components['schemas']['NozzleType']
/** @description The reason for the message. */
reason?: components['schemas']['Reason'] | null
/** @description The result of the command. */
result: components['schemas']['Result']
/** @description The sequence id. */
sequence_id: components['schemas']['SequenceId']
} & {
[key: string]: unknown
})
}
responses: {
/** @description Error */
Error: {
headers: {
[name: string]: unknown
}
content: {
'application/json': components['schemas']['Error']
}
}
}
parameters: never
requestBodies: never
headers: never
pathItems: never
}
export type $defs = Record<string, never>
export interface operations {
api_get_schema: {
parameters: {
query?: never
header?: never
path?: never
cookie?: never
}
requestBody?: never
responses: {
/** @description successful operation */
200: {
headers: {
[name: string]: unknown
}
content: {
'application/json': unknown
}
}
'4XX': components['responses']['Error']
'5XX': components['responses']['Error']
}
}
get_machines: {
parameters: {
query?: never
header?: never
path?: never
cookie?: never
}
requestBody?: never
responses: {
/** @description successful operation */
200: {
headers: {
[name: string]: unknown
}
content: {
'application/json': {
[key: string]: components['schemas']['Machine']
}
}
}
'4XX': components['responses']['Error']
'5XX': components['responses']['Error']
}
}
get_machine: {
parameters: {
query?: never
header?: never
path: {
/** @description The machine ID. */
id: string
}
cookie?: never
}
requestBody?: never
responses: {
/** @description successful operation */
200: {
headers: {
[name: string]: unknown
}
content: {
'application/json': components['schemas']['Message']
}
}
'4XX': components['responses']['Error']
'5XX': components['responses']['Error']
}
}
ping: {
parameters: {
query?: never
header?: never
path?: never
cookie?: never
}
requestBody?: never
responses: {
/** @description successful operation */
200: {
headers: {
[name: string]: unknown
}
content: {
'application/json': components['schemas']['Pong']
}
}
'4XX': components['responses']['Error']
'5XX': components['responses']['Error']
}
}
print_file: {
parameters: {
query?: never
header?: never
path?: never
cookie?: never
}
requestBody: {
content: {
'multipart/form-data': string
}
}
responses: {
/** @description successful operation */
200: {
headers: {
[name: string]: unknown
}
content: {
'application/json': components['schemas']['PrintJobResponse']
}
}
'4XX': components['responses']['Error']
'5XX': components['responses']['Error']
}
}
}

74
src/lib/machineManager.ts Normal file
View File

@ -0,0 +1,74 @@
import { isTauri } from './isTauri'
import { components } from './machine-api'
import { getMachineApiIp, listMachines } from './tauri'
export class MachineManager {
private _isTauri: boolean = isTauri()
private _machines: {
[key: string]: components['schemas']['Machine']
} = {}
private _machineApiIp: string | null = null
private _currentMachine: components['schemas']['Machine'] | null = null
constructor() {
if (!this._isTauri) {
return
}
this.updateMachines()
}
start() {
if (!this._isTauri) {
return
}
// Start a background job to update the machines every ten seconds.
setInterval(() => {
this.updateMachineApiIp()
this.updateMachines()
}, 10000)
}
get machines(): {
[key: string]: components['schemas']['Machine']
} {
return this._machines
}
machineCount(): number {
return Object.keys(this._machines).length
}
get machineApiIp(): string | null {
return this._machineApiIp
}
get currentMachine(): components['schemas']['Machine'] | null {
return this._currentMachine
}
set currentMachine(machine: components['schemas']['Machine'] | null) {
this._currentMachine = machine
}
private async updateMachines(): Promise<void> {
if (!this._isTauri) {
return
}
this._machines = await listMachines()
console.log('Machines:', this._machines)
}
private async updateMachineApiIp(): Promise<void> {
if (!this._isTauri) {
return
}
this._machineApiIp = await getMachineApiIp()
}
}
export const machineManager = new MachineManager()
machineManager.start()

View File

@ -0,0 +1,4 @@
export default interface ModelingAppFile {
name: string
contents: number[]
}

View File

@ -8,7 +8,6 @@ import {
createTagDeclarator, createTagDeclarator,
createUnaryExpression, createUnaryExpression,
} from 'lang/modifyAst' } from 'lang/modifyAst'
import { roundOff } from './utils'
import { ArrayExpression, CallExpression, PipeExpression } from 'lang/wasm' import { ArrayExpression, CallExpression, PipeExpression } from 'lang/wasm'
/** /**

View File

@ -29,6 +29,13 @@ import { Mesh, Object3D, Object3DEventMap } from 'three'
import { AXIS_GROUP, X_AXIS } from 'clientSideScene/sceneInfra' import { AXIS_GROUP, X_AXIS } from 'clientSideScene/sceneInfra'
import { PathToNodeMap } from 'lang/std/sketchcombos' import { PathToNodeMap } from 'lang/std/sketchcombos'
import { err } from 'lib/trap' import { err } from 'lib/trap'
import {
getArtifactOfTypes,
getArtifactsOfTypes,
getCapCodeRef,
getSolid2dCodeRef,
getWallCodeRef,
} from 'lang/std/artifactGraph'
export const X_AXIS_UUID = 'ad792545-7fd3-482a-a602-a93924e3055b' export const X_AXIS_UUID = 'ad792545-7fd3-482a-a602-a93924e3055b'
export const Y_AXIS_UUID = '680fd157-266f-4b8a-984f-cdf46b8bdf01' export const Y_AXIS_UUID = '680fd157-266f-4b8a-984f-cdf46b8bdf01'
@ -41,6 +48,7 @@ export type Selection = {
| 'line-end' | 'line-end'
| 'line-mid' | 'line-mid'
| 'extrude-wall' | 'extrude-wall'
| 'solid2D'
| 'start-cap' | 'start-cap'
| 'end-cap' | 'end-cap'
| 'point' | 'point'
@ -55,15 +63,12 @@ export type Selections = {
codeBasedSelections: Selection[] codeBasedSelections: Selection[]
} }
export async function getEventForSelectWithPoint( export async function getEventForSelectWithPoint({
{ data,
data, }: Extract<
}: Extract< Models['OkModelingCmdResponse_type'],
Models['OkModelingCmdResponse_type'], { type: 'select_with_point' }
{ type: 'select_with_point' } >): Promise<ModelingMachineEvent | null> {
>,
{ sketchEnginePathId }: { sketchEnginePathId?: string }
): Promise<ModelingMachineEvent | null> {
if (!data?.entity_id) { if (!data?.entity_id) {
return { return {
type: 'Set selection', type: 'Set selection',
@ -79,68 +84,64 @@ export async function getEventForSelectWithPoint(
}, },
} }
} }
let _artifact = engineCommandManager.artifactMap[data.entity_id] let _artifact = engineCommandManager.artifactGraph.get(data.entity_id)
if (!_artifact) { if (!_artifact)
// This logic for getting the parent id is for solid2ds as in edit mode it return the face id
// but we don't recognise that in the artifact map because we store the path id when the path is
// created, the solid2d is implicitly created with the close stdlib function
// there's plans to get the faceId back from the solid2d creation
// https://github.com/KittyCAD/engine/issues/2094
// at which point we can add it to the artifact map and remove this logic
const resp = await engineCommandManager.sendSceneCommand({
type: 'modeling_cmd_req',
cmd: {
type: 'entity_get_parent_id',
entity_id: data.entity_id,
},
cmd_id: uuidv4(),
})
const parentId =
resp?.success &&
resp?.resp?.type === 'modeling' &&
resp?.resp?.data?.modeling_response?.type === 'entity_get_parent_id'
? resp?.resp?.data?.modeling_response?.data?.entity_id
: ''
const parentArtifact = engineCommandManager.artifactMap[parentId]
if (parentArtifact) {
_artifact = parentArtifact
}
}
const sourceRange = _artifact?.range
if (_artifact) {
if (_artifact.type === 'extrudeCap')
return {
type: 'Set selection',
data: {
selectionType: 'singleCodeCursor',
selection: {
range: sourceRange,
type: _artifact?.cap === 'end' ? 'end-cap' : 'start-cap',
},
},
}
if (_artifact.type === 'extrudeWall')
return {
type: 'Set selection',
data: {
selectionType: 'singleCodeCursor',
selection: { range: sourceRange, type: 'extrude-wall' },
},
}
return {
type: 'Set selection',
data: {
selectionType: 'singleCodeCursor',
selection: { range: sourceRange, type: 'default' },
},
}
} else {
// if we don't recognise the entity, select nothing
return { return {
type: 'Set selection', type: 'Set selection',
data: { selectionType: 'singleCodeCursor' }, data: { selectionType: 'singleCodeCursor' },
} }
if (_artifact.type === 'solid2D') {
const codeRef = getSolid2dCodeRef(
_artifact,
engineCommandManager.artifactGraph
)
if (err(codeRef)) return null
return {
type: 'Set selection',
data: {
selectionType: 'singleCodeCursor',
selection: { range: codeRef.range, type: 'solid2D' },
},
}
} }
if (_artifact.type === 'cap') {
const codeRef = getCapCodeRef(_artifact, engineCommandManager.artifactGraph)
if (err(codeRef)) return null
return {
type: 'Set selection',
data: {
selectionType: 'singleCodeCursor',
selection: {
range: codeRef.range,
type: _artifact?.subType === 'end' ? 'end-cap' : 'start-cap',
},
},
}
}
if (_artifact.type === 'wall') {
const codeRef = getWallCodeRef(
_artifact,
engineCommandManager.artifactGraph
)
if (err(codeRef)) return null
return {
type: 'Set selection',
data: {
selectionType: 'singleCodeCursor',
selection: { range: codeRef.range, type: 'extrude-wall' },
},
}
}
if (_artifact.type === 'segment' || _artifact.type === 'path') {
return {
type: 'Set selection',
data: {
selectionType: 'singleCodeCursor',
selection: { range: _artifact.codeRef.range, type: 'default' },
},
}
}
return null
} }
export function getEventForSegmentSelection( export function getEventForSegmentSelection(
@ -347,7 +348,7 @@ function resetAndSetEngineEntitySelectionCmds(
export function isSketchPipe(selectionRanges: Selections) { export function isSketchPipe(selectionRanges: Selections) {
if (!isSingleCursorInPipe(selectionRanges, kclManager.ast)) return false if (!isSingleCursorInPipe(selectionRanges, kclManager.ast)) return false
return isCursorInSketchCommandRange( return isCursorInSketchCommandRange(
engineCommandManager.artifactMap, engineCommandManager.artifactGraph,
selectionRanges selectionRanges
) )
} }
@ -499,11 +500,10 @@ function codeToIdSelections(
return codeBasedSelections return codeBasedSelections
.flatMap(({ type, range, ...rest }): null | SelectionToEngine[] => { .flatMap(({ type, range, ...rest }): null | SelectionToEngine[] => {
// TODO #868: loops over all artifacts will become inefficient at a large scale // TODO #868: loops over all artifacts will become inefficient at a large scale
const entriesWithOverlap = Object.entries( const overlappingEntries = Array.from(engineCommandManager.artifactGraph)
engineCommandManager.artifactMap || {}
)
.map(([id, artifact]) => { .map(([id, artifact]) => {
return artifact.range && isOverlap(artifact.range, range) if (!('codeRef' in artifact)) return false
return isOverlap(artifact.codeRef.range, range)
? { ? {
artifact, artifact,
selection: { type, range, ...rest }, selection: { type, range, ...rest },
@ -512,31 +512,73 @@ function codeToIdSelections(
: false : false
}) })
.filter(Boolean) .filter(Boolean)
/** TODO refactor
* selections in our app is a sourceRange plus some metadata
* The metadata is just a union type string of different types of artifacts or 3d features 'extrude-wall' 'segment' etc
* Because the source range is not enough to figure out what the user selected, so here we're using filtering through all the artifacts
* to find something that matches both the source range and the metadata.
*
* What we should migrate to is just storing what the user selected by what it matched in the artifactGraph it will simply the below a lot.
*
* In the case of a user moving the cursor them, we will still need to figure out what artifact from the graph matches best, but we will just need sane defaults
* and most of the time we can expect the user to be clicking in the 3d scene instead.
*/
let bestCandidate let bestCandidate
entriesWithOverlap.forEach((entry) => { overlappingEntries.forEach((entry) => {
if (!entry) return if (!entry) return
if (type === 'default' && entry.artifact.type === 'segment') { if (type === 'default' && entry.artifact.type === 'segment') {
bestCandidate = entry bestCandidate = entry
return return
} }
if ( if (type === 'solid2D' && entry.artifact.type === 'path') {
type === 'start-cap' && const solid = engineCommandManager.artifactGraph.get(
entry.artifact.type === 'extrudeCap' && entry.artifact.solid2dId || ''
entry?.artifact?.cap === 'start' )
) { if (solid?.type !== 'solid2D') return
bestCandidate = entry bestCandidate = {
artifact: solid,
selection: { type, range, ...rest },
id: entry.artifact.solid2dId,
}
}
if (type === 'extrude-wall' && entry.artifact.type === 'segment') {
const wall = engineCommandManager.artifactGraph.get(
entry.artifact.surfaceId
)
if (wall?.type !== 'wall') return
bestCandidate = {
artifact: wall,
selection: { type, range, ...rest },
id: entry.artifact.surfaceId,
}
return return
} }
if ( if (
type === 'end-cap' && (type === 'end-cap' || type === 'start-cap') &&
entry.artifact.type === 'extrudeCap' && entry.artifact.type === 'path'
entry?.artifact?.cap === 'end'
) { ) {
bestCandidate = entry const extrusion = getArtifactOfTypes(
return {
} key: entry.artifact.extrusionId,
if (type === 'extrude-wall' && entry.artifact.type === 'extrudeWall') { types: ['extrusion'],
bestCandidate = entry },
engineCommandManager.artifactGraph
)
if (err(extrusion)) return
const caps = getArtifactsOfTypes(
{ keys: extrusion.surfaceIds, types: ['cap'] },
engineCommandManager.artifactGraph
)
const cap = [...caps].find(
([_, cap]) => cap.subType === (type === 'end-cap' ? 'end' : 'start')
)
if (!cap) return
bestCandidate = {
artifact: entry.artifact,
selection: { type, range, ...rest },
id: cap[0],
}
return return
} }
}) })

View File

@ -9,6 +9,7 @@ import { FileEntry } from 'wasm-lib/kcl/bindings/FileEntry'
import { ProjectState } from 'wasm-lib/kcl/bindings/ProjectState' import { ProjectState } from 'wasm-lib/kcl/bindings/ProjectState'
import { ProjectRoute } from 'wasm-lib/kcl/bindings/ProjectRoute' import { ProjectRoute } from 'wasm-lib/kcl/bindings/ProjectRoute'
import { isTauri } from './isTauri' import { isTauri } from './isTauri'
import { components } from './machine-api'
// Get the app state from tauri. // Get the app state from tauri.
export async function getState(): Promise<ProjectState | undefined> { export async function getState(): Promise<ProjectState | undefined> {
@ -26,6 +27,19 @@ export async function setState(state: ProjectState | undefined): Promise<void> {
return await invoke('set_state', { state }) return await invoke('set_state', { state })
} }
// List machines on the local network.
export async function listMachines(): Promise<{
[key: string]: components['schemas']['Machine']
}> {
let machines: string = await invoke<string>('list_machines')
return JSON.parse(machines)
}
// Get the machine-api ip address.
export async function getMachineApiIp(): Promise<string | null> {
return await invoke<string | null>('get_machine_api_ip')
}
export async function renameProjectDirectory( export async function renameProjectDirectory(
projectPath: string, projectPath: string,
newName: string newName: string

View File

@ -1,6 +1,6 @@
import { Program, ProgramMemory, _executor, SourceRange } from '../lang/wasm' import { Program, ProgramMemory, _executor, SourceRange } from '../lang/wasm'
import { EngineCommandManager } from 'lang/std/engineConnection' import { EngineCommandManager } from 'lang/std/engineConnection'
import { EngineCommand } from 'lang/std/artifactMap' import { EngineCommand } from 'lang/std/artifactGraph'
import { Models } from '@kittycad/lib' import { Models } from '@kittycad/lib'
import { v4 as uuidv4 } from 'uuid' import { v4 as uuidv4 } from 'uuid'
import { DefaultPlanes } from 'wasm-lib/kcl/bindings/DefaultPlanes' import { DefaultPlanes } from 'wasm-lib/kcl/bindings/DefaultPlanes'

View File

@ -58,6 +58,7 @@ import { Coords2d } from 'lang/std/sketch'
import { deleteSegment } from 'clientSideScene/ClientSideSceneComp' import { deleteSegment } from 'clientSideScene/ClientSideSceneComp'
import { executeAst } from 'lang/langHelpers' import { executeAst } from 'lang/langHelpers'
import toast from 'react-hot-toast' import toast from 'react-hot-toast'
import { getExtrusionFromSuspectedPath } from 'lang/std/artifactGraph'
export const MODELING_PERSIST_KEY = 'MODELING_PERSIST_KEY' export const MODELING_PERSIST_KEY = 'MODELING_PERSIST_KEY'
@ -201,6 +202,7 @@ export type ModelingMachineEvent =
| { type: 'Constrain remove constraints'; data?: PathToNode } | { type: 'Constrain remove constraints'; data?: PathToNode }
| { type: 'Re-execute' } | { type: 'Re-execute' }
| { type: 'Export'; data: ModelingCommandSchema['Export'] } | { type: 'Export'; data: ModelingCommandSchema['Export'] }
| { type: 'Make'; data: ModelingCommandSchema['Make'] }
| { type: 'Extrude'; data?: ModelingCommandSchema['Extrude'] } | { type: 'Extrude'; data?: ModelingCommandSchema['Extrude'] }
| { type: 'Fillet'; data?: ModelingCommandSchema['Fillet'] } | { type: 'Fillet'; data?: ModelingCommandSchema['Fillet'] }
| { | {
@ -333,6 +335,13 @@ export const modelingMachine = createMachine(
actions: 'Engine export', actions: 'Engine export',
}, },
Make: {
target: 'idle',
internal: true,
cond: 'Has exportable geometry',
actions: 'Make',
},
'Delete selection': { 'Delete selection': {
target: 'idle', target: 'idle',
cond: 'has valid selection for deletion', cond: 'has valid selection for deletion',
@ -1107,7 +1116,7 @@ export const modelingMachine = createMachine(
editorManager.selectRange(updatedAst?.selections) editorManager.selectRange(updatedAst?.selections)
} }
}, },
'AST delete selection': async ({ sketchDetails, selectionRanges }) => { 'AST delete selection': async ({ selectionRanges }) => {
let ast = kclManager.ast let ast = kclManager.ast
const modifiedAst = await deleteFromSelection( const modifiedAst = await deleteFromSelection(
@ -1160,17 +1169,13 @@ export const modelingMachine = createMachine(
const sketchVar = varDecNode.node.declarations[0].id.name const sketchVar = varDecNode.node.declarations[0].id.name
const sketchGroup = kclManager.programMemory.get(sketchVar) const sketchGroup = kclManager.programMemory.get(sketchVar)
if (sketchGroup?.type !== 'SketchGroup') return if (sketchGroup?.type !== 'SketchGroup') return
const idArtifact = engineCommandManager.artifactMap[sketchGroup.id] const extrusion = getExtrusionFromSuspectedPath(
if (idArtifact.type !== 'startPath') return sketchGroup.id,
const extrusionArtifactId = idArtifact?.extrusionIds?.[0] engineCommandManager.artifactGraph
if (typeof extrusionArtifactId !== 'string') return
const extrusionArtifact =
engineCommandManager.artifactMap[extrusionArtifactId]
if (!extrusionArtifact) return
const pathToExtrudeNode = getNodePathFromSourceRange(
ast,
extrusionArtifact.range
) )
const pathToExtrudeNode = err(extrusion)
? []
: getNodePathFromSourceRange(ast, extrusion.codeRef.range)
// we assume that there is only one body related to the sketch // we assume that there is only one body related to the sketch
// and apply the fillet to it // and apply the fillet to it

View File

@ -335,6 +335,12 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "byteorder-lite"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
[[package]] [[package]]
name = "bytes" name = "bytes"
version = "1.5.0" version = "1.5.0"
@ -1251,12 +1257,12 @@ dependencies = [
[[package]] [[package]]
name = "image" name = "image"
version = "0.25.1" version = "0.25.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd54d660e773627692c524beaad361aca785a4f9f5730ce91f42aabe5bce3d11" checksum = "99314c8a2152b8ddb211f924cdae532d8c5e4c8bb54728e12fff1b0cd5963a10"
dependencies = [ dependencies = [
"bytemuck", "bytemuck",
"byteorder", "byteorder-lite",
"num-traits", "num-traits",
"png", "png",
] ]
@ -2616,9 +2622,9 @@ dependencies = [
[[package]] [[package]]
name = "serde_json" name = "serde_json"
version = "1.0.121" version = "1.0.122"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ab380d7d9f22ef3f21ad3e6c1ebe8e4fc7a2000ccba2e4d71fc96f15b2cb609" checksum = "784b6203951c57ff748476b126ccb5e8e2959a5c19e5c617ab1956be3dbc68da"
dependencies = [ dependencies = [
"indexmap 2.2.5", "indexmap 2.2.5",
"itoa", "itoa",

View File

@ -15,7 +15,7 @@ clap = "4.5.13"
gloo-utils = "0.2.0" gloo-utils = "0.2.0"
kcl-lib = { path = "kcl" } kcl-lib = { path = "kcl" }
kittycad.workspace = true kittycad.workspace = true
serde_json = "1.0.121" serde_json = "1.0.122"
tokio = { version = "1.39.2", features = ["sync"] } tokio = { version = "1.39.2", features = ["sync"] }
toml = "0.8.19" toml = "0.8.19"
uuid = { version = "1.10.0", features = ["v4", "js", "serde"] } uuid = { version = "1.10.0", features = ["v4", "js", "serde"] }

View File

@ -11,5 +11,5 @@ hyper = { version = "0.14.29", features = ["server"] }
kcl-lib = { version = "0.2", path = "../kcl" } kcl-lib = { version = "0.2", path = "../kcl" }
pico-args = "0.5.0" pico-args = "0.5.0"
serde = { version = "1.0.204", features = ["derive"] } serde = { version = "1.0.204", features = ["derive"] }
serde_json = "1.0.121" serde_json = "1.0.122"
tokio = { version = "1.39.2", features = ["macros", "rt-multi-thread"] } tokio = { version = "1.39.2", features = ["macros", "rt-multi-thread"] }

View File

@ -35,7 +35,7 @@ reqwest = { version = "0.11.26", default-features = false, features = ["stream",
ropey = "1.6.1" ropey = "1.6.1"
schemars = { version = "0.8.17", features = ["impl_json_schema", "url", "uuid1"] } schemars = { version = "0.8.17", features = ["impl_json_schema", "url", "uuid1"] }
serde = { version = "1.0.204", features = ["derive"] } serde = { version = "1.0.204", features = ["derive"] }
serde_json = "1.0.121" serde_json = "1.0.122"
sha2 = "0.10.8" sha2 = "0.10.8"
tabled = { version = "0.15.0", optional = true } tabled = { version = "0.15.0", optional = true }
thiserror = "1.0.63" thiserror = "1.0.63"

View File

@ -2834,31 +2834,94 @@ impl MemberExpression {
} }
pub fn get_result(&self, memory: &mut ProgramMemory) -> Result<MemoryItem, KclError> { pub fn get_result(&self, memory: &mut ProgramMemory) -> Result<MemoryItem, KclError> {
let property_name = match &self.property { #[derive(Debug)]
LiteralIdentifier::Identifier(identifier) => identifier.name.to_string(), enum Property {
Number(usize),
String(String),
}
impl Property {
fn type_name(&self) -> &'static str {
match self {
Property::Number(_) => "number",
Property::String(_) => "string",
}
}
}
let property_src: SourceRange = self.property.clone().into();
let property_sr = vec![property_src];
let property: Property = match self.property.clone() {
LiteralIdentifier::Identifier(identifier) => {
let name = identifier.name;
if !self.computed {
// Treat the property as a literal
Property::String(name.to_string())
} else {
// Actually evaluate memory to compute the property.
let prop = memory.get(&name, property_src)?;
let MemoryItem::UserVal(prop) = prop else {
return Err(KclError::Syntax(KclErrorDetails {
source_ranges: property_sr,
message: format!(
"{name} is not a valid property/index, you can only use a string or int (>= 0) here",
),
}));
};
match prop.value {
JValue::Number(ref num) => {
num
.as_u64()
.and_then(|x| usize::try_from(x).ok())
.map(Property::Number)
.ok_or_else(|| {
KclError::Syntax(KclErrorDetails {
source_ranges: property_sr,
message: format!(
"{name} is not a valid property/index, you can only use a string or int (>= 0) here",
),
})
})?
}
JValue::String(ref x) => Property::String(x.to_owned()),
_ => {
return Err(KclError::Syntax(KclErrorDetails {
source_ranges: property_sr,
message: format!(
"{name} is not a valid property/index, you can only use a string to get the property of an object, or an int (>= 0) to get an item in an array",
),
}));
}
}
}
}
LiteralIdentifier::Literal(literal) => { LiteralIdentifier::Literal(literal) => {
let value = literal.value.clone(); let value = literal.value.clone();
match value { match value {
LiteralValue::IInteger(x) if x >= 0 => return self.get_result_array(memory, x as usize),
LiteralValue::IInteger(x) => { LiteralValue::IInteger(x) => {
if let Ok(x) = u64::try_from(x) {
Property::Number(x.try_into().unwrap())
} else {
return Err(KclError::Syntax(KclErrorDetails {
source_ranges: property_sr,
message: format!("{x} is not a valid index, indices must be whole numbers >= 0"),
}));
}
}
LiteralValue::String(s) => Property::String(s),
_ => {
return Err(KclError::Syntax(KclErrorDetails { return Err(KclError::Syntax(KclErrorDetails {
source_ranges: vec![self.into()], source_ranges: vec![self.into()],
message: format!("invalid index: {x}"), message: "Only strings or ints (>= 0) can be properties/indexes".to_owned(),
})) }));
} }
LiteralValue::Fractional(x) => {
return Err(KclError::Syntax(KclErrorDetails {
source_ranges: vec![self.into()],
message: format!("invalid index: {x}"),
}))
}
LiteralValue::String(s) => s,
LiteralValue::Bool(b) => b.to_string(),
} }
} }
}; };
let object = match &self.object { let object = match &self.object {
// TODO: Don't use recursion here, use a loop.
MemberObject::MemberExpression(member_expr) => member_expr.get_result(memory)?, MemberObject::MemberExpression(member_expr) => member_expr.get_result(memory)?,
MemberObject::Identifier(identifier) => { MemberObject::Identifier(identifier) => {
let value = memory.get(&identifier.name, identifier.into())?; let value = memory.get(&identifier.name, identifier.into())?;
@ -2868,25 +2931,57 @@ impl MemberExpression {
let object_json = object.get_json_value()?; let object_json = object.get_json_value()?;
if let serde_json::Value::Object(map) = object_json { // Check the property and object match -- e.g. ints for arrays, strs for objects.
if let Some(value) = map.get(&property_name) { match (object_json, property) {
Ok(MemoryItem::UserVal(UserVal { (JValue::Object(map), Property::String(property)) => {
value: value.clone(), if let Some(value) = map.get(&property) {
meta: vec![Metadata { Ok(MemoryItem::UserVal(UserVal {
source_range: self.into(), value: value.clone(),
}], meta: vec![Metadata {
})) source_range: self.into(),
} else { }],
Err(KclError::UndefinedValue(KclErrorDetails { }))
message: format!("Property {} not found in object", property_name), } else {
source_ranges: vec![self.clone().into()], Err(KclError::UndefinedValue(KclErrorDetails {
})) message: format!("Property {property} not found in object"),
source_ranges: vec![self.clone().into()],
}))
}
} }
} else { (JValue::Object(_), p) => Err(KclError::Semantic(KclErrorDetails {
Err(KclError::Semantic(KclErrorDetails { message: format!(
message: format!("MemberExpression object is not an object: {:?}", object), "Only strings can be used as the property of an object, but you're using a {}",
p.type_name()
),
source_ranges: vec![self.clone().into()], source_ranges: vec![self.clone().into()],
})) })),
(JValue::Array(arr), Property::Number(index)) => {
let value_of_arr: Option<&JValue> = arr.get(index);
if let Some(value) = value_of_arr {
Ok(MemoryItem::UserVal(UserVal {
value: value.clone(),
meta: vec![Metadata {
source_range: self.into(),
}],
}))
} else {
Err(KclError::UndefinedValue(KclErrorDetails {
message: format!("The array doesn't have any item at index {index}"),
source_ranges: vec![self.clone().into()],
}))
}
}
(JValue::Array(_), p) => Err(KclError::Semantic(KclErrorDetails {
message: format!(
"Only integers >= 0 can be used as the index of an array, but you're using a {}",
p.type_name()
),
source_ranges: vec![self.clone().into()],
})),
(_, _) => Err(KclError::Semantic(KclErrorDetails {
message: "Only arrays and objects can be indexed".to_owned(),
source_ranges: vec![self.clone().into()],
})),
} }
} }

View File

@ -77,6 +77,11 @@ async fn inner_shell(
})); }));
} }
// Flush the batch for our fillets/chamfers if there are any.
// If we do not do these for sketch on face, things will fail with face does not exist.
args.flush_batch_for_extrude_group_set(extrude_group.clone().into())
.await?;
args.batch_modeling_cmd( args.batch_modeling_cmd(
uuid::Uuid::new_v4(), uuid::Uuid::new_v4(),
ModelingCmd::Solid3DShellFace { ModelingCmd::Solid3DShellFace {

View File

@ -27,7 +27,7 @@ pub async fn execute_and_snapshot(code: &str, units: UnitLength) -> anyhow::Resu
// Save the snapshot locally, to that temporary file. // Save the snapshot locally, to that temporary file.
std::fs::write(&output_file, snapshot.contents.0)?; std::fs::write(&output_file, snapshot.contents.0)?;
// Decode the snapshot, return it. // Decode the snapshot, return it.
let img = image::io::Reader::open(output_file).unwrap().decode()?; let img = image::ImageReader::open(output_file).unwrap().decode()?;
Ok(img) Ok(img)
} }

View File

@ -0,0 +1,18 @@
// This tests computed properties.
const arr = [0, 0, 0, 10]
const i = 3
const ten = arr[i]
assertLessThanOrEq(ten, 10, "oops")
assertGreaterThanOrEq(ten, 10, "oops2")
const p = "foo"
const obj = {
foo: 1,
bar: 0,
}
const one = obj[p]
assertLessThanOrEq(one, 1, "oops")
assertGreaterThanOrEq(one, 1, "oops2")

View File

@ -0,0 +1,81 @@
const rpizWidth = 30
const rpizLength = 65
const caseThickness = 1
const border = 4
const screwHeight = 4
const caseWidth = rpizWidth + border * 2
const caseLength = rpizLength + border * 2
const caseHeight = 8
const widthBetweenScrews = 23
const lengthBetweenScrews = 29 * 2
const miniHdmiDistance = 12.4
const microUsb1Distance = 41.4
const microUsb2Distance = 54
const miniHdmiWidth = 11.2
const microUsbWidth = 7.4
const connectorPadding = 4
const miniHdmiHole = startSketchAt([
0,
border + miniHdmiDistance - (miniHdmiWidth / 2)
])
|> lineTo([
0,
border + miniHdmiDistance + miniHdmiWidth / 2
], %)
|> lineTo([
1,
border + miniHdmiDistance + miniHdmiWidth / 2
], %)
|> lineTo([
1,
border + miniHdmiDistance - (miniHdmiWidth / 2)
], %)
|> close(%)
const case = startSketchOn('XY')
|> startProfileAt([0, 0], %)
|> lineTo([caseWidth, 0], %, $edge1)
|> lineTo([caseWidth, caseLength], %, $edge2)
|> lineTo([0, caseLength], %, $edge3)
|> close(%, $edge4)
|> extrude(caseHeight, %)
|> fillet({
radius: 1,
tags: [
getNextAdjacentEdge(edge1),
getNextAdjacentEdge(edge2),
getNextAdjacentEdge(edge3),
getNextAdjacentEdge(edge4)
],
}, %)
fn m25Screw = (x, y, height) => {
const screw = startSketchOn("XY")
|> startProfileAt([0, 0], %)
|> circle([x, y], 2.5, %)
|> hole(circle([x, y], 1.25, %), %)
|> extrude(height, %)
return screw
}
m25Screw(border + rpizWidth / 2 - (widthBetweenScrews / 2), 0 + border + rpizLength / 2 - (lengthBetweenScrews / 2), screwHeight)
m25Screw(border + rpizWidth / 2 - (widthBetweenScrews / 2), 0 + border + rpizLength / 2 + lengthBetweenScrews / 2, screwHeight)
m25Screw(border + rpizWidth / 2 + widthBetweenScrews / 2, 0 + border + rpizLength / 2 + lengthBetweenScrews / 2, screwHeight)
m25Screw(border + rpizWidth / 2 + widthBetweenScrews / 2, 0 + border + rpizLength / 2 - (lengthBetweenScrews / 2), screwHeight)
shell({
faces: ['end'],
thickness: caseThickness
}, case)

View File

@ -38,6 +38,13 @@ async fn kcl_test_lego() {
assert_out("lego", &result); assert_out("lego", &result);
} }
#[tokio::test(flavor = "multi_thread")]
async fn kcl_test_computed_var() {
let code = kcl_input!("computed_var");
let result = execute_and_snapshot(code, UnitLength::Mm).await.unwrap();
assert_out("computed_var", &result);
}
#[tokio::test(flavor = "multi_thread")] #[tokio::test(flavor = "multi_thread")]
async fn kcl_test_pipe_as_arg() { async fn kcl_test_pipe_as_arg() {
let code = kcl_input!("pipe_as_arg"); let code = kcl_input!("pipe_as_arg");
@ -2364,3 +2371,15 @@ someFunction('INVALID')
r#"semantic: KclErrorDetails { source_ranges: [SourceRange([89, 114]), SourceRange([126, 155]), SourceRange([159, 182])], message: "Argument at index 0 was supposed to be type kcl_lib::std::sketch::SketchData but wasn't" }"# r#"semantic: KclErrorDetails { source_ranges: [SourceRange([89, 114]), SourceRange([126, 155]), SourceRange([159, 182])], message: "Argument at index 0 was supposed to be type kcl_lib::std::sketch::SketchData but wasn't" }"#
); );
} }
#[tokio::test(flavor = "multi_thread")]
async fn kcl_test_fillet_and_shell() {
let code = kcl_input!("fillet-and-shell");
let result = execute_and_snapshot(code, UnitLength::Mm).await;
assert!(result.is_err());
assert_eq!(
result.err().unwrap().to_string(),
r#"engine: KclErrorDetails { source_ranges: [SourceRange([2004, 2065])], message: "Modeling command failed: [ApiError { error_code: InternalEngine, message: \"Shell of non-planar solid3d not available yet\" }]" }"#
);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

View File

@ -2270,6 +2270,11 @@
dependencies: dependencies:
"@babel/types" "^7.20.7" "@babel/types" "^7.20.7"
"@types/d3-force@^3.0.10":
version "3.0.10"
resolved "https://registry.yarnpkg.com/@types/d3-force/-/d3-force-3.0.10.tgz#6dc8fc6e1f35704f3b057090beeeb7ac674bff1a"
integrity sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==
"@types/eslint@^8.4.5": "@types/eslint@^8.4.5":
version "8.56.10" version "8.56.10"
resolved "https://registry.yarnpkg.com/@types/eslint/-/eslint-8.56.10.tgz#eb2370a73bf04a901eeba8f22595c7ee0f7eb58d" resolved "https://registry.yarnpkg.com/@types/eslint/-/eslint-8.56.10.tgz#eb2370a73bf04a901eeba8f22595c7ee0f7eb58d"
@ -3849,6 +3854,30 @@ csstype@^3.0.2:
resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.3.tgz#d80ff294d114fb0e6ac500fbf85b60137d7eff81" resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.3.tgz#d80ff294d114fb0e6ac500fbf85b60137d7eff81"
integrity sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw== integrity sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==
"d3-dispatch@1 - 3":
version "3.0.1"
resolved "https://registry.yarnpkg.com/d3-dispatch/-/d3-dispatch-3.0.1.tgz#5fc75284e9c2375c36c839411a0cf550cbfc4d5e"
integrity sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==
d3-force@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/d3-force/-/d3-force-3.0.0.tgz#3e2ba1a61e70888fe3d9194e30d6d14eece155c4"
integrity sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==
dependencies:
d3-dispatch "1 - 3"
d3-quadtree "1 - 3"
d3-timer "1 - 3"
"d3-quadtree@1 - 3":
version "3.0.1"
resolved "https://registry.yarnpkg.com/d3-quadtree/-/d3-quadtree-3.0.1.tgz#6dca3e8be2b393c9a9d514dabbd80a92deef1a4f"
integrity sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==
"d3-timer@1 - 3":
version "3.0.1"
resolved "https://registry.yarnpkg.com/d3-timer/-/d3-timer-3.0.1.tgz#6284d2a2708285b1abb7e201eda4380af35e63b0"
integrity sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==
damerau-levenshtein@^1.0.8: damerau-levenshtein@^1.0.8:
version "1.0.8" version "1.0.8"
resolved "https://registry.yarnpkg.com/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz#b43d286ccbd36bc5b2f7ed41caf2d0aba1f8a6e7" resolved "https://registry.yarnpkg.com/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz#b43d286ccbd36bc5b2f7ed41caf2d0aba1f8a6e7"
@ -7683,16 +7712,7 @@ string-natural-compare@^3.0.1:
resolved "https://registry.yarnpkg.com/string-natural-compare/-/string-natural-compare-3.0.1.tgz#7a42d58474454963759e8e8b7ae63d71c1e7fdf4" resolved "https://registry.yarnpkg.com/string-natural-compare/-/string-natural-compare-3.0.1.tgz#7a42d58474454963759e8e8b7ae63d71c1e7fdf4"
integrity sha512-n3sPwynL1nwKi3WJ6AIsClwBMa0zTi54fn2oLU6ndfTSIO05xaznjSf15PcBZU6FNWbmN5Q6cxT4V5hGvB4taw== integrity sha512-n3sPwynL1nwKi3WJ6AIsClwBMa0zTi54fn2oLU6ndfTSIO05xaznjSf15PcBZU6FNWbmN5Q6cxT4V5hGvB4taw==
"string-width-cjs@npm:string-width@^4.2.0": "string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
version "4.2.3"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
dependencies:
emoji-regex "^8.0.0"
is-fullwidth-code-point "^3.0.0"
strip-ansi "^6.0.1"
string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
version "4.2.3" version "4.2.3"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
@ -7770,14 +7790,7 @@ string_decoder@~1.1.1:
dependencies: dependencies:
safe-buffer "~5.1.0" safe-buffer "~5.1.0"
"strip-ansi-cjs@npm:strip-ansi@^6.0.1": "strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1:
version "6.0.1"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
dependencies:
ansi-regex "^5.0.1"
strip-ansi@^6.0.0, strip-ansi@^6.0.1:
version "6.0.1" version "6.0.1"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
@ -8653,7 +8666,7 @@ workerpool@6.2.1:
resolved "https://registry.yarnpkg.com/workerpool/-/workerpool-6.2.1.tgz#46fc150c17d826b86a008e5a4508656777e9c343" resolved "https://registry.yarnpkg.com/workerpool/-/workerpool-6.2.1.tgz#46fc150c17d826b86a008e5a4508656777e9c343"
integrity sha512-ILEIE97kDZvF9Wb9f6h5aXK4swSlKGUcOEGiIYb2OOu/IrDU9iwj0fD//SsA6E5ibwJxpEvhullJY4Sl4GcpAw== integrity sha512-ILEIE97kDZvF9Wb9f6h5aXK4swSlKGUcOEGiIYb2OOu/IrDU9iwj0fD//SsA6E5ibwJxpEvhullJY4Sl4GcpAw==
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": "wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0:
version "7.0.0" version "7.0.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
@ -8671,15 +8684,6 @@ wrap-ansi@^6.2.0:
string-width "^4.1.0" string-width "^4.1.0"
strip-ansi "^6.0.0" strip-ansi "^6.0.0"
wrap-ansi@^7.0.0:
version "7.0.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
dependencies:
ansi-styles "^4.0.0"
string-width "^4.1.0"
strip-ansi "^6.0.0"
wrap-ansi@^8.1.0: wrap-ansi@^8.1.0:
version "8.1.0" version "8.1.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"