Compare commits

..

1 Commits

Author SHA1 Message Date
86ae7a989f initial go
Signed-off-by: Jess Frazelle <github@jessfraz.com>

fixes

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

better

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

typo

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>

updates

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>
2025-05-14 19:09:38 -07:00
85 changed files with 1873 additions and 3011 deletions

View File

@ -123,6 +123,18 @@ jobs:
- id: export_notes
run: echo "notes=`cat release-notes.md`" >> "$GITHUB_OUTPUT"
- name: Prepare electron-builder.yml file for updater test
if: ${{ env.IS_RELEASE == 'true' }}
run: |
yq -i '.publish[0].url = "https://dl.zoo.dev/releases/modeling-app/updater-test"' electron-builder.yml
- uses: actions/upload-artifact@v4
if: ${{ env.IS_RELEASE == 'true' }}
with:
name: prepared-files-updater-test
path: |
electron-builder.yml
build-apps:
needs: [prepare-files]
@ -247,6 +259,49 @@ jobs:
# TODO: add the 'Build for Mac TestFlight (nightly)' stage back
# The steps below are for updater-test builds, only on release
- uses: actions/download-artifact@v4
if: ${{ env.IS_RELEASE == 'true' }}
name: prepared-files-updater-test
- name: Copy updated electron-builder.yml file for updater test
if: ${{ env.IS_RELEASE == 'true' }}
run: |
ls -R prepared-files-updater-test
cp prepared-files-updater-test/electron-builder.yml electron-builder.yml
- name: Build the app (updater-test)
if: ${{ env.IS_RELEASE == 'true' }}
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
CSC_LINK: ${{ secrets.APPLE_CERTIFICATE }}
CSC_KEY_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
CSC_KEYCHAIN: ${{ secrets.APPLE_SIGNING_IDENTITY }}
WINDOWS_CERTIFICATE_THUMBPRINT: ${{ secrets.WINDOWS_CERTIFICATE_THUMBPRINT }}
run: npm run tronb:package:prod
- uses: actions/upload-artifact@v4
if: ${{ env.IS_RELEASE == 'true' }}
with:
name: updater-test-arm64-${{ matrix.platform }}
path: |
out/*-arm64-win.exe
out/*-arm64-mac.dmg
out/*-arm64-linux.AppImage
- uses: actions/upload-artifact@v4
if: ${{ env.IS_RELEASE == 'true' }}
with:
name: updater-test-x64-${{ matrix.platform }}
path: |
out/*-x64-win.exe
out/*-x64-mac.dmg
out/*-x86_64-linux.AppImage
upload-apps-release:
runs-on: ubuntu-22.04

View File

@ -40,7 +40,7 @@ jobs:
- name: Install dependencies
run: npm install
- name: Download Wasm cache
- name: Download Wasm Cache
id: download-wasm
if: ${{ github.event_name != 'schedule' && steps.filter.outputs.rust == 'false' }}
uses: dawidd6/action-download-artifact@v7
@ -52,7 +52,7 @@ jobs:
branch: main
path: rust/kcl-wasm-lib/pkg
- name: Build Wasm condition
- name: Build WASM condition
id: wasm
run: |
set -euox pipefail
@ -70,7 +70,7 @@ jobs:
run: |
[ -e rust-toolchain.toml ] || cp rust/rust-toolchain.toml ./
- name: Install Rust
- name: Install rust
if: ${{ steps.wasm.outputs.should-build-wasm == 'true' }}
uses: actions-rust-lang/setup-rust-toolchain@v1
with:
@ -81,7 +81,7 @@ jobs:
with:
tool: wasm-pack
- name: Use Rust cache
- name: Rust Cache
if: ${{ steps.wasm.outputs.should-build-wasm == 'true' }}
uses: Swatinem/rust-cache@v2
with:
@ -117,7 +117,7 @@ jobs:
- uses: actions/download-artifact@v4
name: prepared-wasm
- name: Copy prepared Wasm
- name: Copy prepared wasm
run: |
ls -R prepared-wasm
cp prepared-wasm/kcl_wasm_lib_bg.wasm public
@ -133,17 +133,20 @@ jobs:
id: deps-install
run: npm install
- name: Cache browsers
- name: Cache Playwright Browsers
uses: actions/cache@v4
with:
path: |
~/.cache/ms-playwright/
key: ${{ runner.os }}-playwright-${{ hashFiles('package-lock.json') }}
- name: Install browsers
- name: Install Playwright Browsers
run: npm run playwright install --with-deps
- name: Capture snapshots
- name: build web
run: npm run tronb:vite:dev
- name: Run ubuntu/chrome snapshots
uses: nick-fields/retry@v3.0.2
with:
shell: bash
@ -167,7 +170,7 @@ jobs:
retention-days: 30
overwrite: true
- name: Check diff
- name: Check for changes
if: ${{ github.ref != 'refs/heads/main' }}
shell: bash
id: git-check
@ -178,8 +181,9 @@ jobs:
else echo "modified=false" >> $GITHUB_OUTPUT
fi
- name: Commit changes
if: ${{ steps.git-check.outputs.modified == 'true' }}
- name: Commit changes, if any
# TODO: find a more reliable way to detect visual changes
if: ${{ false && steps.git-check.outputs.modified == 'true' }}
shell: bash
run: |
git add e2e/playwright/snapshot-tests.spec.ts-snapshots e2e/playwright/snapshots
@ -189,7 +193,7 @@ jobs:
git fetch origin
echo ${{ github.head_ref }}
git checkout ${{ github.head_ref }}
git commit --message "Update snapshots" || true
git commit -m "A snapshot a day keeps the bugs away! 📷🐛" || true
git push
git push origin ${{ github.head_ref }}

View File

@ -130,7 +130,7 @@ git tag $VERSION
git push origin --tags
```
This will trigger the `build-apps` workflow, set the version, build & sign the apps, and generate release files.
This will trigger the `build-apps` workflow, set the version, build & sign the apps, and generate release files as well as updater-test artifacts.
The workflow should be listed right away [in this list](https://github.com/KittyCAD/modeling-app/actions/workflows/build-apps.yml?query=event%3Apush)).
@ -142,10 +142,13 @@ The release builds can be found under the `out-{arch}-{platform}` zip files, at
Manually test against this [list](https://github.com/KittyCAD/modeling-app/issues/3588) across Windows, MacOS, Linux and posting results as comments in the issue.
A prompt should show up asking for a downgrade to the last release version. Running through that at the end of testing
and making sure the current release candidate has the ability to be updated to what electron-updater points to is critical,
but what is actually being downloaded and installed isn't.
If the prompt doesn't show up, start the app in command line to grab the electron-updater logs. This is likely an issue with the current build that needs addressing.
##### Updater-test builds
The other `build-apps` output in the release `build-apps` workflow (triggered by 2.) is `updater-test-{arch}-{platform}`. It's a semi-automated process: for macOS, Windows, and Linux, download the corresponding updater-test artifact file, install the app, run it, expect an updater prompt to a dummy v0.255.255, install it and check that the app comes back at that version.
The only difference with these builds is that they point to a different update location on the release bucket, with this dummy v0.255.255 always available. This helps ensuring that the version we release will be able to update to the next one available.
If the prompt doesn't show up, start the app in command line to grab the electron-updater logs. This is likely an issue with the current build that needs addressing (or the updater-test location in the storage bucket).
```
# Windows (PowerShell)

View File

@ -1,16 +0,0 @@
---
title: "sweep::SKETCH_PLANE"
subtitle: "Constant in std::sweep"
excerpt: "Local/relative to a position centered within the plane being sketched on"
layout: manual
---
Local/relative to a position centered within the plane being sketched on
```kcl
sweep::SKETCH_PLANE: string = 'sketchPlane'
```

View File

@ -1,16 +0,0 @@
---
title: "sweep::TRAJECTORY"
subtitle: "Constant in std::sweep"
excerpt: "Local/relative to the trajectory curve"
layout: manual
---
Local/relative to the trajectory curve
```kcl
sweep::TRAJECTORY: string = 'trajectoryCurve'
```

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -128,9 +128,6 @@ layout: manual
* [`E`](/docs/kcl-std/consts/std-math-E)
* [`PI`](/docs/kcl-std/consts/std-math-PI)
* [`TAU`](/docs/kcl-std/consts/std-math-TAU)
* [**std::sweep**](/docs/kcl-std/modules/std-sweep)
* [`sweep::SKETCH_PLANE`](/docs/kcl-std/consts/std-sweep-SKETCH_PLANE)
* [`sweep::TRAJECTORY`](/docs/kcl-std/consts/std-sweep-TRAJECTORY)
* [**std::turns**](/docs/kcl-std/modules/std-turns)
* [`turns::HALF_TURN`](/docs/kcl-std/consts/std-turns-HALF_TURN)
* [`turns::QUARTER_TURN`](/docs/kcl-std/consts/std-turns-QUARTER_TURN)

View File

@ -1,17 +0,0 @@
---
title: "sweep"
subtitle: "Module in std"
excerpt: ""
layout: manual
---
## Functions and constants
* [`sweep::SKETCH_PLANE`](/docs/kcl-std/consts/std-sweep-SKETCH_PLANE)
* [`sweep::TRAJECTORY`](/docs/kcl-std/consts/std-sweep-TRAJECTORY)

View File

@ -19,7 +19,6 @@ You might also want the [KCL language reference](/docs/kcl-lang) or the [KCL gui
* [`math`](/docs/kcl-std/modules/std-math)
* [`sketch`](/docs/kcl-std/modules/std-sketch)
* [`solid`](/docs/kcl-std/modules/std-solid)
* [`sweep::sweep`](/docs/kcl-std/modules/std-sweep)
* [`transform`](/docs/kcl-std/modules/std-transform)
* [`turns::turns`](/docs/kcl-std/modules/std-turns)
* [`types`](/docs/kcl-std/modules/std-types)

View File

@ -251786,7 +251786,7 @@
}
},
"required": false,
"description": "What is the sweep relative to? Can be either 'sketchPlane' or 'trajectoryCurve'. Defaults to trajectoryCurve.",
"description": "What is the sweep relative to? Can be either 'sketchPlane' or 'trajectoryCurve'. Defaults to sketchPlane.",
"labelRequired": true
},
{
@ -256720,7 +256720,7 @@
false
],
[
"// Create a spring by sweeping around a helix path.\n\n// Create a helix around the Z axis.\nhelixPath = helix(\n angleStart = 0,\n ccw = true,\n revolutions = 4,\n length = 10,\n radius = 5,\n axis = Z,\n)\n\n// Create a spring by sweeping around the helix path.\nspringSketch = startSketchOn(YZ)\n |> circle(center = [0, 0], radius = 1)\n |> sweep(path = helixPath, relativeTo = \"sketchPlane\")",
"// Create a spring by sweeping around a helix path.\n\n// Create a helix around the Z axis.\nhelixPath = helix(\n angleStart = 0,\n ccw = true,\n revolutions = 4,\n length = 10,\n radius = 5,\n axis = Z,\n)\n\n// Create a spring by sweeping around the helix path.\nspringSketch = startSketchOn(YZ)\n |> circle(center = [0, 0], radius = 1)\n |> sweep(path = helixPath)",
false
],
[

File diff suppressed because one or more lines are too long

View File

@ -134,6 +134,8 @@ extrude001 = extrude(sketch001, length = 5)`
await page.setBodyDimensions({ width: 1200, height: 500 })
await homePage.goToModelingScene()
await page.waitForTimeout(1000)
// Ensure badge is present
const codePaneButtonHolder = page.locator('#code-button-holder')
await expect(codePaneButtonHolder).toContainText('notification', {
@ -181,7 +183,7 @@ extrude001 = extrude(sketch001, length = 5)`
await page.setBodyDimensions({ width: 1200, height: 500 })
await homePage.goToModelingScene()
// await scene.settled(cmdBar)
await scene.settled(cmdBar)
// Ensure badge is present
const codePaneButtonHolder = page.locator('#code-button-holder')

View File

@ -1533,6 +1533,7 @@ sketch001 = startSketchOn(XZ)
await homePage.goToModelingScene()
await scene.connectionEstablished()
await scene.settled(cmdBar)
await scene.expectPixelColor(
TEST_COLORS.DARK_MODE_BKGD,

View File

@ -1931,6 +1931,84 @@ sketch002 = startSketchOn(XZ)
})
})
test(`Sweep point-and-click failing validation`, async ({
context,
page,
homePage,
scene,
toolbar,
cmdBar,
}) => {
const initialCode = `@settings(defaultLengthUnit = in)
sketch001 = startSketchOn(YZ)
|> circle(
center = [0, 0],
radius = 500
)
sketch002 = startSketchOn(XZ)
|> startProfile(at = [0, 0])
|> xLine(length = -500)
|> line(endAbsolute = [-2000, 500])
`
await context.addInitScript((initialCode) => {
localStorage.setItem('persistCode', initialCode)
}, initialCode)
await page.setBodyDimensions({ width: 1000, height: 500 })
await homePage.goToModelingScene()
await scene.settled(cmdBar)
// One dumb hardcoded screen pixel value
const testPoint = { x: 700, y: 250 }
const [clickOnSketch1] = scene.makeMouseHelpers(testPoint.x, testPoint.y)
const [clickOnSketch2] = scene.makeMouseHelpers(
testPoint.x - 50,
testPoint.y
)
await test.step(`Look for sketch001`, async () => {
await toolbar.closePane('code')
await scene.expectPixelColor([53, 53, 53], testPoint, 15)
})
await test.step(`Go through the command bar flow and fail validation with a toast`, async () => {
await toolbar.sweepButton.click()
await expect
.poll(() => page.getByText('Please select one').count())
.toBe(1)
await cmdBar.expectState({
commandName: 'Sweep',
currentArgKey: 'sketches',
currentArgValue: '',
headerArguments: {
Sectional: '',
Sketches: '',
Path: '',
},
highlightedHeaderArg: 'sketches',
stage: 'arguments',
})
await clickOnSketch1()
await cmdBar.progressCmdBar()
await cmdBar.expectState({
commandName: 'Sweep',
currentArgKey: 'path',
currentArgValue: '',
headerArguments: {
Sectional: '',
Sketches: '1 face',
Path: '',
},
highlightedHeaderArg: 'path',
stage: 'arguments',
})
await clickOnSketch2()
await cmdBar.progressCmdBar()
await expect(
page.getByText('Unable to sweep with the current selection. Reason:')
).toBeVisible()
})
})
test(`Fillet point-and-click`, async ({
context,
page,
@ -3549,6 +3627,67 @@ profile001 = startProfile(sketch001, at = [-20, 20])
})
})
test(`Shell dry-run validation rejects sweeps`, async ({
context,
page,
homePage,
scene,
editor,
toolbar,
cmdBar,
}) => {
const initialCode = `sketch001 = startSketchOn(YZ)
|> circle(
center = [0, 0],
radius = 500
)
sketch002 = startSketchOn(XZ)
|> startProfile(at = [0, 0])
|> xLine(length = -2000)
sweep001 = sweep(sketch001, path = sketch002)
`
await context.addInitScript((initialCode) => {
localStorage.setItem('persistCode', initialCode)
}, initialCode)
await page.setBodyDimensions({ width: 1000, height: 500 })
await homePage.goToModelingScene()
await scene.settled(cmdBar)
// One dumb hardcoded screen pixel value
const testPoint = { x: 500, y: 250 }
const [clickOnSweep] = scene.makeMouseHelpers(testPoint.x, testPoint.y)
await test.step(`Confirm sweep exists`, async () => {
await toolbar.closePane('code')
await scene.expectPixelColor([231, 231, 231], testPoint, 15)
})
await test.step(`Go through the Shell flow and fail validation with a toast`, async () => {
await toolbar.shellButton.click()
await expect
.poll(() => page.getByText('Please select one').count())
.toBe(1)
await cmdBar.expectState({
stage: 'arguments',
currentArgKey: 'selection',
currentArgValue: '',
headerArguments: {
Selection: '',
Thickness: '',
},
highlightedHeaderArg: 'selection',
commandName: 'Shell',
})
await clickOnSweep()
await page.waitForTimeout(500)
await cmdBar.progressCmdBar()
await expect(
page.getByText('Unable to shell with the current selection. Reason:')
).toBeVisible()
await page.waitForTimeout(1000)
})
})
test.describe('Revolve point and click workflows', () => {
test('Base case workflow, auto spam continue in command bar', async ({
context,
@ -4804,34 +4943,4 @@ path001 = startProfile(sketch001, at = [0, 0])
)
})
})
test(`Point and click codemods can't run on KCL errors`, async ({
context,
page,
homePage,
scene,
editor,
toolbar,
cmdBar,
}) => {
const badCode = `sketch001 = startSketchOn(XZ)
profile001 = circle(sketch001, center = [0, 0], radius = 1)
extrude001 = extrude(profile001 length = 1)`
await context.addInitScript((initialCode) => {
localStorage.setItem('persistCode', initialCode)
}, badCode)
await page.setBodyDimensions({ width: 1000, height: 500 })
await homePage.goToModelingScene()
await scene.connectionEstablished()
await test.step(`Start Sketch is disabled`, async () => {
await expect(toolbar.startSketchBtn).not.toBeEnabled()
await editor.expectEditor.toContain(badCode, { shouldNormalise: true })
})
await test.step(`Helix is disabled`, async () => {
await expect(toolbar.helixButton).not.toBeEnabled()
await editor.expectEditor.toContain(badCode, { shouldNormalise: true })
})
})
})

View File

@ -19,12 +19,11 @@ test.describe('Regression tests', () => {
context,
page,
homePage,
scene,
}) => {
// because the model has `line([0,0]..` it is valid code, but the model is invalid
// regression test for https://github.com/KittyCAD/modeling-app/issues/3251
// Since the bad model also found as issue with the artifact graph, which in tern blocked the editor diognostics
// const u = await getUtils(page)
const u = await getUtils(page)
await context.addInitScript(async () => {
localStorage.setItem(
'persistCode',
@ -41,8 +40,7 @@ test.describe('Regression tests', () => {
await page.setBodyDimensions({ width: 1000, height: 500 })
await homePage.goToModelingScene()
await scene.connectionEstablished()
// await u.waitForPageLoad()
await u.waitForPageLoad()
// error in guter
await expect(page.locator('.cm-lint-marker-error')).toBeVisible()
@ -190,8 +188,8 @@ extrude001 = extrude(sketch001, length = 50)
page.locator('.pretty-json-container >> text=myVar:"67')
).toBeVisible()
})
test('ProgramMemory can be serialised', async ({ page, homePage, scene }) => {
// const u = await getUtils(page)
test('ProgramMemory can be serialised', async ({ page, homePage }) => {
const u = await getUtils(page)
await page.addInitScript(async () => {
localStorage.setItem(
'persistCode',
@ -216,12 +214,11 @@ extrude001 = extrude(sketch001, length = 50)
// Listen for all console events and push the message text to an array
page.on('console', (message) => messages.push(message.text()))
await homePage.goToModelingScene()
// await u.waitForPageLoad()
await scene.connectionEstablished()
await u.waitForPageLoad()
// wait for execution done
// await u.openDebugPanel()
// await u.expectCmdLog('[data-message-type="execution-done"]')
await u.openDebugPanel()
await u.expectCmdLog('[data-message-type="execution-done"]')
const forbiddenMessages = ['cannot serialize tagged newtype variant']
forbiddenMessages.forEach((forbiddenMessage) => {
@ -235,7 +232,6 @@ extrude001 = extrude(sketch001, length = 50)
context,
page,
homePage,
scene,
}) => {
const u = await getUtils(page)
// const PUR = 400 / 37.5 //pixeltoUnitRatio
@ -254,10 +250,11 @@ extrude001 = extrude(sketch001, length = 50)
shell(exampleSketch, faces = ['end'], thickness = 0.25)`
)
})
await homePage.goToModelingScene()
await scene.connectionEstablished()
await expect(async () => {
await homePage.goToModelingScene()
await u.waitForPageLoad()
// error in guter
await expect(page.locator('.cm-lint-marker-error')).toBeVisible({
timeout: 1_000,

View File

@ -1365,18 +1365,18 @@ solid001 = subtract([extrude001], tools = [extrude002])
await page.addInitScript(async () => {
localStorage.setItem(
'persistCode',
`fn in2mm(@inches) {
`fn in2mm = (inches) => {
return inches * 25.4
}
railTop = in2mm(.748)
railSide = in2mm(.024)
railBaseWidth = in2mm(.612)
railWideWidth = in2mm(.835)
railBaseLength = in2mm(.200)
railClampable = in2mm(.200)
const railTop = in2mm(.748)
const railSide = in2mm(.024)
const railBaseWidth = in2mm(.612)
const railWideWidth = in2mm(.835)
const railBaseLength = in2mm(.200)
const railClampable = in2mm(.200)
rail = startSketchOn(XZ)
const rail = startSketchOn(XZ)
|> startProfile(at = [-railTop / 2, railClampable + railBaseLength])
|> line(endAbsolute = [
railTop / 2,
@ -3540,6 +3540,7 @@ profile001 = startProfile(sketch001, at = [127.56, 179.02])
await homePage.openProject('multi-file-sketch-test')
await scene.connectionEstablished()
await scene.settled(cmdBar)
await u.closeDebugPanel()
@ -3554,6 +3555,9 @@ profile001 = startProfile(sketch001, at = [127.56, 179.02])
await toolbar.openFile('error.kcl')
// Ensure filetree is populated
await scene.settled(cmdBar)
await expect(
toolbar.featureTreePane.getByRole('button', { name: 'Sketch' })
).toHaveCount(0)

View File

@ -1,3 +1,4 @@
import { KCL_DEFAULT_LENGTH } from '@src/lib/constants'
import type { CmdBarFixture } from '@e2e/playwright/fixtures/cmdBarFixture'
import type { SceneFixture } from '@e2e/playwright/fixtures/sceneFixture'
import { TEST_SETTINGS, TEST_SETTINGS_KEY } from '@e2e/playwright/storageStates'
@ -8,7 +9,6 @@ import {
settingsToToml,
} from '@e2e/playwright/test-utils'
import { expect, test } from '@e2e/playwright/zoo-test'
import { KCL_DEFAULT_LENGTH } from '@src/lib/constants'
test.beforeEach(async ({ page, context }) => {
// Make the user avatar image always 404
@ -766,7 +766,7 @@ test.describe('Grid visibility', { tag: '@snapshot' }, () => {
})
})
test('theme persists', async ({ page, context, homePage }) => {
test('theme persists', async ({ page, context }) => {
const u = await getUtils(page)
await context.addInitScript(async () => {
localStorage.setItem(
@ -784,7 +784,7 @@ test('theme persists', async ({ page, context, homePage }) => {
await page.setViewportSize({ width: 1200, height: 500 })
await homePage.goToModelingScene()
await u.waitForAuthSkipAppStart()
await page.waitForTimeout(500)
// await page.getByRole('link', { name: 'Settings Settings (tooltip)' }).click()
@ -812,7 +812,7 @@ test('theme persists', async ({ page, context, homePage }) => {
// Disconnect and reconnect to check the theme persists through a reload
// Expect the network to be down
await expect(networkToggle).toContainText('Problem')
await expect(networkToggle).toContainText('Offline')
// simulate network up
await u.emulateNetworkConditions({
@ -834,7 +834,13 @@ test('theme persists', async ({ page, context, homePage }) => {
})
test.describe('code color goober', { tag: '@snapshot' }, () => {
test('code color goober', async ({ page, context, scene, cmdBar }) => {
test('code color goober', async ({
page,
context,
scene,
cmdBar,
editor,
}) => {
const u = await getUtils(page)
await context.addInitScript(async () => {
localStorage.setItem(
@ -873,56 +879,13 @@ sweepSketch = startSketchOn(XY)
mask: lowerRightMasks(page),
})
})
test('code color goober works with single quotes', async ({
page,
context,
scene,
cmdBar,
}) => {
const u = await getUtils(page)
await context.addInitScript(async () => {
localStorage.setItem(
'persistCode',
`// Create a pipe using a sweep.
// Create a path for the sweep.
sweepPath = startSketchOn(XZ)
|> startProfile(at = [0.05, 0.05])
|> line(end = [0, 7])
|> tangentialArc(angle = 90, radius = 5)
|> line(end = [-3, 0])
|> tangentialArc(angle = -90, radius = 5)
|> line(end = [0, 7])
sweepSketch = startSketchOn(XY)
|> startProfile(at = [2, 0])
|> arc(angleStart = 0, angleEnd = 360, radius = 2)
|> sweep(path = sweepPath)
|> appearance(
color = '#bb00ff',
metalness = 90,
roughness = 90
)
`
)
})
await page.setViewportSize({ width: 1200, height: 1000 })
await u.waitForAuthSkipAppStart()
await scene.settled(cmdBar)
await expect(page, 'expect small color widget').toHaveScreenshot({
maxDiffPixels: 100,
mask: lowerRightMasks(page),
})
})
test('code color goober opening window', async ({
page,
context,
scene,
cmdBar,
editor,
}) => {
const u = await getUtils(page)
await context.addInitScript(async () => {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

View File

@ -1,257 +1,236 @@
import type { EngineCommand } from '@src/lang/std/artifactGraph'
import { uuidv4 } from '@src/lib/utils'
import {
commonPoints,
getUtils,
TEST_COLORS,
circleMove,
} from '@e2e/playwright/test-utils'
import { commonPoints, getUtils } from '@e2e/playwright/test-utils'
import { expect, test } from '@e2e/playwright/zoo-test'
test.describe('Test network related behaviors', () => {
test(
'simulate network down and network little widget',
{ tag: '@skipLocalEngine' },
async ({ page, homePage }) => {
const networkToggleConnectedText = page.getByText('Connected')
const networkToggleWeakText = page.getByText('Network health (Weak)')
test.describe(
'Test network and connection issues',
{
tag: ['@macos', '@windows'],
},
() => {
test(
'simulate network down and network little widget',
{ tag: '@skipLocalEngine' },
async ({ page, homePage }) => {
const u = await getUtils(page)
await page.setBodyDimensions({ width: 1200, height: 500 })
const u = await getUtils(page)
await page.setBodyDimensions({ width: 1200, height: 500 })
await homePage.goToModelingScene()
await homePage.goToModelingScene()
const networkToggle = page.getByTestId('network-toggle')
const networkToggle = page.getByTestId('network-toggle')
// This is how we wait until the stream is online
await expect(
page.getByRole('button', { name: 'Start Sketch' })
).not.toBeDisabled({ timeout: 15000 })
// This is how we wait until the stream is online
await expect(
page.getByRole('button', { name: 'Start Sketch' })
).not.toBeDisabled({ timeout: 15000 })
const networkWidget = page.locator('[data-testid="network-toggle"]')
await expect(networkWidget).toBeVisible()
await networkWidget.hover()
const networkWidget = page.locator('[data-testid="network-toggle"]')
await expect(networkWidget).toBeVisible()
await networkWidget.hover()
const networkPopover = page.locator('[data-testid="network-popover"]')
await expect(networkPopover).not.toBeVisible()
const networkPopover = page.locator('[data-testid="network-popover"]')
await expect(networkPopover).not.toBeVisible()
// (First check) Expect the network to be up
await expect(networkToggle).toContainText('Connected')
// (First check) Expect the network to be up
await expect(
networkToggleConnectedText.or(networkToggleWeakText)
).toBeVisible()
// Click the network widget
await networkWidget.click()
// Click the network widget
await networkWidget.click()
// Check the modal opened.
await expect(networkPopover).toBeVisible()
// Check the modal opened.
await expect(networkPopover).toBeVisible()
// Click off the modal.
await page.mouse.click(100, 100)
await expect(networkPopover).not.toBeVisible()
// Click off the modal.
await page.mouse.click(100, 100)
await expect(networkPopover).not.toBeVisible()
// Turn off the network
await u.emulateNetworkConditions({
offline: true,
// values of 0 remove any active throttling. crbug.com/456324#c9
latency: 0,
downloadThroughput: -1,
uploadThroughput: -1,
})
// Turn off the network
await u.emulateNetworkConditions({
offline: true,
// values of 0 remove any active throttling. crbug.com/456324#c9
latency: 0,
downloadThroughput: -1,
uploadThroughput: -1,
})
// Expect the network to be down
await expect(networkToggle).toContainText('Problem')
// Expect the network to be down
await expect(networkToggle).toContainText('Problem')
// Click the network widget
await networkWidget.click()
// Click the network widget
await networkWidget.click()
// Check the modal opened.
await expect(networkPopover).toBeVisible()
// Check the modal opened.
await expect(networkPopover).toBeVisible()
// Click off the modal.
await page.mouse.click(0, 0)
await expect(networkPopover).not.toBeVisible()
// Click off the modal.
await page.mouse.click(0, 0)
await expect(networkPopover).not.toBeVisible()
// Turn back on the network
await u.emulateNetworkConditions({
offline: false,
// values of 0 remove any active throttling. crbug.com/456324#c9
latency: 0,
downloadThroughput: -1,
uploadThroughput: -1,
})
// Turn back on the network
await u.emulateNetworkConditions({
offline: false,
// values of 0 remove any active throttling. crbug.com/456324#c9
latency: 0,
downloadThroughput: -1,
uploadThroughput: -1,
})
await expect(
page.getByRole('button', { name: 'Start Sketch' })
).not.toBeDisabled({ timeout: 15000 })
await expect(
page.getByRole('button', { name: 'Start Sketch' })
).not.toBeDisabled({ timeout: 15000 })
// (Second check) expect the network to be up
await expect(networkToggle).toContainText('Connected')
}
)
// (Second check) expect the network to be up
await expect(
networkToggleConnectedText.or(networkToggleWeakText)
).toBeVisible()
}
)
test(
'Engine disconnect & reconnect in sketch mode',
{ tag: '@skipLocalEngine' },
async ({ page, homePage, toolbar, scene, cmdBar }) => {
const networkToggle = page.getByTestId('network-toggle')
test(
'Engine disconnect & reconnect in sketch mode',
{ tag: '@skipLocalEngine' },
async ({ page, homePage, toolbar, scene, cmdBar }) => {
const networkToggle = page.getByTestId('network-toggle')
const networkToggleConnectedText = page.getByText('Connected')
const networkToggleWeakText = page.getByText('Network health (Weak)')
const u = await getUtils(page)
await page.setBodyDimensions({ width: 1200, height: 500 })
const PUR = 400 / 37.5 //pixeltoUnitRatio
const u = await getUtils(page)
await page.setBodyDimensions({ width: 1200, height: 500 })
const PUR = 400 / 37.5 //pixeltoUnitRatio
await homePage.goToModelingScene()
await u.waitForPageLoad()
await homePage.goToModelingScene()
await u.waitForPageLoad()
await u.openDebugPanel()
// click on "Start Sketch" button
await u.clearCommandLogs()
await page.getByRole('button', { name: 'Start Sketch' }).click()
await page.waitForTimeout(100)
await u.openDebugPanel()
// click on "Start Sketch" button
await u.clearCommandLogs()
await page.getByRole('button', { name: 'Start Sketch' }).click()
await page.waitForTimeout(100)
// select a plane
await page.mouse.click(700, 200)
// select a plane
await page.mouse.click(700, 200)
await expect(page.locator('.cm-content')).toHaveText(
`sketch001 = startSketchOn(XZ)`
)
await u.closeDebugPanel()
await expect(page.locator('.cm-content')).toHaveText(
`@settings(defaultLengthUnit = in)sketch001 = startSketchOn(XZ)`
)
await u.closeDebugPanel()
await page.waitForTimeout(500) // TODO detect animation ending, or disable animation
await page.waitForTimeout(500) // TODO detect animation ending, or disable animation
const startXPx = 600
await page.mouse.click(startXPx + PUR * 10, 500 - PUR * 10)
await expect(page.locator('.cm-content')).toHaveText(
`sketch001 = startSketchOn(XZ)profile001 = startProfile(sketch001, at = ${commonPoints.startAt})`
)
await page.waitForTimeout(100)
const startXPx = 600
await page.mouse.click(startXPx + PUR * 10, 500 - PUR * 10)
await expect(page.locator('.cm-content')).toHaveText(
`@settings(defaultLengthUnit = in)sketch001 = startSketchOn(XZ)profile001 = startProfile(sketch001, at = ${commonPoints.startAt})`
)
await page.waitForTimeout(100)
await page.mouse.click(startXPx + PUR * 20, 500 - PUR * 10)
await page.waitForTimeout(100)
await page.mouse.click(startXPx + PUR * 20, 500 - PUR * 10)
await page.waitForTimeout(100)
await expect(
page.locator('.cm-content')
).toHaveText(`@settings(defaultLengthUnit = in)sketch001 = startSketchOn(XZ)profile001 = startProfile(sketch001, at = ${commonPoints.startAt})
await expect(
page.locator('.cm-content')
).toHaveText(`sketch001 = startSketchOn(XZ)profile001 = startProfile(sketch001, at = ${commonPoints.startAt})
|> xLine(length = ${commonPoints.num1})`)
// Expect the network to be up
await networkToggle.hover()
await expect(
networkToggleConnectedText.or(networkToggleWeakText)
).toBeVisible()
// Expect the network to be up
await expect(networkToggle).toContainText('Connected')
// simulate network down
await u.emulateNetworkConditions({
offline: true,
// values of 0 remove any active throttling. crbug.com/456324#c9
latency: 0,
downloadThroughput: -1,
uploadThroughput: -1,
})
// simulate network down
await u.emulateNetworkConditions({
offline: true,
// values of 0 remove any active throttling. crbug.com/456324#c9
latency: 0,
downloadThroughput: -1,
uploadThroughput: -1,
})
// Expect the network to be down
await networkToggle.hover()
await expect(networkToggle).toContainText('Problem')
// Expect the network to be down
await expect(networkToggle).toContainText('Problem')
// Ensure we are not in sketch mode
await expect(
page.getByRole('button', { name: 'Exit Sketch' })
).not.toBeVisible()
await expect(
page.getByRole('button', { name: 'Start Sketch' })
).toBeVisible()
// Ensure we are not in sketch mode
await expect(
page.getByRole('button', { name: 'Exit Sketch' })
).not.toBeVisible()
await expect(
page.getByRole('button', { name: 'Start Sketch' })
).toBeVisible()
// simulate network up
await u.emulateNetworkConditions({
offline: false,
// values of 0 remove any active throttling. crbug.com/456324#c9
latency: 0,
downloadThroughput: -1,
uploadThroughput: -1,
})
// simulate network up
await u.emulateNetworkConditions({
offline: false,
// values of 0 remove any active throttling. crbug.com/456324#c9
latency: 0,
downloadThroughput: -1,
uploadThroughput: -1,
})
// Wait for the app to be ready for use
await expect(
page.getByRole('button', { name: 'Start Sketch' })
).not.toBeDisabled({ timeout: 15000 })
// Wait for the app to be ready for use
await expect(
page.getByRole('button', { name: 'Start Sketch' })
).not.toBeDisabled({ timeout: 15000 })
// Expect the network to be up
await networkToggle.hover()
await expect(
networkToggleConnectedText.or(networkToggleWeakText)
).toBeVisible()
// Expect the network to be up
await expect(networkToggle).toContainText('Connected')
await scene.settled(cmdBar)
await scene.settled(cmdBar)
// Click off the code pane.
await page.mouse.click(100, 100)
// Click off the code pane.
await page.mouse.click(100, 100)
// select a line
await page
.getByText(`startProfile(sketch001, at = ${commonPoints.startAt})`)
.click()
// select a line
await page
.getByText(`startProfile(sketch001, at = ${commonPoints.startAt})`)
.click()
// enter sketch again
await toolbar.editSketch()
// enter sketch again
await toolbar.editSketch()
// Click the line tool
await page
.getByRole('button', { name: 'line Line', exact: true })
.click()
// Click the line tool
await page.getByRole('button', { name: 'line Line', exact: true }).click()
await page.waitForTimeout(150)
await page.waitForTimeout(150)
const camCommand: EngineCommand = {
type: 'modeling_cmd_req',
cmd_id: uuidv4(),
cmd: {
type: 'default_camera_look_at',
center: { x: 109, y: 0, z: -152 },
vantage: { x: 115, y: -505, z: -152 },
up: { x: 0, y: 0, z: 1 },
},
}
const updateCamCommand: EngineCommand = {
type: 'modeling_cmd_req',
cmd_id: uuidv4(),
cmd: {
type: 'default_camera_get_settings',
},
}
await toolbar.openPane('debug')
await u.sendCustomCmd(camCommand)
await page.waitForTimeout(100)
await u.sendCustomCmd(updateCamCommand)
await page.waitForTimeout(100)
const camCommand: EngineCommand = {
type: 'modeling_cmd_req',
cmd_id: uuidv4(),
cmd: {
type: 'default_camera_look_at',
center: { x: 109, y: 0, z: -152 },
vantage: { x: 115, y: -505, z: -152 },
up: { x: 0, y: 0, z: 1 },
},
}
const updateCamCommand: EngineCommand = {
type: 'modeling_cmd_req',
cmd_id: uuidv4(),
cmd: {
type: 'default_camera_get_settings',
},
}
await toolbar.openPane('debug')
await u.sendCustomCmd(camCommand)
await page.waitForTimeout(100)
await u.sendCustomCmd(updateCamCommand)
await page.waitForTimeout(100)
// click to continue profile
await page.mouse.click(1007, 400)
await page.waitForTimeout(100)
// Ensure we can continue sketching
await page.mouse.click(startXPx + PUR * 20, 500 - PUR * 20)
await expect
.poll(u.normalisedEditorCode)
.toBe(`@settings(defaultLengthUnit = in)
sketch001 = startSketchOn(XZ)
// click to continue profile
await page.mouse.click(1007, 400)
await page.waitForTimeout(100)
// Ensure we can continue sketching
await page.mouse.click(startXPx + PUR * 20, 500 - PUR * 20)
await expect
.poll(u.normalisedEditorCode)
.toBe(`sketch001 = startSketchOn(XZ)
profile001 = startProfile(sketch001, at = [12.34, -12.34])
|> xLine(length = 12.34)
|> line(end = [-12.34, 12.34])
`)
await page.waitForTimeout(100)
await page.mouse.click(startXPx, 500 - PUR * 20)
await page.waitForTimeout(100)
await page.mouse.click(startXPx, 500 - PUR * 20)
await expect
.poll(u.normalisedEditorCode)
.toBe(`@settings(defaultLengthUnit = in)
sketch001 = startSketchOn(XZ)
await expect
.poll(u.normalisedEditorCode)
.toBe(`sketch001 = startSketchOn(XZ)
profile001 = startProfile(sketch001, at = [12.34, -12.34])
|> xLine(length = 12.34)
|> line(end = [-12.34, 12.34])
@ -259,105 +238,22 @@ profile001 = startProfile(sketch001, at = [12.34, -12.34])
`)
// Unequip line tool
await page.keyboard.press('Escape')
// Make sure we didn't pop out of sketch mode.
await expect(
page.getByRole('button', { name: 'Exit Sketch' })
).toBeVisible()
await expect(
page.getByRole('button', { name: 'line Line', exact: true })
).not.toHaveAttribute('aria-pressed', 'true')
// Unequip line tool
await page.keyboard.press('Escape')
// Make sure we didn't pop out of sketch mode.
await expect(
page.getByRole('button', { name: 'Exit Sketch' })
).toBeVisible()
await expect(
page.getByRole('button', { name: 'line Line', exact: true })
).not.toHaveAttribute('aria-pressed', 'true')
// Exit sketch
await page.keyboard.press('Escape')
await expect(
page.getByRole('button', { name: 'Exit Sketch' })
).not.toBeVisible()
}
)
test(
'Paused stream freezes view frame, unpause reconnect is seamless to user',
{ tag: ['@electron', '@skipLocalEngine'] },
async ({ page, homePage, scene, cmdBar, toolbar, tronApp }) => {
const networkToggle = page.getByTestId('network-toggle')
const networkToggleConnectedText = page.getByText('Connected')
const networkToggleWeakText = page.getByText('Network health (Weak)')
if (!tronApp) {
fail()
// Exit sketch
await page.keyboard.press('Escape')
await expect(
page.getByRole('button', { name: 'Exit Sketch' })
).not.toBeVisible()
}
await tronApp.cleanProjectDir({
app: {
stream_idle_mode: 5000,
},
})
await page.addInitScript(async () => {
localStorage.setItem(
'persistCode',
`sketch001 = startSketchOn(XY)
profile001 = startProfile(sketch001, at = [0.0, 0.0])
|> line(end = [10.0, 0])
|> line(end = [0, 10.0])
|> close()`
)
})
const dim = { width: 1200, height: 500 }
await page.setBodyDimensions(dim)
await test.step('Go to modeling scene', async () => {
await homePage.goToModelingScene()
await scene.settled(cmdBar)
})
await test.step('Verify pausing behavior', async () => {
// Wait 5s + 1s to pause.
await page.waitForTimeout(6000)
// We should now be paused. To the user, it should appear we're still
// connected.
await networkToggle.hover()
await expect(
networkToggleConnectedText.or(networkToggleWeakText)
).toBeVisible()
const center = {
x: dim.width / 2,
y: dim.height / 2,
}
let probe = { x: 0, y: 0 }
// ... and the model's still visibly there
probe.x = center.x + dim.width / 100
probe.y = center.y
await scene.expectPixelColor(TEST_COLORS.GREY, probe, 15)
probe = { ...center }
// Now move the mouse around to unpause!
await circleMove(page, probe.x, probe.y, 20, 10)
// ONCE AGAIN! Check the view area hasn't changed at all.
// Check the pixel a couple times as it reconnects.
// NOTE: Remember, idle behavior is still on at this point -
// if this test takes longer than 5s shit WILL go south!
probe.x = center.x + dim.width / 100
probe.y = center.y
await scene.expectPixelColor(TEST_COLORS.GREY, probe, 15)
await page.waitForTimeout(1000)
await scene.expectPixelColor(TEST_COLORS.GREY, probe, 15)
probe = { ...center }
// Ensure we're still connected
await networkToggle.hover()
await expect(
networkToggleConnectedText.or(networkToggleWeakText)
).toBeVisible()
})
}
)
})
)
}
)

View File

@ -44,8 +44,6 @@ export const lowerRightMasks = (page: Page) => [
export type TestColor = [number, number, number]
export const TEST_COLORS: { [key: string]: TestColor } = {
WHITE: [249, 249, 249],
OFFWHITE: [237, 237, 237],
GREY: [142, 142, 142],
YELLOW: [255, 255, 0],
BLUE: [0, 0, 255],
DARK_MODE_BKGD: [27, 27, 27],

View File

@ -41,6 +41,14 @@ interface LSPRequestMap {
LSP.DefinitionParams,
LSP.Definition | LSP.DefinitionLink[] | null,
]
'textDocument/documentColor': [
LSP.DocumentColorParams,
LSP.ColorInformation[] | null,
]
'textDocument/colorPresentation': [
LSP.ColorPresentationParams,
LSP.ColorPresentation[] | null,
]
}
// Client to server
@ -229,6 +237,22 @@ export class LanguageServerClient {
return await this.request('textDocument/definition', params)
}
async textDocumentDocumentColor(params: LSP.DocumentColorParams) {
const serverCapabilities = this.getServerCapabilities()
if (!serverCapabilities.colorProvider) {
return null
}
return await this.request('textDocument/documentColor', params)
}
async textDocumentColorPresentation(params: LSP.ColorPresentationParams) {
const serverCapabilities = this.getServerCapabilities()
if (!serverCapabilities.colorProvider) {
return null
}
return await this.request('textDocument/colorPresentation', params)
}
attachPlugin(plugin: LanguageServerPlugin) {
this.plugins.push(plugin)
}

View File

@ -21,6 +21,7 @@ export {
lspRenameEvent,
lspSemanticTokensEvent,
lspCodeActionEvent,
lspColorUpdateEvent,
} from './plugin/annotation'
export {
LanguageServerPlugin,

View File

@ -6,6 +6,7 @@ export enum LspAnnotation {
Diagnostics = 'diagnostics',
Rename = 'rename',
CodeAction = 'code-action',
ColorUpdate = 'color-update',
}
const lspEvent = Annotation.define<LspAnnotation>()
@ -14,3 +15,4 @@ export const lspFormatCodeEvent = lspEvent.of(LspAnnotation.FormatCode)
export const lspDiagnosticsEvent = lspEvent.of(LspAnnotation.Diagnostics)
export const lspRenameEvent = lspEvent.of(LspAnnotation.Rename)
export const lspCodeActionEvent = lspEvent.of(LspAnnotation.CodeAction)
export const lspColorUpdateEvent = lspEvent.of(LspAnnotation.ColorUpdate)

View File

@ -0,0 +1,278 @@
import {
StateEffect,
StateField,
type Extension,
type Range,
} from '@codemirror/state'
import {
Decoration,
type DecorationSet,
EditorView,
ViewPlugin,
WidgetType,
type ViewUpdate,
} from '@codemirror/view'
import type { LanguageServerPlugin } from './lsp'
import { lspColorUpdateEvent } from './annotation'
import { isArray } from '../lib/utils'
import { offsetToPos, posToOffset, posToOffsetOrZero } from './util'
import type * as LSP from 'vscode-languageserver-protocol'
/* ------------------------------------------------------------------ */
/* ---------- original helpers / widget / color utilities ---------- */
/* ------------------------------------------------------------------ */
interface PickerState {
from: number
to: number
red: number
green: number
blue: number
alpha: number
}
export interface WidgetOptions extends PickerState {
color: string
}
export type ColorData = Omit<WidgetOptions, 'from' | 'to'>
const pickerState = new WeakMap<HTMLInputElement, PickerState>()
function rgbaToHex(color: LSP.Color): string {
return (
'#' +
[color.red, color.green, color.blue]
.map((c) =>
Math.round(c * 255)
.toString(16)
.padStart(2, '0')
)
.join('')
)
}
function hexToRGBComponents(hex: string): number[] {
const r = hex.slice(1, 3)
const g = hex.slice(3, 5)
const b = hex.slice(5, 7)
return [parseInt(r, 16) / 255, parseInt(g, 16) / 255, parseInt(b, 16) / 255]
}
async function discoverColorsViaLsp(
view: EditorView,
plugin: LanguageServerPlugin
): Promise<WidgetOptions | Array<WidgetOptions> | null> {
const responses = await plugin.requestDocumentColors()
if (!responses) return null
const colors: Array<WidgetOptions> = []
for (const color of responses) {
if (!color.range || !color.color) continue
const { start, end } = color.range
const from = posToOffset(view.state.doc, start)
const to = posToOffset(view.state.doc, end)
if (from == null || to == null) continue
colors.push({
color: rgbaToHex(color.color),
...color.color,
from,
to,
})
}
return colors
}
async function colorPickersDecorations(
view: EditorView,
plugin: LanguageServerPlugin
): Promise<DecorationSet> {
const widgets: Array<Range<Decoration>> = []
const maybe = await discoverColorsViaLsp(view, plugin)
if (!maybe) return Decoration.none
const optionsList = isArray(maybe) ? maybe : [maybe]
for (const wo of optionsList) {
widgets.push(
Decoration.widget({
widget: new ColorPickerWidget(wo),
side: 1,
}).range(wo.from)
)
}
return Decoration.set(widgets)
}
export const wrapperClassName = 'cm-css-color-picker-wrapper'
class ColorPickerWidget extends WidgetType {
private readonly state: PickerState
private readonly color: string
constructor({ color, ...state }: WidgetOptions) {
super()
this.state = state
this.color = color
}
eq(other: ColorPickerWidget) {
return (
other.color === this.color &&
other.state.from === this.state.from &&
other.state.to === this.state.to &&
other.state.alpha === this.state.alpha
)
}
toDOM() {
const picker = document.createElement('input')
pickerState.set(picker, this.state)
picker.type = 'color'
picker.value = this.color
const wrapper = document.createElement('span')
wrapper.appendChild(picker)
wrapper.className = wrapperClassName
return wrapper
}
ignoreEvent() {
return false
}
}
export const colorPickerTheme = EditorView.baseTheme({
[`.${wrapperClassName}`]: {
display: 'inline-block',
outline: '1px solid #eee',
marginRight: '0.6ch',
height: '1em',
width: '1em',
transform: 'translateY(1px)',
},
[`.${wrapperClassName} input[type="color"]`]: {
cursor: 'pointer',
height: '100%',
width: '100%',
padding: 0,
border: 'none',
'&::-webkit-color-swatch-wrapper': { padding: 0 },
'&::-webkit-color-swatch': { border: 'none' },
'&::-moz-color-swatch': { border: 'none' },
},
})
/* ------------------------------------------------------------------ */
/* ------------------- ✅ new state machinery -------------------- */
/* ------------------------------------------------------------------ */
// Effect that carries a fresh DecorationSet
const setColorDecorations = StateEffect.define<DecorationSet>()
// Field that stores the current DecorationSet
const colorDecorationsField = StateField.define<DecorationSet>({
create: () => Decoration.none,
update(value, tr) {
value = value.map(tr.changes)
for (const e of tr.effects) if (e.is(setColorDecorations)) value = e.value
return value
},
provide: (f) => EditorView.decorations.from(f),
})
/* ------------------------------------------------------------------ */
/* ------------------ original ViewPlugin, patched ---------------- */
/* ------------------------------------------------------------------ */
export const makeColorPicker = (plugin: ViewPlugin<LanguageServerPlugin>) =>
ViewPlugin.fromClass(
class ColorPickerViewPlugin {
plugin: LanguageServerPlugin | null
constructor(view: EditorView) {
this.plugin = view.plugin(plugin)
if (!this.plugin) return
// initial async load → dispatch decorations
// eslint-disable-next-line @typescript-eslint/no-floating-promises
colorPickersDecorations(view, this.plugin).then((deco) => {
view.dispatch({ effects: setColorDecorations.of(deco) })
})
}
async update(update: ViewUpdate) {
if (!this.plugin) return
if (!(update.docChanged || update.viewportChanged)) return
const deco = await colorPickersDecorations(update.view, this.plugin)
update.view.dispatch({ effects: setColorDecorations.of(deco) })
}
},
{
eventHandlers: {
change: (e: Event, view: EditorView) => {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
colorPickerChange(e, view, plugin)
},
},
}
)
/* ------------------------------------------------------------------ */
/* -------------------- unchanged event handler ------------------- */
/* ------------------------------------------------------------------ */
async function colorPickerChange(
e: Event,
view: EditorView,
plugin: ViewPlugin<LanguageServerPlugin>
): Promise<boolean> {
const value = view.plugin(plugin)
if (!value) return false
const target = e.target as HTMLInputElement
if (
target.nodeName !== 'INPUT' ||
!target.parentElement?.classList.contains(wrapperClassName)
)
return false
const data = pickerState.get(target)!
const converted = target.value + data.alpha
const [red, green, blue] = hexToRGBComponents(converted)
const responses = await value.requestColorPresentation(
{ red, green, blue, alpha: data.alpha },
{
start: offsetToPos(view.state.doc, data.from),
end: offsetToPos(view.state.doc, data.to),
}
)
if (!responses?.length) return false
for (const resp of responses) {
const changes = resp.textEdit
? {
from: posToOffsetOrZero(view.state.doc, resp.textEdit.range.start),
to: posToOffsetOrZero(view.state.doc, resp.textEdit.range.end),
insert: resp.textEdit.newText,
}
: { from: data.from, to: data.to, insert: resp.label }
view.dispatch({ changes, annotations: [lspColorUpdateEvent] })
}
return true
}
/* ------------------------------------------------------------------ */
/* ------------------------- public API --------------------------- */
/* ------------------------------------------------------------------ */
export default function lspColorsExt(
plugin: ViewPlugin<LanguageServerPlugin>
): Extension {
return [colorDecorationsField, makeColorPicker(plugin), colorPickerTheme]
}

View File

@ -48,6 +48,7 @@ import { isArray } from '../lib/utils'
import lspGoToDefinitionExt from './go-to-definition'
import lspRenameExt from './rename'
import lspSignatureHelpExt from './signature-help'
import lspColorsExt from './colors'
const useLast = (values: readonly any[]) => values.reduce((_, v) => v, '')
export const docPathFacet = Facet.define<string, string>({
@ -534,6 +535,37 @@ export class LanguageServerPlugin implements PluginValue {
})
}
async requestDocumentColors() {
if (
!(this.client.getServerCapabilities().colorProvider && this.client.ready)
) {
return
}
const result = await this.client.textDocumentDocumentColor({
textDocument: { uri: this.getDocUri() },
})
if (!result) return
return result
}
async requestColorPresentation(color: LSP.Color, range: LSP.Range) {
if (
!(this.client.getServerCapabilities().colorProvider && this.client.ready)
) {
return
}
const result = await this.client.textDocumentColorPresentation({
textDocument: { uri: this.getDocUri() },
color,
range,
})
if (!result) return
return result
}
async requestRename(
view: EditorView,
{ line, character }: { line: number; character: number }
@ -1318,6 +1350,7 @@ export class LanguageServerPluginSpec
return [
linter(null),
lspAutocompleteExt(plugin),
lspColorsExt(plugin),
lspFormatExt(plugin),
lspGoToDefinitionExt(plugin),
lspHoverExt(plugin),

20
rust/Cargo.lock generated
View File

@ -1815,7 +1815,7 @@ dependencies = [
[[package]]
name = "kcl-bumper"
version = "0.1.72"
version = "0.1.70"
dependencies = [
"anyhow",
"clap",
@ -1826,7 +1826,7 @@ dependencies = [
[[package]]
name = "kcl-derive-docs"
version = "0.1.72"
version = "0.1.70"
dependencies = [
"Inflector",
"anyhow",
@ -1845,7 +1845,7 @@ dependencies = [
[[package]]
name = "kcl-directory-test-macro"
version = "0.1.72"
version = "0.1.70"
dependencies = [
"proc-macro2",
"quote",
@ -1854,7 +1854,7 @@ dependencies = [
[[package]]
name = "kcl-language-server"
version = "0.2.72"
version = "0.2.70"
dependencies = [
"anyhow",
"clap",
@ -1875,7 +1875,7 @@ dependencies = [
[[package]]
name = "kcl-language-server-release"
version = "0.1.72"
version = "0.1.70"
dependencies = [
"anyhow",
"clap",
@ -1895,7 +1895,7 @@ dependencies = [
[[package]]
name = "kcl-lib"
version = "0.2.72"
version = "0.2.70"
dependencies = [
"anyhow",
"approx 0.5.1",
@ -1971,7 +1971,7 @@ dependencies = [
[[package]]
name = "kcl-python-bindings"
version = "0.3.72"
version = "0.3.70"
dependencies = [
"anyhow",
"kcl-lib",
@ -1986,7 +1986,7 @@ dependencies = [
[[package]]
name = "kcl-test-server"
version = "0.1.72"
version = "0.1.70"
dependencies = [
"anyhow",
"hyper 0.14.32",
@ -1999,7 +1999,7 @@ dependencies = [
[[package]]
name = "kcl-to-core"
version = "0.1.72"
version = "0.1.70"
dependencies = [
"anyhow",
"async-trait",
@ -2013,7 +2013,7 @@ dependencies = [
[[package]]
name = "kcl-wasm-lib"
version = "0.1.72"
version = "0.1.70"
dependencies = [
"anyhow",
"bson",

View File

@ -1,7 +1,7 @@
[package]
name = "kcl-bumper"
version = "0.1.72"
version = "0.1.70"
edition = "2021"
repository = "https://github.com/KittyCAD/modeling-api"
rust-version = "1.76"

View File

@ -1,7 +1,7 @@
[package]
name = "kcl-derive-docs"
description = "A tool for generating documentation from Rust derive macros"
version = "0.1.72"
version = "0.1.70"
edition = "2021"
license = "MIT"
repository = "https://github.com/KittyCAD/modeling-app"

View File

@ -1,7 +1,7 @@
[package]
name = "kcl-directory-test-macro"
description = "A tool for generating tests from a directory of kcl files"
version = "0.1.72"
version = "0.1.70"
edition = "2021"
license = "MIT"
repository = "https://github.com/KittyCAD/modeling-app"

View File

@ -1,6 +1,6 @@
[package]
name = "kcl-language-server-release"
version = "0.1.72"
version = "0.1.70"
edition = "2021"
authors = ["KittyCAD Inc <kcl@kittycad.io>"]
publish = false

View File

@ -2,7 +2,7 @@
name = "kcl-language-server"
description = "A language server for KCL."
authors = ["KittyCAD Inc <kcl@kittycad.io>"]
version = "0.2.72"
version = "0.2.70"
edition = "2021"
license = "MIT"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

View File

@ -1,7 +1,7 @@
[package]
name = "kcl-lib"
description = "KittyCAD Language implementation and tools"
version = "0.2.72"
version = "0.2.70"
edition = "2021"
license = "MIT"
repository = "https://github.com/KittyCAD/modeling-app"

View File

@ -788,7 +788,6 @@ impl ArgData {
Some("Axis2d | Edge") | Some("Axis3d | Edge") => Some((index, format!(r#"{label}${{{index}:X}}"#))),
Some("Edge") => Some((index, format!(r#"{label}${{{index}:tag_or_edge_fn}}"#))),
Some("[Edge; 1+]") => Some((index, format!(r#"{label}[${{{index}:tag_or_edge_fn}}]"#))),
Some("Plane") => Some((index, format!(r#"{label}${{{}:XY}}"#, index))),
Some("string") => Some((index, format!(r#"{label}${{{}:"string"}}"#, index))),
Some("bool") => Some((index, format!(r#"{label}${{{}:false}}"#, index))),

View File

@ -1167,16 +1167,6 @@ mod tests {
assert_eq!(snippet, r#"clone(${0:part001})"#);
}
#[test]
fn get_autocomplete_snippet_offset_plane() {
let data = kcl_doc::walk_prelude();
let DocData::Fn(offset_plane_fn) = data.find_by_name("offsetPlane").unwrap() else {
panic!();
};
let snippet = offset_plane_fn.to_autocomplete_snippet();
assert_eq!(snippet, r#"offsetPlane(${0:XY}, offset = ${1:3.14})"#);
}
// We want to test the snippets we compile at lsp start.
#[test]
fn get_all_stdlib_autocomplete_snippets() {

View File

@ -4316,64 +4316,3 @@ sketch001 = startSketchOn(XY)
}]
);
}
#[tokio::test(flavor = "multi_thread")]
async fn test_kcl_lsp_diagnostic_compilation_warnings() {
let server = kcl_lsp_server(false).await.unwrap();
// Send open file.
server
.did_open(tower_lsp::lsp_types::DidOpenTextDocumentParams {
text_document: tower_lsp::lsp_types::TextDocumentItem {
uri: "file:///test.kcl".try_into().unwrap(),
language_id: "kcl".to_string(),
version: 1,
text: r#"foo = 42
@settings(defaultLengthUnit = mm)"#
.to_string(),
},
})
.await;
// Send diagnostics request.
let diagnostics = server
.diagnostic(tower_lsp::lsp_types::DocumentDiagnosticParams {
text_document: tower_lsp::lsp_types::TextDocumentIdentifier {
uri: "file:///test.kcl".try_into().unwrap(),
},
partial_result_params: Default::default(),
work_done_progress_params: Default::default(),
identifier: None,
previous_result_id: None,
})
.await
.unwrap();
// Check the diagnostics.
if let tower_lsp::lsp_types::DocumentDiagnosticReportResult::Report(diagnostics) = diagnostics {
if let tower_lsp::lsp_types::DocumentDiagnosticReport::Full(diagnostics) = diagnostics {
assert_eq!(diagnostics.full_document_diagnostic_report.items.len(), 1);
assert_eq!(
diagnostics.full_document_diagnostic_report.items[0],
tower_lsp::lsp_types::Diagnostic {
range: tower_lsp::lsp_types::Range {
start: tower_lsp::lsp_types::Position { line: 0, character: 8 },
end: tower_lsp::lsp_types::Position { line: 1, character: 33 },
},
severity: Some(tower_lsp::lsp_types::DiagnosticSeverity::WARNING),
code: None,
source: Some("kcl".to_string()),
message: "Named attributes should appear before any declarations or expressions.\n\nBecause named attributes apply to the whole function or module, including code written before them, it can be confusing for readers to not have these attributes at the top of code blocks.".to_string(),
related_information: None,
tags: None,
data: None,
code_description: None,
}
);
} else {
panic!("Expected full diagnostics");
}
} else {
panic!("Expected diagnostics");
}
}

View File

@ -96,7 +96,6 @@ pub(crate) fn read_std(mod_name: &str) -> Option<&'static str> {
"solid" => Some(include_str!("../std/solid.kcl")),
"units" => Some(include_str!("../std/units.kcl")),
"array" => Some(include_str!("../std/array.kcl")),
"sweep" => Some(include_str!("../std/sweep.kcl")),
"transform" => Some(include_str!("../std/transform.kcl")),
_ => None,
}

View File

@ -2748,6 +2748,7 @@ mod import_mesh_clone {
/// Test that KCL is executed correctly.
#[tokio::test(flavor = "multi_thread")]
#[ignore = "turn on when katie fixes the mesh import"]
async fn kcl_test_execute() {
super::execute(TEST_NAME, true).await
}

View File

@ -112,7 +112,7 @@ pub async fn sweep(exec_state: &mut ExecState, args: Args) -> Result<KclValue, K
/// // Create a spring by sweeping around the helix path.
/// springSketch = startSketchOn(YZ)
/// |> circle( center = [0, 0], radius = 1)
/// |> sweep(path = helixPath, relativeTo = "sketchPlane")
/// |> sweep(path = helixPath)
/// ```
///
/// ```no_run
@ -167,7 +167,7 @@ pub async fn sweep(exec_state: &mut ExecState, args: Args) -> Result<KclValue, K
path = { docs = "The path to sweep the sketch along" },
sectional = { docs = "If true, the sweep will be broken up into sub-sweeps (extrusions, revolves, sweeps) based on the trajectory path components." },
tolerance = { docs = "Tolerance for this operation" },
relative_to = { docs = "What is the sweep relative to? Can be either 'sketchPlane' or 'trajectoryCurve'. Defaults to trajectoryCurve."},
relative_to = { docs = "What is the sweep relative to? Can be either 'sketchPlane' or 'trajectoryCurve'. Defaults to sketchPlane."},
tag_start = { docs = "A named tag for the face at the start of the sweep, i.e. the original sketch" },
tag_end = { docs = "A named tag for the face at the end of the sweep" },
},
@ -191,13 +191,14 @@ async fn inner_sweep(
};
let relative_to = match relative_to.as_deref() {
Some("sketchPlane") => RelativeTo::SketchPlane,
Some("trajectoryCurve") | None => RelativeTo::TrajectoryCurve,
Some("trajectoryCurve") => RelativeTo::TrajectoryCurve,
Some(_) => {
return Err(KclError::Syntax(crate::errors::KclErrorDetails {
source_ranges: vec![args.source_range],
message: "If you provide relativeTo, it must either be 'sketchPlane' or 'trajectoryCurve'".to_owned(),
}))
}
None => RelativeTo::default(),
};
let mut solids = Vec::new();

View File

@ -21,7 +21,6 @@ export import * from "std::sketch"
export import * from "std::solid"
export import * from "std::transform"
export import "std::turns"
export import "std::sweep"
/// An abstract 3d plane aligned with the X and Y axes. Its normal is the positive Z axis.
export XY = {
@ -84,7 +83,7 @@ export END = 'end'
/// // Create a spring by sweeping around the helix path.
/// springSketch = startSketchOn(YZ)
/// |> circle( center = [0, 0], radius = 0.5)
/// |> sweep(path = helixPath, relativeTo = sweep::SKETCH_PLANE)
/// |> sweep(path = helixPath)
/// ```
///
/// ```
@ -105,7 +104,7 @@ export END = 'end'
/// // Create a spring by sweeping around the helix path.
/// springSketch = startSketchOn(XY)
/// |> circle( center = [0, 0], radius = 0.5 )
/// |> sweep(path = helixPath, relativeTo = sweep::SKETCH_PLANE)
/// |> sweep(path = helixPath)
/// ```
///
/// ```
@ -125,7 +124,7 @@ export END = 'end'
/// // Create a spring by sweeping around the helix path.
/// springSketch = startSketchOn(XY)
/// |> circle( center = [0, 0], radius = 1 )
/// |> sweep(path = helixPath, relativeTo = sweep::SKETCH_PLANE)
/// |> sweep(path = helixPath)
/// ```
///
/// ```
@ -414,7 +413,7 @@ export fn offsetPlane(
/// // Create a spring by sweeping around the helix path.
/// sweepedSpring = clone(springSketch)
/// |> translate(x=100)
/// |> sweep(path = helixPath, relativeTo = sweep::SKETCH_PLANE)
/// |> sweep(path = helixPath)
/// ```
///
/// ```kcl

View File

@ -1,5 +0,0 @@
/// Local/relative to the trajectory curve
export TRAJECTORY = 'trajectoryCurve'
/// Local/relative to a position centered within the plane being sketched on
export SKETCH_PLANE = 'sketchPlane'

View File

@ -996,51 +996,10 @@ description: Artifact commands import_mesh_clone.kcl
"direction": "positive"
}
},
"units": "m"
"units": "mm"
}
}
},
{
"cmdId": "[uuid]",
"range": [],
"command": {
"type": "set_object_transform",
"object_id": "[uuid]",
"transforms": [
{
"translate": {
"property": {
"x": -2000.0,
"y": -2000.0,
"z": 0.0
},
"set": false,
"is_local": true
},
"rotate_rpy": null,
"rotate_angle_axis": null,
"scale": null
}
]
}
},
{
"cmdId": "[uuid]",
"range": [],
"command": {
"type": "object_set_material_params_pbr",
"object_id": "[uuid]",
"color": {
"r": 1.0,
"g": 1.0,
"b": 0.0,
"a": 100.0
},
"metalness": 0.0,
"roughness": 0.0,
"ambient_occlusion": 0.0
}
},
{
"cmdId": "[uuid]",
"range": [],
@ -1049,22 +1008,6 @@ description: Artifact commands import_mesh_clone.kcl
"entity_id": "[uuid]"
}
},
{
"cmdId": "[uuid]",
"range": [],
"command": {
"type": "entity_get_all_child_uuids",
"entity_id": "[uuid]"
}
},
{
"cmdId": "[uuid]",
"range": [],
"command": {
"type": "entity_get_all_child_uuids",
"entity_id": "[uuid]"
}
},
{
"cmdId": "[uuid]",
"range": [],
@ -1075,7 +1018,7 @@ description: Artifact commands import_mesh_clone.kcl
{
"translate": {
"property": {
"x": 4000.0,
"x": 1020.0,
"y": 0.0,
"z": 0.0
},
@ -1101,138 +1044,8 @@ description: Artifact commands import_mesh_clone.kcl
"b": 0.0,
"a": 100.0
},
"metalness": 0.0,
"roughness": 0.0,
"ambient_occlusion": 0.0
}
},
{
"cmdId": "[uuid]",
"range": [],
"command": {
"type": "entity_clone",
"entity_id": "[uuid]"
}
},
{
"cmdId": "[uuid]",
"range": [],
"command": {
"type": "entity_get_all_child_uuids",
"entity_id": "[uuid]"
}
},
{
"cmdId": "[uuid]",
"range": [],
"command": {
"type": "entity_get_all_child_uuids",
"entity_id": "[uuid]"
}
},
{
"cmdId": "[uuid]",
"range": [],
"command": {
"type": "set_object_transform",
"object_id": "[uuid]",
"transforms": [
{
"translate": {
"property": {
"x": 0.0,
"y": 4000.0,
"z": 0.0
},
"set": false,
"is_local": true
},
"rotate_rpy": null,
"rotate_angle_axis": null,
"scale": null
}
]
}
},
{
"cmdId": "[uuid]",
"range": [],
"command": {
"type": "object_set_material_params_pbr",
"object_id": "[uuid]",
"color": {
"r": 0.0,
"g": 1.0,
"b": 0.0,
"a": 100.0
},
"metalness": 0.0,
"roughness": 0.0,
"ambient_occlusion": 0.0
}
},
{
"cmdId": "[uuid]",
"range": [],
"command": {
"type": "entity_clone",
"entity_id": "[uuid]"
}
},
{
"cmdId": "[uuid]",
"range": [],
"command": {
"type": "entity_get_all_child_uuids",
"entity_id": "[uuid]"
}
},
{
"cmdId": "[uuid]",
"range": [],
"command": {
"type": "entity_get_all_child_uuids",
"entity_id": "[uuid]"
}
},
{
"cmdId": "[uuid]",
"range": [],
"command": {
"type": "set_object_transform",
"object_id": "[uuid]",
"transforms": [
{
"translate": {
"property": {
"x": 0.0,
"y": 4000.0,
"z": 0.0
},
"set": false,
"is_local": true
},
"rotate_rpy": null,
"rotate_angle_axis": null,
"scale": null
}
]
}
},
{
"cmdId": "[uuid]",
"range": [],
"command": {
"type": "object_set_material_params_pbr",
"object_id": "[uuid]",
"color": {
"r": 0.0,
"g": 0.0,
"b": 1.0,
"a": 100.0
},
"metalness": 0.0,
"roughness": 0.0,
"metalness": 0.5,
"roughness": 0.5,
"ambient_occlusion": 0.0
}
}

View File

@ -8,46 +8,6 @@ description: Result of parsing import_mesh_clone.kcl
{
"commentStart": 0,
"end": 0,
"outerAttrs": [
{
"commentStart": 0,
"end": 0,
"name": null,
"properties": [
{
"commentStart": 0,
"end": 0,
"key": {
"commentStart": 0,
"end": 0,
"name": "lengthUnit",
"start": 0,
"type": "Identifier"
},
"start": 0,
"type": "ObjectProperty",
"value": {
"abs_path": false,
"commentStart": 0,
"end": 0,
"name": {
"commentStart": 0,
"end": 0,
"name": "m",
"start": 0,
"type": "Identifier"
},
"path": [],
"start": 0,
"type": "Name",
"type": "Name"
}
}
],
"start": 0,
"type": "Annotation"
}
],
"path": {
"type": "Foreign",
"path": "../inputs/cube.obj"
@ -57,7 +17,7 @@ description: Result of parsing import_mesh_clone.kcl
"alias": {
"commentStart": 0,
"end": 0,
"name": "yellow",
"name": "cube",
"start": 0,
"type": "Identifier"
}
@ -68,180 +28,24 @@ description: Result of parsing import_mesh_clone.kcl
},
{
"commentStart": 0,
"end": 0,
"expression": {
"body": [
{
"abs_path": false,
"commentStart": 0,
"end": 0,
"name": {
"commentStart": 0,
"end": 0,
"name": "yellow",
"start": 0,
"type": "Identifier"
},
"path": [],
"start": 0,
"type": "Name",
"type": "Name"
},
{
"arguments": [
{
"type": "LabeledArg",
"label": {
"commentStart": 0,
"end": 0,
"name": "x",
"start": 0,
"type": "Identifier"
},
"arg": {
"argument": {
"commentStart": 0,
"end": 0,
"raw": "2000",
"start": 0,
"type": "Literal",
"type": "Literal",
"value": {
"value": 2000.0,
"suffix": "None"
}
},
"commentStart": 0,
"end": 0,
"operator": "-",
"start": 0,
"type": "UnaryExpression",
"type": "UnaryExpression"
}
},
{
"type": "LabeledArg",
"label": {
"commentStart": 0,
"end": 0,
"name": "y",
"start": 0,
"type": "Identifier"
},
"arg": {
"argument": {
"commentStart": 0,
"end": 0,
"raw": "2000",
"start": 0,
"type": "Literal",
"type": "Literal",
"value": {
"value": 2000.0,
"suffix": "None"
}
},
"commentStart": 0,
"end": 0,
"operator": "-",
"start": 0,
"type": "UnaryExpression",
"type": "UnaryExpression"
}
}
],
"callee": {
"abs_path": false,
"commentStart": 0,
"end": 0,
"name": {
"commentStart": 0,
"end": 0,
"name": "translate",
"start": 0,
"type": "Identifier"
},
"path": [],
"start": 0,
"type": "Name"
},
"commentStart": 0,
"end": 0,
"start": 0,
"type": "CallExpressionKw",
"type": "CallExpressionKw",
"unlabeled": {
"commentStart": 0,
"end": 0,
"start": 0,
"type": "PipeSubstitution",
"type": "PipeSubstitution"
}
}
],
"declaration": {
"commentStart": 0,
"end": 0,
"start": 0,
"type": "PipeExpression",
"type": "PipeExpression"
},
"start": 0,
"type": "ExpressionStatement",
"type": "ExpressionStatement"
},
{
"commentStart": 0,
"end": 0,
"expression": {
"arguments": [
{
"type": "LabeledArg",
"label": {
"commentStart": 0,
"end": 0,
"name": "color",
"start": 0,
"type": "Identifier"
},
"arg": {
"commentStart": 0,
"end": 0,
"raw": "\"#ffff00\"",
"start": 0,
"type": "Literal",
"type": "Literal",
"value": "#ffff00"
}
}
],
"callee": {
"abs_path": false,
"id": {
"commentStart": 0,
"end": 0,
"name": {
"commentStart": 0,
"end": 0,
"name": "appearance",
"start": 0,
"type": "Identifier"
},
"path": [],
"name": "model",
"start": 0,
"type": "Name"
"type": "Identifier"
},
"commentStart": 0,
"end": 0,
"start": 0,
"type": "CallExpressionKw",
"type": "CallExpressionKw",
"unlabeled": {
"init": {
"abs_path": false,
"commentStart": 0,
"end": 0,
"name": {
"commentStart": 0,
"end": 0,
"name": "yellow",
"name": "cube",
"start": 0,
"type": "Identifier"
},
@ -249,11 +53,15 @@ description: Result of parsing import_mesh_clone.kcl
"start": 0,
"type": "Name",
"type": "Name"
}
},
"start": 0,
"type": "VariableDeclarator"
},
"end": 0,
"kind": "const",
"start": 0,
"type": "ExpressionStatement",
"type": "ExpressionStatement"
"type": "VariableDeclaration",
"type": "VariableDeclaration"
},
{
"commentStart": 0,
@ -263,7 +71,7 @@ description: Result of parsing import_mesh_clone.kcl
"id": {
"commentStart": 0,
"end": 0,
"name": "red",
"name": "model2",
"start": 0,
"type": "Identifier"
},
@ -297,7 +105,7 @@ description: Result of parsing import_mesh_clone.kcl
"name": {
"commentStart": 0,
"end": 0,
"name": "yellow",
"name": "model",
"start": 0,
"type": "Identifier"
},
@ -321,12 +129,12 @@ description: Result of parsing import_mesh_clone.kcl
"arg": {
"commentStart": 0,
"end": 0,
"raw": "4000",
"raw": "1020",
"start": 0,
"type": "Literal",
"type": "Literal",
"value": {
"value": 4000.0,
"value": 1020.0,
"suffix": "None"
}
}
@ -352,147 +160,7 @@ description: Result of parsing import_mesh_clone.kcl
"start": 0,
"type": "CallExpressionKw",
"type": "CallExpressionKw",
"unlabeled": {
"commentStart": 0,
"end": 0,
"start": 0,
"type": "PipeSubstitution",
"type": "PipeSubstitution"
}
}
],
"commentStart": 0,
"end": 0,
"start": 0,
"type": "PipeExpression",
"type": "PipeExpression"
},
"start": 0,
"type": "VariableDeclarator"
},
"end": 0,
"kind": "const",
"start": 0,
"type": "VariableDeclaration",
"type": "VariableDeclaration"
},
{
"commentStart": 0,
"end": 0,
"expression": {
"arguments": [
{
"type": "LabeledArg",
"label": {
"commentStart": 0,
"end": 0,
"name": "color",
"start": 0,
"type": "Identifier"
},
"arg": {
"commentStart": 0,
"end": 0,
"raw": "\"#ff0000\"",
"start": 0,
"type": "Literal",
"type": "Literal",
"value": "#ff0000"
}
}
],
"callee": {
"abs_path": false,
"commentStart": 0,
"end": 0,
"name": {
"commentStart": 0,
"end": 0,
"name": "appearance",
"start": 0,
"type": "Identifier"
},
"path": [],
"start": 0,
"type": "Name"
},
"commentStart": 0,
"end": 0,
"start": 0,
"type": "CallExpressionKw",
"type": "CallExpressionKw",
"unlabeled": {
"abs_path": false,
"commentStart": 0,
"end": 0,
"name": {
"commentStart": 0,
"end": 0,
"name": "red",
"start": 0,
"type": "Identifier"
},
"path": [],
"start": 0,
"type": "Name",
"type": "Name"
}
},
"start": 0,
"type": "ExpressionStatement",
"type": "ExpressionStatement"
},
{
"commentStart": 0,
"declaration": {
"commentStart": 0,
"end": 0,
"id": {
"commentStart": 0,
"end": 0,
"name": "green",
"start": 0,
"type": "Identifier"
},
"init": {
"body": [
{
"callee": {
"abs_path": false,
"commentStart": 0,
"end": 0,
"name": {
"commentStart": 0,
"end": 0,
"name": "clone",
"start": 0,
"type": "Identifier"
},
"path": [],
"start": 0,
"type": "Name"
},
"commentStart": 0,
"end": 0,
"start": 0,
"type": "CallExpressionKw",
"type": "CallExpressionKw",
"unlabeled": {
"abs_path": false,
"commentStart": 0,
"end": 0,
"name": {
"commentStart": 0,
"end": 0,
"name": "yellow",
"start": 0,
"type": "Identifier"
},
"path": [],
"start": 0,
"type": "Name",
"type": "Name"
}
"unlabeled": null
},
{
"arguments": [
@ -501,206 +169,60 @@ description: Result of parsing import_mesh_clone.kcl
"label": {
"commentStart": 0,
"end": 0,
"name": "y",
"name": "color",
"start": 0,
"type": "Identifier"
},
"arg": {
"commentStart": 0,
"end": 0,
"raw": "4000",
"raw": "\"#ff0000\"",
"start": 0,
"type": "Literal",
"type": "Literal",
"value": {
"value": 4000.0,
"suffix": "None"
}
"value": "#ff0000"
}
}
],
"callee": {
"abs_path": false,
"commentStart": 0,
"end": 0,
"name": {
"commentStart": 0,
"end": 0,
"name": "translate",
"start": 0,
"type": "Identifier"
},
"path": [],
"start": 0,
"type": "Name"
},
"commentStart": 0,
"end": 0,
"start": 0,
"type": "CallExpressionKw",
"type": "CallExpressionKw",
"unlabeled": {
"commentStart": 0,
"end": 0,
"start": 0,
"type": "PipeSubstitution",
"type": "PipeSubstitution"
}
}
],
"commentStart": 0,
"end": 0,
"start": 0,
"type": "PipeExpression",
"type": "PipeExpression"
},
"start": 0,
"type": "VariableDeclarator"
},
"end": 0,
"kind": "const",
"start": 0,
"type": "VariableDeclaration",
"type": "VariableDeclaration"
},
{
"commentStart": 0,
"end": 0,
"expression": {
"arguments": [
{
"type": "LabeledArg",
"label": {
"commentStart": 0,
"end": 0,
"name": "color",
"start": 0,
"type": "Identifier"
},
"arg": {
"commentStart": 0,
"end": 0,
"raw": "\"#00ff00\"",
"start": 0,
"type": "Literal",
"type": "Literal",
"value": "#00ff00"
}
}
],
"callee": {
"abs_path": false,
"commentStart": 0,
"end": 0,
"name": {
"commentStart": 0,
"end": 0,
"name": "appearance",
"start": 0,
"type": "Identifier"
},
"path": [],
"start": 0,
"type": "Name"
},
"commentStart": 0,
"end": 0,
"start": 0,
"type": "CallExpressionKw",
"type": "CallExpressionKw",
"unlabeled": {
"abs_path": false,
"commentStart": 0,
"end": 0,
"name": {
"commentStart": 0,
"end": 0,
"name": "green",
"start": 0,
"type": "Identifier"
},
"path": [],
"start": 0,
"type": "Name",
"type": "Name"
}
},
"start": 0,
"type": "ExpressionStatement",
"type": "ExpressionStatement"
},
{
"commentStart": 0,
"declaration": {
"commentStart": 0,
"end": 0,
"id": {
"commentStart": 0,
"end": 0,
"name": "blue",
"start": 0,
"type": "Identifier"
},
"init": {
"body": [
{
"callee": {
"abs_path": false,
"commentStart": 0,
"end": 0,
"name": {
"commentStart": 0,
"end": 0,
"name": "clone",
"start": 0,
"type": "Identifier"
},
"path": [],
"start": 0,
"type": "Name"
},
"commentStart": 0,
"end": 0,
"start": 0,
"type": "CallExpressionKw",
"type": "CallExpressionKw",
"unlabeled": {
"abs_path": false,
"commentStart": 0,
"end": 0,
"name": {
"commentStart": 0,
"end": 0,
"name": "red",
"start": 0,
"type": "Identifier"
},
"path": [],
"start": 0,
"type": "Name",
"type": "Name"
}
},
{
"arguments": [
{
"type": "LabeledArg",
"label": {
"commentStart": 0,
"end": 0,
"name": "y",
"name": "metalness",
"start": 0,
"type": "Identifier"
},
"arg": {
"commentStart": 0,
"end": 0,
"raw": "4000",
"raw": "50",
"start": 0,
"type": "Literal",
"type": "Literal",
"value": {
"value": 4000.0,
"value": 50.0,
"suffix": "None"
}
}
},
{
"type": "LabeledArg",
"label": {
"commentStart": 0,
"end": 0,
"name": "roughness",
"start": 0,
"type": "Identifier"
},
"arg": {
"commentStart": 0,
"end": 0,
"raw": "50",
"start": 0,
"type": "Literal",
"type": "Literal",
"value": {
"value": 50.0,
"suffix": "None"
}
}
@ -713,7 +235,7 @@ description: Result of parsing import_mesh_clone.kcl
"name": {
"commentStart": 0,
"end": 0,
"name": "translate",
"name": "appearance",
"start": 0,
"type": "Identifier"
},
@ -726,13 +248,7 @@ description: Result of parsing import_mesh_clone.kcl
"start": 0,
"type": "CallExpressionKw",
"type": "CallExpressionKw",
"unlabeled": {
"commentStart": 0,
"end": 0,
"start": 0,
"type": "PipeSubstitution",
"type": "PipeSubstitution"
}
"unlabeled": null
}
],
"commentStart": 0,
@ -749,72 +265,6 @@ description: Result of parsing import_mesh_clone.kcl
"start": 0,
"type": "VariableDeclaration",
"type": "VariableDeclaration"
},
{
"commentStart": 0,
"end": 0,
"expression": {
"arguments": [
{
"type": "LabeledArg",
"label": {
"commentStart": 0,
"end": 0,
"name": "color",
"start": 0,
"type": "Identifier"
},
"arg": {
"commentStart": 0,
"end": 0,
"raw": "\"#0000ff\"",
"start": 0,
"type": "Literal",
"type": "Literal",
"value": "#0000ff"
}
}
],
"callee": {
"abs_path": false,
"commentStart": 0,
"end": 0,
"name": {
"commentStart": 0,
"end": 0,
"name": "appearance",
"start": 0,
"type": "Identifier"
},
"path": [],
"start": 0,
"type": "Name"
},
"commentStart": 0,
"end": 0,
"start": 0,
"type": "CallExpressionKw",
"type": "CallExpressionKw",
"unlabeled": {
"abs_path": false,
"commentStart": 0,
"end": 0,
"name": {
"commentStart": 0,
"end": 0,
"name": "blue",
"start": 0,
"type": "Identifier"
},
"path": [],
"start": 0,
"type": "Name",
"type": "Name"
}
},
"start": 0,
"type": "ExpressionStatement",
"type": "ExpressionStatement"
}
],
"commentStart": 0,
@ -832,29 +282,7 @@ description: Result of parsing import_mesh_clone.kcl
}
}
],
"2": [
{
"commentStart": 0,
"end": 0,
"start": 0,
"type": "NonCodeNode",
"value": {
"type": "newLine"
}
}
],
"4": [
{
"commentStart": 0,
"end": 0,
"start": 0,
"type": "NonCodeNode",
"value": {
"type": "newLine"
}
}
],
"6": [
"1": [
{
"commentStart": 0,
"end": 0,

View File

@ -0,0 +1,10 @@
---
source: kcl-lib/src/simulation_tests.rs
description: Error from executing import_mesh_clone.kcl
---
KCL Engine error
× engine: Modeling command failed: websocket closed early
╭────
13 │ )
╰────

View File

@ -1,18 +1,13 @@
@(lengthUnit = m)
import "../inputs/cube.obj" as yellow
import "../inputs/cube.obj" as cube
yellow
|> translate(%, x = -2000, y = -2000)
appearance(yellow, color = "#ffff00")
model = cube
red = clone(yellow)
|> translate(%, x = 4000)
appearance(red, color = "#ff0000")
green = clone(yellow)
|> translate(%, y = 4000)
appearance(green, color = "#00ff00")
blue = clone(red)
|> translate(%, y = 4000)
appearance(blue, color = "#0000ff")
model2 = clone(model)
|> translate(
x = 1020,
)
|> appearance(
color = "#ff0000",
metalness = 50,
roughness = 50
)

View File

@ -25,32 +25,6 @@ description: Operations executed import_mesh_clone.kcl
"labeledArgs": {},
"sourceRange": []
},
{
"type": "KclStdLibCall",
"name": "clone",
"unlabeledArg": {
"value": {
"type": "ImportedGeometry",
"artifact_id": "[uuid]"
},
"sourceRange": []
},
"labeledArgs": {},
"sourceRange": []
},
{
"type": "KclStdLibCall",
"name": "clone",
"unlabeledArg": {
"value": {
"type": "ImportedGeometry",
"artifact_id": "[uuid]"
},
"sourceRange": []
},
"labeledArgs": {},
"sourceRange": []
},
{
"type": "GroupEnd"
}

View File

@ -1,31 +0,0 @@
---
source: kcl-lib/src/simulation_tests.rs
description: Variables in memory after executing import_mesh_clone.kcl
---
{
"blue": {
"type": "ImportedGeometry",
"id": "[uuid]",
"value": [
"cube.obj"
]
},
"green": {
"type": "ImportedGeometry",
"id": "[uuid]",
"value": [
"cube.obj"
]
},
"red": {
"type": "ImportedGeometry",
"id": "[uuid]",
"value": [
"cube.obj"
]
},
"yellow": {
"type": "Module",
"value": 1
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

View File

@ -2,21 +2,10 @@
source: kcl-lib/src/simulation_tests.rs
description: Result of unparsing import_mesh_clone.kcl
---
@(lengthUnit = m)
import "../inputs/cube.obj" as yellow
import "../inputs/cube.obj" as cube
yellow
|> translate(%, x = -2000, y = -2000)
appearance(yellow, color = "#ffff00")
model = cube
red = clone(yellow)
|> translate(%, x = 4000)
appearance(red, color = "#ff0000")
green = clone(yellow)
|> translate(%, y = 4000)
appearance(green, color = "#00ff00")
blue = clone(red)
|> translate(%, y = 4000)
appearance(blue, color = "#0000ff")
model2 = clone(model)
|> translate(x = 1020)
|> appearance(color = "#ff0000", metalness = 50, roughness = 50)

View File

@ -5122,7 +5122,7 @@ description: Artifact commands bench.kcl
"trajectory": "[uuid]",
"sectional": false,
"tolerance": 0.0000001,
"relative_to": "trajectory_curve"
"relative_to": "sketch_plane"
}
},
{
@ -5134,7 +5134,7 @@ description: Artifact commands bench.kcl
"trajectory": "[uuid]",
"sectional": false,
"tolerance": 0.0000001,
"relative_to": "trajectory_curve"
"relative_to": "sketch_plane"
}
}
]

View File

@ -906,7 +906,7 @@ description: Artifact commands cold-plate.kcl
"trajectory": "[uuid]",
"sectional": false,
"tolerance": 0.0000001,
"relative_to": "trajectory_curve"
"relative_to": "sketch_plane"
}
},
{

View File

@ -5576,7 +5576,7 @@ description: Artifact commands cpu-cooler.kcl
"trajectory": "[uuid]",
"sectional": false,
"tolerance": 0.0000001,
"relative_to": "trajectory_curve"
"relative_to": "sketch_plane"
}
},
{
@ -6111,7 +6111,7 @@ description: Artifact commands cpu-cooler.kcl
"trajectory": "[uuid]",
"sectional": false,
"tolerance": 0.0000001,
"relative_to": "trajectory_curve"
"relative_to": "sketch_plane"
}
},
{
@ -9469,7 +9469,7 @@ description: Artifact commands cpu-cooler.kcl
"trajectory": "[uuid]",
"sectional": false,
"tolerance": 0.0000001,
"relative_to": "trajectory_curve"
"relative_to": "sketch_plane"
}
},
{
@ -9601,7 +9601,7 @@ description: Artifact commands cpu-cooler.kcl
"trajectory": "[uuid]",
"sectional": false,
"tolerance": 0.0000001,
"relative_to": "trajectory_curve"
"relative_to": "sketch_plane"
}
},
{
@ -10120,7 +10120,7 @@ description: Artifact commands cpu-cooler.kcl
"trajectory": "[uuid]",
"sectional": false,
"tolerance": 0.0000001,
"relative_to": "trajectory_curve"
"relative_to": "sketch_plane"
}
},
{
@ -10252,7 +10252,7 @@ description: Artifact commands cpu-cooler.kcl
"trajectory": "[uuid]",
"sectional": false,
"tolerance": 0.0000001,
"relative_to": "trajectory_curve"
"relative_to": "sketch_plane"
}
},
{

View File

@ -1598,7 +1598,7 @@ description: Artifact commands exhaust-manifold.kcl
"trajectory": "[uuid]",
"sectional": false,
"tolerance": 0.0000001,
"relative_to": "trajectory_curve"
"relative_to": "sketch_plane"
}
},
{
@ -1610,7 +1610,7 @@ description: Artifact commands exhaust-manifold.kcl
"trajectory": "[uuid]",
"sectional": false,
"tolerance": 0.0000001,
"relative_to": "trajectory_curve"
"relative_to": "sketch_plane"
}
},
{
@ -1622,7 +1622,7 @@ description: Artifact commands exhaust-manifold.kcl
"trajectory": "[uuid]",
"sectional": false,
"tolerance": 0.0000001,
"relative_to": "trajectory_curve"
"relative_to": "sketch_plane"
}
},
{
@ -1634,7 +1634,7 @@ description: Artifact commands exhaust-manifold.kcl
"trajectory": "[uuid]",
"sectional": false,
"tolerance": 0.0000001,
"relative_to": "trajectory_curve"
"relative_to": "sketch_plane"
}
},
{

View File

@ -4491,7 +4491,7 @@ description: Artifact commands utility-sink.kcl
"trajectory": "[uuid]",
"sectional": false,
"tolerance": 0.0000001,
"relative_to": "trajectory_curve"
"relative_to": "sketch_plane"
}
},
{

View File

@ -418,7 +418,7 @@ description: Artifact commands subtract_regression03.kcl
"trajectory": "[uuid]",
"sectional": false,
"tolerance": 0.0000001,
"relative_to": "trajectory_curve"
"relative_to": "sketch_plane"
}
},
{

View File

@ -395,7 +395,7 @@ description: Artifact commands subtract_regression05.kcl
"trajectory": "[uuid]",
"sectional": false,
"tolerance": 0.0000001,
"relative_to": "trajectory_curve"
"relative_to": "sketch_plane"
}
},
{

View File

@ -1,6 +1,6 @@
[package]
name = "kcl-python-bindings"
version = "0.3.72"
version = "0.3.70"
edition = "2021"
repository = "https://github.com/kittycad/modeling-app"
exclude = ["tests/*", "files/*", "venv/*"]

View File

@ -227,31 +227,6 @@ async fn new_context_state(current_file: Option<std::path::PathBuf>) -> Result<(
Ok((ctx, state))
}
/// Parse the kcl code from a file path.
#[pyfunction]
async fn parse(path: String) -> PyResult<bool> {
tokio()
.spawn(async move {
let (code, path) = get_code_and_file_path(&path)
.await
.map_err(|err| pyo3::exceptions::PyException::new_err(err.to_string()))?;
let _program = kcl_lib::Program::parse_no_errs(&code)
.map_err(|err| into_miette_for_parse(&path.display().to_string(), &code, err))?;
Ok(true)
})
.await
.map_err(|err| pyo3::exceptions::PyException::new_err(err.to_string()))?
}
/// Parse the kcl code.
#[pyfunction]
fn parse_code(code: String) -> PyResult<bool> {
let _program = kcl_lib::Program::parse_no_errs(&code).map_err(|err| into_miette_for_parse("", &code, err))?;
Ok(true)
}
/// Execute the kcl code from a file path.
#[pyfunction]
async fn execute(path: String) -> PyResult<()> {
@ -559,8 +534,6 @@ fn kcl(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_class::<Discovered>()?;
// Add our functions to the module.
m.add_function(wrap_pyfunction!(parse, m)?)?;
m.add_function(wrap_pyfunction!(parse_code, m)?)?;
m.add_function(wrap_pyfunction!(execute, m)?)?;
m.add_function(wrap_pyfunction!(execute_code, m)?)?;
m.add_function(wrap_pyfunction!(execute_and_snapshot, m)?)?;

View File

@ -39,35 +39,6 @@ async def test_kcl_execute():
await kcl.execute(lego_file)
@pytest.mark.asyncio
async def test_kcl_parse_with_exception():
# Read from a file.
try:
await kcl.parse(os.path.join(files_dir, "parse_file_error"))
except Exception as e:
assert e is not None
assert len(str(e)) > 0
assert "lksjndflsskjfnak;jfna##" in str(e)
@pytest.mark.asyncio
async def test_kcl_parse():
# Read from a file.
result = await kcl.parse(lego_file)
assert result is True
@pytest.mark.asyncio
async def test_kcl_parse_code():
# Read from a file.
with open(lego_file, "r") as f:
code = str(f.read())
assert code is not None
assert len(code) > 0
result = kcl.parse_code(code)
assert result is True
@pytest.mark.asyncio
async def test_kcl_execute_code():
# Read from a file.
@ -126,7 +97,9 @@ async def test_kcl_execute_and_snapshot():
@pytest.mark.asyncio
async def test_kcl_execute_and_snapshot_dir():
# Read from a file.
image_bytes = await kcl.execute_and_snapshot(car_wheel_dir, kcl.ImageFormat.Jpeg)
image_bytes = await kcl.execute_and_snapshot(
car_wheel_dir, kcl.ImageFormat.Jpeg
)
assert image_bytes is not None
assert len(image_bytes) > 0
@ -156,12 +129,10 @@ def test_kcl_format():
assert formatted_code is not None
assert len(formatted_code) > 0
@pytest.mark.asyncio
async def test_kcl_format_dir():
await kcl.format_dir(car_wheel_dir)
def test_kcl_lint():
# Read from a file.
with open(os.path.join(files_dir, "box_with_linter_errors.kcl"), "r") as f:

View File

@ -1,7 +1,7 @@
[package]
name = "kcl-test-server"
description = "A test server for KCL"
version = "0.1.72"
version = "0.1.70"
edition = "2021"
license = "MIT"

View File

@ -1,7 +1,7 @@
[package]
name = "kcl-to-core"
description = "Utility methods to convert kcl to engine core executable tests"
version = "0.1.72"
version = "0.1.70"
edition = "2021"
license = "MIT"
repository = "https://github.com/KittyCAD/modeling-app"

View File

@ -1,6 +1,6 @@
[package]
name = "kcl-wasm-lib"
version = "0.1.72"
version = "0.1.70"
edition = "2021"
repository = "https://github.com/KittyCAD/modeling-app"
rust-version = "1.83"

View File

@ -117,9 +117,7 @@ export function App() {
// When leaving the modeling scene, cut the engine stream.
return () => {
// When leaving the modeling scene, cut the engine stream.
// Stop is more serious than Pause
engineStreamActor.send({ type: EngineStreamTransition.Stop })
engineStreamActor.send({ type: EngineStreamTransition.Pause })
}
}, [])

View File

@ -158,8 +158,7 @@ export function Toolbar({
const isDisabled =
disableAllButtons ||
!isConfiguredAvailable ||
maybeIconConfig.disabled?.(state) === true ||
kclManager.hasErrors()
maybeIconConfig.disabled?.(state) === true
return {
...maybeIconConfig,
@ -445,15 +444,6 @@ const ToolbarItemTooltip = memo(function ToolbarItemContents({
contentClassName={contentClassName}
>
{children}
{kclManager.hasErrors() && (
<p className="text-xs p-1 text-chalkboard-70 dark:text-chalkboard-40">
<CustomIcon
name="exclamationMark"
className="w-4 h-4 inline-block mr-1 text-destroy-80 bg-destroy-10"
/>
Fix KCL errors to enable tools
</p>
)}
</Tooltip>
)
})

View File

@ -975,6 +975,7 @@ export class CameraControls {
},
})
}
await this.engineCommandManager.sendSceneCommand({
type: 'modeling_cmd_req',
cmd_id: uuidv4(),

View File

@ -13,7 +13,6 @@ export const STRAIGHT_SEGMENT_SNAP_LINE = 'straight-segment-snap-line'
export const CIRCLE_SEGMENT = 'circle-segment'
export const CIRCLE_SEGMENT_BODY = 'circle-segment-body'
export const CIRCLE_SEGMENT_DASH = 'circle-segment-body-dashed'
export const CIRCLE_SEGMENT_RADIUS_BODY = 'circle-segment-radius-body'
export const TANGENTIAL_ARC_TO_SEGMENT = 'tangential-arc-to-segment'
export const TANGENTIAL_ARC_TO_SEGMENT_BODY = 'tangential-arc-to-segment-body'
export const TANGENTIAL_ARC_TO__SEGMENT_DASH =

View File

@ -60,6 +60,7 @@ import {
PROFILE_START,
SEGMENT_BODIES,
SEGMENT_BODIES_PLUS_PROFILE_START,
SEGMENT_WIDTH_PX,
STRAIGHT_SEGMENT,
STRAIGHT_SEGMENT_DASH,
TANGENTIAL_ARC_TO_SEGMENT,
@ -89,7 +90,6 @@ import {
getSceneScale,
} from '@src/clientSideScene/sceneUtils'
import type { SegmentUtils } from '@src/clientSideScene/segments'
import { createLineShape } from '@src/clientSideScene/segments'
import {
createProfileStartHandle,
dashedStraight,
@ -3653,6 +3653,10 @@ export class SceneEntities {
this.sceneInfra._baseUnitMultiplier
const from = group.userData.from
const shape = new Shape()
shape.moveTo(0, (-SEGMENT_WIDTH_PX / 2) * scale) // The width of the line in px (2.4px in this case)
shape.lineTo(0, (SEGMENT_WIDTH_PX / 2) * scale)
const straightSegmentBodyDashed = group.children.find(
(child) => child.userData.type === STRAIGHT_SEGMENT_DASH
) as Mesh
@ -3660,7 +3664,7 @@ export class SceneEntities {
straightSegmentBodyDashed.geometry = dashedStraight(
from,
to,
createLineShape(scale),
shape,
scale
)
}

View File

@ -64,7 +64,6 @@ import {
THREE_POINT_ARC_SEGMENT_BODY,
THREE_POINT_ARC_SEGMENT_DASH,
getParentGroup,
CIRCLE_SEGMENT_RADIUS_BODY,
} from '@src/clientSideScene/sceneConstants'
import type { SceneInfra } from '@src/clientSideScene/sceneInfra'
import {
@ -256,7 +255,9 @@ class StraightSegment implements SegmentUtils {
const { from, to } = input
group.userData.from = from
group.userData.to = to
const shape = createLineShape(scale)
const shape = new Shape()
shape.moveTo(0, (-SEGMENT_WIDTH_PX / 2) * scale) // The width of the line in px (2.4px in this case)
shape.lineTo(0, (SEGMENT_WIDTH_PX / 2) * scale)
const arrowGroup = group.getObjectByName(ARROWHEAD) as Group
const labelGroup = group.getObjectByName(SEGMENT_LENGTH_LABEL) as Group
@ -349,7 +350,6 @@ class StraightSegment implements SegmentUtils {
new Vector3(from[0], from[1], 0),
new Vector3(to[0], to[1], 0)
)
straightSegmentBody.geometry?.dispose()
straightSegmentBody.geometry = new ExtrudeGeometry(shape, {
steps: 2,
bevelEnabled: false,
@ -607,21 +607,6 @@ class CircleSegment implements SegmentUtils {
const arcMesh = new Mesh(geometry, mat)
const meshType = isDraftSegment ? CIRCLE_SEGMENT_DASH : CIRCLE_SEGMENT_BODY
const arrowGroup = createArrowhead(scale, theme, color)
const shape = new Shape()
const line = new LineCurve3(
new Vector3(from[0], from[1], 0),
new Vector3(center[0], center[1], 0)
)
const arrowGeometry = new ExtrudeGeometry(shape, {
steps: 2,
bevelEnabled: false,
extrudePath: line,
})
const body = new MeshBasicMaterial({ color })
const arrowBody = new Mesh(arrowGeometry, body)
arrowBody.name = CIRCLE_SEGMENT_RADIUS_BODY
const circleCenterGroup = createCircleCenterHandle(scale, theme, color)
// A radius indicator that appears from the center to the perimeter
const radiusIndicatorGroup = createLengthIndicator({
@ -648,13 +633,7 @@ class CircleSegment implements SegmentUtils {
}
group.name = CIRCLE_SEGMENT
group.add(
arcMesh,
arrowGroup,
arrowBody,
circleCenterGroup,
radiusIndicatorGroup
)
group.add(arcMesh, arrowGroup, circleCenterGroup, radiusIndicatorGroup)
const updateOverlaysCallback = this.update({
prevSegment,
input,
@ -721,25 +700,6 @@ class CircleSegment implements SegmentUtils {
)
arrowGroup.scale.set(scale, scale, scale)
arrowGroup.visible = isHandlesVisible
const straightSegmentBody = group.getObjectByName(
CIRCLE_SEGMENT_RADIUS_BODY
) as Mesh
if (straightSegmentBody) {
const line = new LineCurve3(
new Vector3(center[0], center[1], 0),
new Vector3(arrowPoint.x, arrowPoint.y, 0)
)
straightSegmentBody.geometry?.dispose()
straightSegmentBody.geometry = new ExtrudeGeometry(
createLineShape(scale),
{
steps: 2,
bevelEnabled: false,
extrudePath: line,
}
)
}
}
if (radiusLengthIndicator) {
@ -1878,8 +1838,10 @@ export function createArcGeometry({
ccw,
0
)
const shape = new Shape()
shape.moveTo(0, (-SEGMENT_WIDTH_PX / 2) * scale)
shape.lineTo(0, (SEGMENT_WIDTH_PX / 2) * scale) // The width of the line
const shape = createLineShape(scale)
if (!isDashed) {
const points = arcStart.getPoints(50)
const path = new CurvePath<Vector3>()
@ -2148,14 +2110,6 @@ function updateAngleIndicator(
angleIndicator.geometry.setFromPoints(points)
}
// Used to create a line with thickness
export function createLineShape(scale: number) {
const shape = new Shape()
shape.moveTo(0, (-SEGMENT_WIDTH_PX / 2) * scale) // The width of the line in px (2.4px in this case)
shape.lineTo(0, (SEGMENT_WIDTH_PX / 2) * scale)
return shape
}
export const segmentUtils = {
straight: new StraightSegment(),
tangentialArc: new TangentialArcToSegment(),

View File

@ -136,9 +136,8 @@ function optionIsDisabled(option: Command): boolean {
option.disabled ||
('machineActor' in option &&
option.machineActor !== undefined &&
(!getActorNextEvents(option.machineActor.getSnapshot()).includes(
!getActorNextEvents(option.machineActor.getSnapshot()).includes(
option.name
) ||
!option.machineActor?.getSnapshot().can({ type: option.name })))
))
)
}

View File

@ -1,4 +1,3 @@
import { isPlaywright } from '@src/lib/isPlaywright'
import { useAppState } from '@src/AppState'
import { ClientSideScene } from '@src/clientSideScene/ClientSideSceneComp'
import { ViewControlContextMenu } from '@src/components/ViewControlMenu'
@ -6,10 +5,7 @@ import { useModelingContext } from '@src/hooks/useModelingContext'
import { useNetworkContext } from '@src/hooks/useNetworkContext'
import { NetworkHealthState } from '@src/hooks/useNetworkStatus'
import { getArtifactOfTypes } from '@src/lang/std/artifactGraph'
import {
EngineCommandManagerEvents,
EngineConnectionStateType,
} from '@src/lang/std/engineConnection'
import { EngineCommandManagerEvents } from '@src/lang/std/engineConnection'
import { btnName } from '@src/lib/cameraControls'
import { PATHS } from '@src/lib/paths'
import { sendSelectEventToEngine } from '@src/lib/selections'
@ -37,38 +33,22 @@ import { createThumbnailPNGOnDesktop } from '@src/lib/screenshot'
import type { SettingsViaQueryString } from '@src/lib/settings/settingsTypes'
import { resetCameraPosition } from '@src/lib/resetCameraPosition'
const TIME_1_SECOND = 1000
export const EngineStream = (props: {
pool: string | null
authToken: string | undefined
}) => {
const { setAppState } = useAppState()
const [firstPlay, setFirstPlay] = useState(true)
const { overallState } = useNetworkContext()
const settings = useSettings()
const { state: modelingMachineState, send: modelingMachineActorSend } =
useModelingContext()
const engineStreamState = useSelector(engineStreamActor, (state) => state)
const { file, project } = useRouteLoaderData(PATHS.FILE) as IndexLoaderData
const last = useRef<number>(Date.now())
const [firstPlay, setFirstPlay] = useState(true)
const [isRestartRequestStarting, setIsRestartRequestStarting] =
useState(false)
const [attemptTimes, setAttemptTimes] = useState<[number, number]>([
0,
TIME_1_SECOND,
])
// These will be passed to the engineStreamActor to handle.
const videoRef = useRef<HTMLVideoElement>(null)
const canvasRef = useRef<HTMLCanvasElement>(null)
// For attaching right-click menu events
const videoWrapperRef = useRef<HTMLDivElement>(null)
const { overallState } = useNetworkContext()
const engineStreamState = useSelector(engineStreamActor, (state) => state)
/**
* We omit `pool` here because `engineStreamMachine` will override it anyway
* within the `EngineStreamTransition.StartOrReconfigureEngine` Promise actor.
@ -82,46 +62,19 @@ export const EngineStream = (props: {
cameraOrbit: settings.modeling.cameraOrbit.current,
}
const { state: modelingMachineState, send: modelingMachineActorSend } =
useModelingContext()
const streamIdleMode = settings.app.streamIdleMode.current
useEffect(() => {
engineStreamActor.send({
type: EngineStreamTransition.SetVideoRef,
videoRef: { current: videoRef.current },
})
}, [videoRef.current])
useEffect(() => {
engineStreamActor.send({
type: EngineStreamTransition.SetCanvasRef,
canvasRef: { current: canvasRef.current },
})
}, [canvasRef.current])
useEffect(() => {
engineStreamActor.send({
type: EngineStreamTransition.SetPool,
pool: props.pool,
})
}, [props.pool])
useEffect(() => {
engineStreamActor.send({
type: EngineStreamTransition.SetAuthToken,
authToken: props.authToken,
})
}, [props.authToken])
// We have to call this here because of the dependencies:
// modelingMachineActorSend, setAppState, settingsEngine
// It's possible to pass these in earlier but I (lee) don't want to
// restructure this further at the moment.
const startOrReconfigureEngine = () => {
engineStreamActor.send({
type: EngineStreamTransition.StartOrReconfigureEngine,
modelingMachineActorSend,
settings: settingsEngine,
setAppState,
// It's possible a reconnect happens as we drag the window :')
onMediaStream(mediaStream: MediaStream) {
engineStreamActor.send({
type: EngineStreamTransition.SetMediaStream,
@ -131,47 +84,18 @@ export const EngineStream = (props: {
})
}
useEffect(() => {
if (
engineStreamState.value !== EngineStreamState.WaitingForDependencies &&
engineStreamState.value !== EngineStreamState.Stopped
)
return
startOrReconfigureEngine()
}, [engineStreamState, setAppState])
// I would inline this but it needs to be a function for removeEventListener.
// When the scene is ready play the stream and execute!
const play = () => {
engineStreamActor.send({
type: EngineStreamTransition.Play,
})
}
useEffect(() => {
engineCommandManager.addEventListener(
EngineCommandManagerEvents.SceneReady,
play
)
return () => {
engineCommandManager.removeEventListener(
EngineCommandManagerEvents.SceneReady,
play
)
}
}, [])
// When the scene is ready, execute kcl!
const executeKcl = () => {
console.log('scene is ready, execute kcl')
const kmp = kclManager.executeCode().catch(trap)
if (!firstPlay) return
setFirstPlay(false)
// Reset the restart timeouts
setAttemptTimes([0, TIME_1_SECOND])
console.log('scene is ready, fire!')
console.log('firstPlay true, zoom to fit')
kmp
.then(async () => {
await resetCameraPosition()
@ -188,65 +112,51 @@ export const EngineStream = (props: {
useEffect(() => {
engineCommandManager.addEventListener(
EngineCommandManagerEvents.SceneReady,
executeKcl
play
)
return () => {
engineCommandManager.removeEventListener(
EngineCommandManagerEvents.SceneReady,
executeKcl
play
)
}
}, [firstPlay])
useEffect(() => {
// We do a back-off restart, using a fibonacci sequence, since it
// has a nice retry time curve (somewhat quick then exponential)
const attemptRestartIfNecessary = () => {
if (isRestartRequestStarting) return
setIsRestartRequestStarting(true)
setTimeout(() => {
engineStreamState.context.videoRef.current?.pause()
engineCommandManager.tearDown()
startOrReconfigureEngine()
setFirstPlay(false)
setIsRestartRequestStarting(false)
}, attemptTimes[0] + attemptTimes[1])
setAttemptTimes([attemptTimes[1], attemptTimes[0] + attemptTimes[1]])
}
// Poll that we're connected. If not, send a reset signal.
// Do not restart if we're in idle mode.
const connectionCheckIntervalId = setInterval(() => {
// SKIP DURING TESTS BECAUSE IT WILL MESS WITH REUSING THE
// ELECTRON INSTANCE.
if (isPlaywright()) {
return
}
// Don't try try to restart if we're already connected!
const hasEngineConnectionInst = engineCommandManager.engineConnection
const isDisconnected =
engineCommandManager.engineConnection?.state.type ===
EngineConnectionStateType.Disconnected
const inIdleMode = engineStreamState.value === EngineStreamState.Paused
if ((hasEngineConnectionInst && !isDisconnected) || inIdleMode) return
attemptRestartIfNecessary()
}, TIME_1_SECOND)
engineCommandManager.addEventListener(
EngineCommandManagerEvents.EngineRestartRequest,
attemptRestartIfNecessary
EngineCommandManagerEvents.SceneReady,
play
)
return () => {
clearInterval(connectionCheckIntervalId)
engineCommandManager.removeEventListener(
EngineCommandManagerEvents.EngineRestartRequest,
attemptRestartIfNecessary
)
engineStreamActor.send({
type: EngineStreamTransition.SetPool,
data: { pool: props.pool },
})
engineStreamActor.send({
type: EngineStreamTransition.SetAuthToken,
data: { authToken: props.authToken },
})
return () => {
engineCommandManager.tearDown()
}
}, [engineStreamState, attemptTimes, isRestartRequestStarting])
}, [])
// In the past we'd try to play immediately, but the proper thing is to way
// for the 'canplay' event to tell us data is ready.
useEffect(() => {
const videoRef = engineStreamState.context.videoRef.current
if (!videoRef) {
return
}
const play = () => {
videoRef.play().catch(console.error)
}
videoRef.addEventListener('canplay', play)
return () => {
videoRef.removeEventListener('canplay', play)
}
}, [engineStreamState.context.videoRef.current])
useEffect(() => {
if (engineStreamState.value === EngineStreamState.Reconfiguring) return
@ -274,6 +184,25 @@ export const EngineStream = (props: {
}).observe(document.body)
}, [engineStreamState.value])
// When the video and canvas element references are set, start the engine.
useEffect(() => {
if (
engineStreamState.context.canvasRef.current &&
engineStreamState.context.videoRef.current
) {
startOrReconfigureEngine()
}
}, [
engineStreamState.context.canvasRef.current,
engineStreamState.context.videoRef.current,
])
// On settings change, reconfigure the engine. When paused this gets really tricky,
// and also requires onMediaStream to be set!
useEffect(() => {
startOrReconfigureEngine()
}, Object.values(settingsEngine))
/**
* Subscribe to execute code when the file changes
* but only if the scene is already ready.
@ -356,7 +285,18 @@ export const EngineStream = (props: {
}
if (engineStreamState.value === EngineStreamState.Paused) {
startOrReconfigureEngine()
engineStreamActor.send({
type: EngineStreamTransition.StartOrReconfigureEngine,
modelingMachineActorSend,
settings: settingsEngine,
setAppState,
onMediaStream(mediaStream: MediaStream) {
engineStreamActor.send({
type: EngineStreamTransition.SetMediaStream,
mediaStream,
})
},
})
}
timeoutStart.current = Date.now()
@ -374,7 +314,7 @@ export const EngineStream = (props: {
window.document.addEventListener('mouseup', onAnyInput)
window.document.addEventListener('scroll', onAnyInput)
window.document.addEventListener('touchstart', onAnyInput)
window.document.addEventListener('touchend', onAnyInput)
window.document.addEventListener('touchstop', onAnyInput)
return () => {
timeoutStart.current = null
@ -385,34 +325,10 @@ export const EngineStream = (props: {
window.document.removeEventListener('mouseup', onAnyInput)
window.document.removeEventListener('scroll', onAnyInput)
window.document.removeEventListener('touchstart', onAnyInput)
window.document.removeEventListener('touchend', onAnyInput)
window.document.removeEventListener('touchstop', onAnyInput)
}
}, [streamIdleMode, engineStreamState.value])
// On various inputs save the camera state, in case we get disconnected.
useEffect(() => {
const onInput = () => {
// Save the remote camera state to restore on stream restore.
// Fire-and-forget because we don't know when a camera movement is
// completed on the engine side (there are no responses to data channel
// mouse movements.)
sceneInfra.camControls.saveRemoteCameraState().catch(trap)
}
// These usually signal a user is done some sort of operation.
window.document.addEventListener('keyup', onInput)
window.document.addEventListener('mouseup', onInput)
window.document.addEventListener('scroll', onInput)
window.document.addEventListener('touchend', onInput)
return () => {
window.document.removeEventListener('keyup', onInput)
window.document.removeEventListener('mouseup', onInput)
window.document.removeEventListener('scroll', onInput)
window.document.removeEventListener('touchend', onInput)
}
}, [])
const isNetworkOkay =
overallState === NetworkHealthState.Ok ||
overallState === NetworkHealthState.Weak
@ -483,7 +399,7 @@ export const EngineStream = (props: {
autoPlay
muted
key={engineStreamActor.id + 'video'}
ref={videoRef}
ref={engineStreamState.context.videoRef}
controls={false}
className="w-full cursor-pointer h-full"
disablePictureInPicture
@ -491,7 +407,7 @@ export const EngineStream = (props: {
/>
<canvas
key={engineStreamActor.id + 'canvas'}
ref={canvasRef}
ref={engineStreamState.context.canvasRef}
className="cursor-pointer"
id="freeze-frame"
>
@ -508,11 +424,9 @@ export const EngineStream = (props: {
}
menuTargetElement={videoWrapperRef}
/>
{![
EngineStreamState.Playing,
EngineStreamState.Paused,
EngineStreamState.Resuming,
].some((s) => s === engineStreamState.value) && (
{![EngineStreamState.Playing, EngineStreamState.Paused].some(
(s) => s === engineStreamState.value
) && (
<Loading dataTestId="loading-engine" className="fixed inset-0 h-screen">
Connecting to engine
</Loading>

View File

@ -138,9 +138,7 @@ const Loading = ({ children, className, dataTestId }: LoadingProps) => {
CONNECTION_ERROR_TEXT[error.error] +
(error.context
? '\n\nThe error details are: ' +
(error.context instanceof Object
? JSON.stringify(error.context)
: error.context)
JSON.stringify(error.context)
: ''),
{
renderer: new SafeRenderer(markedOptions),

View File

@ -204,13 +204,12 @@ export const ModelingMachineProvider = ({
sceneInfra.camControls.syncDirection = 'engineToClient'
// TODO: Re-evaluate if this pause/play logic is needed.
store.videoElement?.pause()
return kclManager
.executeCode()
.then(() => {
if (engineCommandManager.idleMode) return
if (engineCommandManager.engineConnection?.idleMode) return
store.videoElement?.play().catch((e) => {
console.warn('Video playing was prevented', e)
@ -580,23 +579,24 @@ export const ModelingMachineProvider = ({
selectionRanges
)
},
'Has exportable geometry': () =>
!kclManager.hasErrors() && kclManager.ast.body.length > 0,
'Has exportable geometry': () => {
if (!kclManager.hasErrors() && kclManager.ast.body.length > 0)
return true
else {
let errorMessage = 'Unable to Export '
if (kclManager.hasErrors()) errorMessage += 'due to KCL Errors'
else if (kclManager.ast.body.length === 0)
errorMessage += 'due to Empty Scene'
console.error(errorMessage)
toast.error(errorMessage)
return false
}
},
},
actors: {
exportFromEngine: fromPromise(
async ({ input }: { input?: ModelingCommandSchema['Export'] }) => {
if (kclManager.hasErrors() || kclManager.ast.body.length === 0) {
let errorMessage = 'Unable to Export '
if (kclManager.hasErrors()) {
errorMessage += 'due to KCL Errors'
} else if (kclManager.ast.body.length === 0) {
errorMessage += 'due to Empty Scene'
}
console.error(errorMessage)
toast.error(errorMessage)
return new Error(errorMessage)
} else if (!input) {
if (!input) {
return new Error('No input provided')
}

View File

@ -1,328 +0,0 @@
import { language, syntaxTree } from '@codemirror/language'
import type { Extension, Range, Text } from '@codemirror/state'
import type { DecorationSet, ViewUpdate } from '@codemirror/view'
import {
Decoration,
EditorView,
ViewPlugin,
WidgetType,
} from '@codemirror/view'
import type { Tree } from '@lezer/common'
import { NodeProp } from '@lezer/common'
import { isArray } from '@src/lib/utils'
interface PickerState {
from: number
to: number
alpha: string
colorType: ColorType
}
export interface WidgetOptions extends PickerState {
color: string
}
export type ColorData = Omit<WidgetOptions, 'from' | 'to'>
const pickerState = new WeakMap<HTMLInputElement, PickerState>()
export enum ColorType {
hex = 'HEX',
}
const hexRegex = /(^|\b)(#[0-9a-f]{3,9})(\b|$)/i
function discoverColorsInKCL(
syntaxTree: Tree,
from: number,
to: number,
typeName: string,
doc: Text,
language?: string
): WidgetOptions | Array<WidgetOptions> | null {
switch (typeName) {
case 'Program':
case 'VariableDeclaration':
case 'CallExpressionKw':
case 'ObjectExpression':
case 'ObjectProperty':
case 'ArgumentList':
case 'PipeExpression': {
let innerTree = syntaxTree.resolveInner(from, 0).tree
if (!innerTree) {
innerTree = syntaxTree.resolveInner(from, 1).tree
if (!innerTree) {
return null
}
}
const overlayTree = innerTree.prop(NodeProp.mounted)?.tree
if (overlayTree?.type.name !== 'Styles') {
return null
}
const ret: Array<WidgetOptions> = []
overlayTree.iterate({
from: 0,
to: overlayTree.length,
enter: ({ type, from: overlayFrom, to: overlayTo }) => {
const maybeWidgetOptions = discoverColorsInKCL(
syntaxTree,
// We add one because the tree doesn't include the
// quotation mark from the style tag
from + 1 + overlayFrom,
from + 1 + overlayTo,
type.name,
doc,
language
)
if (maybeWidgetOptions) {
if (isArray(maybeWidgetOptions)) {
console.error('Unexpected nested overlays')
ret.push(...maybeWidgetOptions)
} else {
ret.push(maybeWidgetOptions)
}
}
},
})
return ret
}
case 'String': {
const result = parseColorLiteral(doc.sliceString(from, to))
if (!result) {
return null
}
return {
...result,
from,
to,
}
}
default:
return null
}
}
export function parseColorLiteral(colorLiteral: string): ColorData | null {
const literal = colorLiteral.replace(/"/g, '').replace(/'/g, '')
const match = hexRegex.exec(literal)
if (!match) {
return null
}
const [color, alpha] = toFullHex(literal)
return {
colorType: ColorType.hex,
color,
alpha,
}
}
function colorPickersDecorations(
view: EditorView,
discoverColors: typeof discoverColorsInKCL
) {
const widgets: Array<Range<Decoration>> = []
const st = syntaxTree(view.state)
for (const range of view.visibleRanges) {
st.iterate({
from: range.from,
to: range.to,
enter: ({ type, from, to }) => {
const maybeWidgetOptions = discoverColors(
st,
from,
to,
type.name,
view.state.doc,
view.state.facet(language)?.name
)
if (!maybeWidgetOptions) {
return
}
if (!isArray(maybeWidgetOptions)) {
widgets.push(
Decoration.widget({
widget: new ColorPickerWidget(maybeWidgetOptions),
side: 1,
}).range(maybeWidgetOptions.from)
)
return
}
for (const wo of maybeWidgetOptions) {
widgets.push(
Decoration.widget({
widget: new ColorPickerWidget(wo),
side: 1,
}).range(wo.from)
)
}
},
})
}
return Decoration.set(widgets)
}
function toFullHex(color: string): string[] {
if (color.length === 4) {
// 3-char hex
return [
`#${color[1].repeat(2)}${color[2].repeat(2)}${color[3].repeat(2)}`,
'',
]
}
if (color.length === 5) {
// 4-char hex (alpha)
return [
`#${color[1].repeat(2)}${color[2].repeat(2)}${color[3].repeat(2)}`,
color[4].repeat(2),
]
}
if (color.length === 9) {
// 8-char hex (alpha)
return [`#${color.slice(1, -2)}`, color.slice(-2)]
}
return [color, '']
}
export const wrapperClassName = 'cm-css-color-picker-wrapper'
class ColorPickerWidget extends WidgetType {
private readonly state: PickerState
private readonly color: string
constructor({ color, ...state }: WidgetOptions) {
super()
this.state = state
this.color = color
}
eq(other: ColorPickerWidget) {
return (
other.state.colorType === this.state.colorType &&
other.color === this.color &&
other.state.from === this.state.from &&
other.state.to === this.state.to &&
other.state.alpha === this.state.alpha
)
}
toDOM() {
const picker = document.createElement('input')
pickerState.set(picker, this.state)
picker.type = 'color'
picker.value = this.color
const wrapper = document.createElement('span')
wrapper.appendChild(picker)
wrapper.className = wrapperClassName
return wrapper
}
ignoreEvent() {
return false
}
}
export const colorPickerTheme = EditorView.baseTheme({
[`.${wrapperClassName}`]: {
display: 'inline-block',
outline: '1px solid #eee',
marginRight: '0.6ch',
height: '1em',
width: '1em',
transform: 'translateY(1px)',
},
[`.${wrapperClassName} input[type="color"]`]: {
cursor: 'pointer',
height: '100%',
width: '100%',
padding: 0,
border: 'none',
'&::-webkit-color-swatch-wrapper': {
padding: 0,
},
'&::-webkit-color-swatch': {
border: 'none',
},
'&::-moz-color-swatch': {
border: 'none',
},
},
})
interface IFactoryOptions {
discoverColors: typeof discoverColorsInKCL
}
export const makeColorPicker = (options: IFactoryOptions) =>
ViewPlugin.fromClass(
class ColorPickerViewPlugin {
decorations: DecorationSet
constructor(view: EditorView) {
this.decorations = colorPickersDecorations(view, options.discoverColors)
}
update(update: ViewUpdate) {
if (update.docChanged || update.viewportChanged) {
this.decorations = colorPickersDecorations(
update.view,
options.discoverColors
)
}
}
},
{
decorations: (v) => v.decorations,
eventHandlers: {
change: (e, view) => {
const target = e.target as HTMLInputElement
if (
target.nodeName !== 'INPUT' ||
!target.parentElement ||
!target.parentElement.classList.contains(wrapperClassName)
) {
return false
}
const data = pickerState.get(target)!
let converted = '"' + target.value + data.alpha + '"'
view.dispatch({
changes: {
from: data.from,
to: data.to,
insert: converted,
},
})
return true
},
},
}
)
export const colorPicker: Extension = [
makeColorPicker({ discoverColors: discoverColorsInKCL }),
colorPickerTheme,
]

View File

@ -7,6 +7,7 @@ import type {
} from '@kittycad/codemirror-lsp-client'
import {
lspCodeActionEvent,
lspColorUpdateEvent,
lspFormatCodeEvent,
lspPlugin,
lspRenameEvent,
@ -88,6 +89,8 @@ export class KclPlugin implements PluginValue {
isRelevant = true
} else if (tr.annotation(lspCodeActionEvent.type)) {
isRelevant = true
} else if (tr.annotation(lspColorUpdateEvent.type)) {
isRelevant = true
}
// Don't make this an else.

View File

@ -8,7 +8,6 @@ import type {
import type * as LSP from 'vscode-languageserver-protocol'
import { kclPlugin } from '@src/editor/plugins/lsp/kcl'
import { colorPicker } from '@src/editor/plugins/lsp/kcl/colors'
export interface LanguageOptions {
workspaceFolders: LSP.WorkspaceFolder[]
@ -22,7 +21,6 @@ export interface LanguageOptions {
export function kcl(options: LanguageOptions) {
return new LanguageSupport(KclLanguage, [
colorPicker,
kclPlugin({
documentUri: options.documentUri,
workspaceFolders: options.workspaceFolders,

View File

@ -212,12 +212,6 @@ code {
z-index: 99999999999 !important;
}
.cm-rename-popup input {
/* use black text on white background in both light and dark mode */
color: black !important;
background: white !important;
}
@keyframes blink {
0%,
100% {

View File

@ -1,4 +1,3 @@
import { TEST } from '@src/env'
import type { Models } from '@kittycad/lib'
import { VITE_KC_API_WS_MODELING_URL, VITE_KC_DEV_TOKEN } from '@src/env'
import { jsAppSettings } from '@src/lib/settings/settingsUtils'
@ -8,7 +7,6 @@ import type { MachineManager } from '@src/components/MachineManagerProvider'
import type { useModelingContext } from '@src/hooks/useModelingContext'
import type { KclManager } from '@src/lang/KclSingleton'
import type CodeManager from '@src/lang/codeManager'
import type { SceneInfra } from '@src/clientSideScene/sceneInfra'
import type { EngineCommand, ResponseMap } from '@src/lang/std/artifactGraph'
import type { CommandLog } from '@src/lang/std/commandLog'
import { CommandLogType } from '@src/lang/std/commandLog'
@ -111,13 +109,6 @@ export const CONNECTION_ERROR_TEXT: Record<ConnectionError, string> = {
'An unexpected error occurred. Please report this to us.',
}
export const WEBSOCKET_READYSTATE_TEXT: Record<number, string> = {
[WebSocket.CONNECTING]: 'WebSocket.CONNECTING',
[WebSocket.OPEN]: 'WebSocket.OPEN',
[WebSocket.CLOSING]: 'WebSocket.CLOSING',
[WebSocket.CLOSED]: 'WebSocket.CLOSED',
}
export interface ErrorType {
// The error we've encountered.
error: ConnectionError
@ -217,9 +208,6 @@ export enum EngineConnectionEvents {
// We can eventually use it for more, but one step at a time.
ConnectionStateChanged = 'connection-state-changed', // (state: EngineConnectionState) => void
// There are various failure scenarios where we want to try a restart.
RestartRequest = 'restart-request',
// These are used for the EngineCommandManager and were created
// before onConnectionStateChange existed.
ConnectionStarted = 'connection-started', // (engineConnection: EngineConnection) => void
@ -250,23 +238,11 @@ class EngineConnection extends EventTarget {
pc?: RTCPeerConnection
unreliableDataChannel?: RTCDataChannel
mediaStream?: MediaStream
idleMode: boolean = false
promise?: Promise<void>
sdpAnswer?: RTCSessionDescriptionInit
triggeredStart = false
onWebSocketOpen = function (event: Event) {}
onWebSocketClose = function (event: Event) {}
onWebSocketError = function (event: Event) {}
onWebSocketMessage = function (event: MessageEvent) {}
onIceGatheringStateChange = function (
this: RTCPeerConnection,
event: Event
) {}
onIceConnectionStateChange = function (
this: RTCPeerConnection,
event: Event
) {}
onNegotiationNeeded = function (this: RTCPeerConnection, event: Event) {}
onIceCandidate = function (
this: RTCPeerConnection,
event: RTCPeerConnectionIceEvent
@ -276,9 +252,6 @@ class EngineConnection extends EventTarget {
event: RTCPeerConnectionIceErrorEvent
) {}
onConnectionStateChange = function (this: RTCPeerConnection, event: Event) {}
onSignalingStateChange = function (this: RTCDataChannel, event: Event) {}
onTrack = function (this: RTCPeerConnection, event: RTCTrackEvent) {}
onDataChannelOpen = function (this: RTCDataChannel, event: Event) {}
onDataChannelClose = function (this: RTCDataChannel, event: Event) {}
onDataChannelError = function (this: RTCDataChannel, event: Event) {}
@ -287,7 +260,11 @@ class EngineConnection extends EventTarget {
this: RTCPeerConnection,
event: RTCDataChannelEvent
) {}
onTrack = function (this: RTCPeerConnection, event: RTCTrackEvent) {}
onWebSocketOpen = function (event: Event) {}
onWebSocketClose = function (event: Event) {}
onWebSocketError = function (event: Event) {}
onWebSocketMessage = function (event: MessageEvent) {}
onNetworkStatusReady = () => {}
private _state: EngineConnectionState = {
@ -332,10 +309,10 @@ class EngineConnection extends EventTarget {
private engineCommandManager: EngineCommandManager
private pingPongSpan: { ping?: number; pong?: number }
private pingIntervalId: ReturnType<typeof setInterval> | undefined = undefined
private pingIntervalId: ReturnType<typeof setInterval> | null = null
isUsingConnectionLite: boolean = false
timeoutToForceConnectId: ReturnType<typeof setTimeout> | undefined = undefined
timeoutToForceConnectId: ReturnType<typeof setTimeout> | null = null
constructor({
engineCommandManager,
@ -439,18 +416,18 @@ class EngineConnection extends EventTarget {
return this.state.type === EngineConnectionStateType.ConnectionEstablished
}
tearDown() {
clearInterval(this.pingIntervalId)
clearTimeout(this.timeoutToForceConnectId)
// As each network connection (websocket, webrtc, peer connection) is
// closed, they will handle removing their own event listeners.
// If they didn't then it'd be possible we stop listened to close events
// which is what we want to do in the first place :)
tearDown(opts?: { idleMode: boolean }) {
this.idleMode = opts?.idleMode ?? false
if (this.pingIntervalId) {
clearInterval(this.pingIntervalId)
}
if (this.timeoutToForceConnectId) {
clearTimeout(this.timeoutToForceConnectId)
}
this.disconnectAll()
if (this.engineCommandManager.idleMode) {
if (this.idleMode) {
this.state = {
type: EngineConnectionStateType.Disconnecting,
value: {
@ -458,7 +435,6 @@ class EngineConnection extends EventTarget {
},
}
}
// Pass the state along
if (this.state.type === EngineConnectionStateType.Disconnecting) return
if (this.state.type === EngineConnectionStateType.Disconnected) return
@ -592,41 +568,30 @@ class EngineConnection extends EventTarget {
}, 3000)
}
this.pc?.addEventListener?.('icecandidate', this.onIceCandidate)
// Watch out human! The names of the next couple events are really similar!
this.onIceGatheringStateChange = (event) => {
console.log('icegatheringstatechange', event)
that.initiateConnectionExclusive()
}
this.pc?.addEventListener?.(
'icegatheringstatechange',
this.onIceGatheringStateChange
function (_event) {
console.log('icegatheringstatechange', this.iceGatheringState)
if (this.iceGatheringState !== 'complete') return
that.initiateConnectionExclusive()
}
)
this.onIceConnectionStateChange = (event: Event) => {
console.log('iceconnectionstatechange', event)
}
this.pc?.addEventListener?.(
'iceconnectionstatechange',
this.onIceConnectionStateChange
)
this.onNegotiationNeeded = (event: Event) => {
console.log('negotiationneeded', event)
}
this.pc?.addEventListener?.(
'negotiationneeded',
this.onNegotiationNeeded
)
this.onSignalingStateChange = (event) => {
console.log('signalingstatechange', event)
}
this.pc?.addEventListener?.(
'signalingstatechange',
this.onSignalingStateChange
function (_event) {
console.log('iceconnectionstatechange', this.iceConnectionState)
console.log('iceconnectionstatechange', this.iceGatheringState)
}
)
this.pc?.addEventListener?.('negotiationneeded', function (_event) {
console.log('negotiationneeded', this.iceConnectionState)
console.log('negotiationneeded', this.iceGatheringState)
})
this.pc?.addEventListener?.('signalingstatechange', function (event) {
console.log('signalingstatechange', this.signalingState)
})
this.onIceCandidateError = (_event: Event) => {
const event = _event as RTCPeerConnectionIceErrorEvent
@ -653,12 +618,38 @@ class EngineConnection extends EventTarget {
detail: { conn: this, mediaStream: this.mediaStream! },
})
)
break
setTimeout(() => {
// Everything is now connected.
this.state = {
type: EngineConnectionStateType.ConnectionEstablished,
}
this.engineCommandManager.inSequence = 1
this.dispatchEvent(
new CustomEvent(EngineConnectionEvents.Opened, {
detail: this,
})
)
markOnce('code/endInitialEngineConnect')
}, 2000)
break
case 'connecting':
break
case 'disconnected':
case 'failed':
this.pc?.removeEventListener('icecandidate', this.onIceCandidate)
this.pc?.removeEventListener(
'icecandidateerror',
this.onIceCandidateError
)
this.pc?.removeEventListener(
'connectionstatechange',
this.onConnectionStateChange
)
this.pc?.removeEventListener('track', this.onTrack)
this.state = {
type: EngineConnectionStateType.Disconnecting,
value: {
@ -671,43 +662,6 @@ class EngineConnection extends EventTarget {
}
this.disconnectAll()
break
// The remote end broke up with us! :(
case 'disconnected':
this.dispatchEvent(
new CustomEvent(EngineConnectionEvents.RestartRequest, {})
)
break
case 'closed':
this.pc?.removeEventListener('icecandidate', this.onIceCandidate)
this.pc?.removeEventListener(
'icegatheringstatechange',
this.onIceGatheringStateChange
)
this.pc?.removeEventListener(
'iceconnectionstatechange',
this.onIceConnectionStateChange
)
this.pc?.removeEventListener(
'negotiationneeded',
this.onNegotiationNeeded
)
this.pc?.removeEventListener(
'signalingstatechange',
this.onSignalingStateChange
)
this.pc?.removeEventListener(
'icecandidateerror',
this.onIceCandidateError
)
this.pc?.removeEventListener(
'connectionstatechange',
this.onConnectionStateChange
)
this.pc?.removeEventListener('track', this.onTrack)
this.pc?.removeEventListener('datachannel', this.onDataChannel)
break
default:
break
}
@ -781,7 +735,9 @@ class EngineConnection extends EventTarget {
// The app is eager to use the MediaStream; as soon as onNewTrack is
// called, the following sequence happens:
// EngineConnection.onNewTrack -> StoreState.setMediaStream ->
// EngineStream.tsx reacts to mediaStream change, setting a video element.
// Stream.tsx reacts to mediaStream change, setting a video element.
// We wait until connectionstatechange changes to "connected"
// to pass it to the rest of the application.
this.mediaStream = mediaStream
}
@ -805,25 +761,6 @@ class EngineConnection extends EventTarget {
type: ConnectingType.DataChannelEstablished,
},
}
// Start firing off engine commands at this point.
// They could be fired at an earlier time, onWebSocketOpen,
// but DataChannel can offer some benefits like speed,
// and it's nice to say everything's connected before interacting
// with the server.
this.state = {
type: EngineConnectionStateType.ConnectionEstablished,
}
this.engineCommandManager.inSequence = 1
this.dispatchEvent(
new CustomEvent(EngineConnectionEvents.Opened, {
detail: this,
})
)
markOnce('code/endInitialEngineConnect')
}
this.unreliableDataChannel?.addEventListener(
'open',
@ -847,6 +784,7 @@ class EngineConnection extends EventTarget {
'message',
this.onDataChannelMessage
)
this.pc?.removeEventListener('datachannel', this.onDataChannel)
this.disconnectAll()
}
@ -960,19 +898,16 @@ class EngineConnection extends EventTarget {
}
this.websocket.addEventListener('close', this.onWebSocketClose)
this.onWebSocketError = (event: Event) => {
if (event.target instanceof WebSocket) {
this.state = {
type: EngineConnectionStateType.Disconnecting,
this.onWebSocketError = (event) => {
this.state = {
type: EngineConnectionStateType.Disconnecting,
value: {
type: DisconnectingType.Error,
value: {
type: DisconnectingType.Error,
value: {
error: ConnectionError.WebSocketError,
context:
WEBSOCKET_READYSTATE_TEXT[event.target.readyState] ?? event,
},
error: ConnectionError.WebSocketError,
context: event,
},
}
},
}
this.disconnectAll()
@ -1278,27 +1213,20 @@ class EngineConnection extends EventTarget {
!this.websocket ||
this.websocket?.readyState === 3
if (!(closedPc && closedUDC && closedWS)) {
return
}
// Clean up all the event listeners.
if (!this.engineCommandManager.idleMode) {
// Do not notify the rest of the program that we have cut off anything.
this.state = { type: EngineConnectionStateType.Disconnected }
this.dispatchEvent(
new CustomEvent(EngineConnectionEvents.RestartRequest, {})
)
} else {
this.state = {
type: EngineConnectionStateType.Disconnecting,
value: {
type: DisconnectingType.Pause,
},
if (closedPc && closedUDC && closedWS) {
if (!this.idleMode) {
// Do not notify the rest of the program that we have cut off anything.
this.state = { type: EngineConnectionStateType.Disconnected }
} else {
this.state = {
type: EngineConnectionStateType.Disconnecting,
value: {
type: DisconnectingType.Pause,
},
}
}
this.triggeredStart = false
}
this.triggeredStart = false
}
}
@ -1326,9 +1254,6 @@ export enum EngineCommandManagerEvents {
// engineConnection is available but scene setup may not have run
EngineAvailable = 'engine-available',
// request a restart of engineConnection
EngineRestartRequest = 'engine-restart-request',
// the whole scene is ready (settings loaded)
SceneReady = 'scene-ready',
}
@ -1441,29 +1366,10 @@ export class EngineCommandManager extends EventTarget {
kclManager: null | KclManager = null
codeManager?: CodeManager
rustContext?: RustContext
sceneInfra?: SceneInfra
// The current "manufacturing machine" aka 3D printer, CNC, etc.
public machineManager: MachineManager | null = null
// Dispatch to the application the engine needs a restart.
private onEngineConnectionRestartRequest = () => {
this.dispatchEvent(
new CustomEvent(EngineCommandManagerEvents.EngineRestartRequest, {})
)
}
private onOffline = () => {
console.log('Browser reported network is offline')
if (TEST) {
console.warn('DURING TESTS ENGINECONNECTION.ONOFFLINE WILL DO NOTHING.')
return
}
this.onEngineConnectionRestartRequest()
}
idleMode: boolean = false
start({
setMediaStream,
setIsStreamReady,
@ -1509,8 +1415,6 @@ export class EngineCommandManager extends EventTarget {
return
}
window.addEventListener('offline', this.onOffline)
let additionalSettings = this.settings.enableSSAO ? '&post_effect=ssao' : ''
additionalSettings +=
'&show_grid=' + (this.settings.showScaleGrid ? 'true' : 'false')
@ -1532,51 +1436,29 @@ export class EngineCommandManager extends EventTarget {
})
)
this.engineConnection.addEventListener(
EngineConnectionEvents.RestartRequest,
this.onEngineConnectionRestartRequest as EventListener
)
// eslint-disable-next-line @typescript-eslint/no-misused-promises
this.onEngineConnectionOpened = async () => {
console.log('onEngineConnectionOpened')
try {
console.log('clearing scene and busting cache')
await this.rustContext?.clearSceneAndBustCache(
await jsAppSettings(),
this.codeManager?.currentFilePath || undefined
)
} catch (e) {
// If this happens shit's actually gone south aka the websocket closed.
// Let's restart.
console.warn("shit's gone south")
console.warn(e)
this.engineConnection?.dispatchEvent(
new CustomEvent(EngineConnectionEvents.RestartRequest, {})
)
return
}
await this.rustContext?.clearSceneAndBustCache(
await jsAppSettings(),
this.codeManager?.currentFilePath || undefined
)
// Set the stream's camera projection type
// We don't send a command to the engine if in perspective mode because
// for now it's the engine's default.
if (settings.cameraProjection === 'orthographic') {
console.log('Setting camera to orthographic')
await this.sendSceneCommand({
this.sendSceneCommand({
type: 'modeling_cmd_req',
cmd_id: uuidv4(),
cmd: {
type: 'default_camera_set_orthographic',
},
})
}).catch(reportRejection)
}
// Set the theme
console.log('Setting theme', this.settings.theme)
await this.setTheme(this.settings.theme)
this.setTheme(this.settings.theme).catch(reportRejection)
// Set up a listener for the dark theme media query
console.log('Setup theme media query change')
darkModeMatcher?.addEventListener(
'change',
this.onDarkThemeMediaQueryChange
@ -1584,8 +1466,7 @@ export class EngineCommandManager extends EventTarget {
// Set the edge lines visibility
// eslint-disable-next-line @typescript-eslint/no-floating-promises
console.log('setting edge_lines_visible')
await this.sendSceneCommand({
this.sendSceneCommand({
type: 'modeling_cmd_req',
cmd_id: uuidv4(),
cmd: {
@ -1594,30 +1475,21 @@ export class EngineCommandManager extends EventTarget {
},
})
console.log('camControlsCameraChange')
this._camControlsCameraChange()
// We should eventually only have 1 restoral call.
if (this.idleMode) {
await this.sceneInfra?.camControls.restoreRemoteCameraStateAndTriggerSync()
} else {
// NOTE: This code is old. It uses the old hack to restore camera.
console.log('call default_camera_get_settings')
// eslint-disable-next-line @typescript-eslint/no-floating-promises
await this.sendSceneCommand({
// CameraControls subscribes to default_camera_get_settings response events
// firing this at connection ensure the camera's are synced initially
type: 'modeling_cmd_req',
cmd_id: uuidv4(),
cmd: {
type: 'default_camera_get_settings',
},
})
}
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.sendSceneCommand({
// CameraControls subscribes to default_camera_get_settings response events
// firing this at connection ensure the camera's are synced initially
type: 'modeling_cmd_req',
cmd_id: uuidv4(),
cmd: {
type: 'default_camera_get_settings',
},
})
setIsStreamReady(true)
console.log('Dispatching SceneReady')
// Other parts of the application should use this to react on scene ready.
this.dispatchEvent(
new CustomEvent(EngineCommandManagerEvents.SceneReady, {
@ -1683,7 +1555,7 @@ export class EngineCommandManager extends EventTarget {
}) as EventListener)
this.onVideoTrackMute = () => {
console.warn('video track mute - potentially lost stream for a moment')
console.error('video track mute: check webrtc internals -> inbound rtp')
}
this.onEngineConnectionNewTrack = ({
@ -1874,11 +1746,9 @@ export class EngineCommandManager extends EventTarget {
this.engineConnection?.send(resizeCmd)
}
tearDown(opts?: { idleMode: boolean }) {
this.idleMode = opts?.idleMode ?? false
window.removeEventListener('offline', this.onOffline)
tearDown(opts?: {
idleMode: boolean
}) {
if (this.engineConnection) {
for (const [cmdId, pending] of Object.entries(this.pendingCommands)) {
pending.reject([
@ -1916,14 +1786,14 @@ export class EngineCommandManager extends EventTarget {
this.onDarkThemeMediaQueryChange
)
this.engineConnection?.tearDown()
this.engineConnection?.tearDown(opts)
// Our window.engineCommandManager.tearDown assignment causes this case to happen which is
// only really for tests.
// @ts-ignore
} else if (this.engineCommandManager?.engineConnection) {
// @ts-ignore
this.engineCommandManager?.engineConnection?.tearDown()
this.engineCommandManager?.engineConnection?.tearDown(opts)
// @ts-ignore
this.engineCommandManager.engineConnection = null
}
@ -2242,25 +2112,25 @@ export class EngineCommandManager extends EventTarget {
// Set the stream background color
// This takes RGBA values from 0-1
// So we convert from the conventional 0-255 found in Figma
await this.sendSceneCommand({
this.sendSceneCommand({
cmd_id: uuidv4(),
type: 'modeling_cmd_req',
cmd: {
type: 'set_background_color',
color: getThemeColorForEngine(theme),
},
})
}).catch(reportRejection)
// Sets the default line colors
const opposingTheme = getOppositeTheme(theme)
await this.sendSceneCommand({
this.sendSceneCommand({
cmd_id: uuidv4(),
type: 'modeling_cmd_req',
cmd: {
type: 'set_default_system_properties',
color: getThemeColorForEngine(opposingTheme),
},
})
}).catch(reportRejection)
}
}

View File

@ -12,6 +12,12 @@ import type {
VariableDeclarator,
} from '@src/lang/wasm'
import { isPathToNode } from '@src/lang/wasm'
import {
loftValidator,
revolveAxisValidator,
shellValidator,
sweepValidator,
} from '@src/lib/commandBarConfigs/validators'
import type {
KclCommandValue,
StateMachineCommandSetConfig,
@ -422,6 +428,7 @@ export const modelingMachineCommandConfig: StateMachineCommandSetConfig<
selectionTypes: ['segment'],
required: true,
multiple: false,
validation: sweepValidator,
hidden: (context) => Boolean(context.argumentsToSubmit.nodeToEdit),
},
sectional: {
@ -448,6 +455,7 @@ export const modelingMachineCommandConfig: StateMachineCommandSetConfig<
selectionTypes: ['solid2d'],
multiple: true,
required: true,
validation: loftValidator,
},
},
},
@ -500,6 +508,7 @@ export const modelingMachineCommandConfig: StateMachineCommandSetConfig<
inputType: 'selection',
selectionTypes: ['segment', 'sweepEdge', 'edgeCutEdge'],
multiple: false,
validation: revolveAxisValidator,
hidden: (context) => Boolean(context.argumentsToSubmit.nodeToEdit),
},
angle: {
@ -526,6 +535,7 @@ export const modelingMachineCommandConfig: StateMachineCommandSetConfig<
selectionTypes: ['cap', 'wall'],
multiple: true,
required: true,
validation: shellValidator,
hidden: (context) => Boolean(context.argumentsToSubmit.nodeToEdit),
},
thickness: {

View File

@ -0,0 +1,13 @@
import { parseEngineErrorMessage } from '@src/lib/commandBarConfigs/validators'
describe('parseEngineErrorMessage', () => {
it('takes an engine error string and parses its json message', () => {
const engineError =
'[{"success": false,"request_id": "e6c0104b-ec60-4779-8e98-722f0a5019ec","errors": [{"error_code": "internal_engine","message": "Trajectory curve must be G1 continuous (with continuous tangents)"}]}]'
const parsedEngineError = JSON.parse(engineError)
const message = parseEngineErrorMessage(parsedEngineError)
expect(message).toEqual(
'Trajectory curve must be G1 continuous (with continuous tangents)'
)
})
})

View File

@ -0,0 +1,317 @@
import type { Models } from '@kittycad/lib'
import type { Selections } from '@src/lib/selections'
import { engineCommandManager, kclManager } from '@src/lib/singletons'
import { isArray, uuidv4 } from '@src/lib/utils'
import type { CommandBarContext } from '@src/machines/commandBarMachine'
export const disableDryRunWithRetry = async (numberOfRetries = 3) => {
for (let tries = 0; tries < numberOfRetries; tries++) {
try {
await engineCommandManager.sendSceneCommand({
type: 'modeling_cmd_req',
cmd_id: uuidv4(),
cmd: { type: 'disable_dry_run' },
})
// Exit out since the command was successful
return
} catch (e) {
console.error(e)
console.error('disable_dry_run failed. This is bad!')
}
}
}
// Takes a callback function and wraps it around enable_dry_run and disable_dry_run
export const dryRunWrapper = async (
callback: () => Promise<
| Models['WebSocketResponse_type']
| [Models['WebSocketResponse_type']]
| undefined
| null
>
): Promise<[Models['WebSocketResponse_type']] | undefined> => {
// Gotcha: What about race conditions?
try {
await engineCommandManager.sendSceneCommand({
type: 'modeling_cmd_req',
cmd_id: uuidv4(),
cmd: { type: 'enable_dry_run' },
})
const result = await callback()
if (!result) {
return undefined
}
if (isArray(result)) {
return result
}
return [result]
} catch (e) {
console.error(e)
} finally {
await disableDryRunWithRetry(5)
}
}
function isSelections(selections: unknown): selections is Selections {
return (
(selections as Selections).graphSelections !== undefined &&
(selections as Selections).otherSelections !== undefined
)
}
export function parseEngineErrorMessage(
engineErrors?: [Models['WebSocketResponse_type']]
): string | undefined {
if (!engineErrors) {
return undefined
}
if (!engineErrors[0]) {
return undefined
}
const engineError = engineErrors[0]
if (engineError.success) {
return undefined
}
const errors = engineError.errors
if (!errors[0]) {
return undefined
}
return errors[0].message
}
export const revolveAxisValidator = async ({
data,
context,
}: {
data: { [key: string]: Selections }
context: CommandBarContext
}): Promise<boolean | string> => {
if (!isSelections(context.argumentsToSubmit.sketches)) {
return 'Unable to revolve, selections are missing'
}
// Gotcha: this validation only works for the first sketch
const artifact =
context.argumentsToSubmit.sketches.graphSelections[0].artifact
if (!artifact) {
return 'Unable to revolve, sketch not found'
}
if (!('pathId' in artifact)) {
return 'Unable to revolve, sketch has no path'
}
const sketchSelection = artifact.pathId
let edgeSelection = data.edge.graphSelections[0].artifact?.id
if (!sketchSelection) {
return 'Unable to revolve, sketch is missing'
}
if (!edgeSelection) {
return 'Unable to revolve, edge is missing'
}
const angleInDegrees: Models['Angle_type'] = {
unit: 'degrees',
value: 360,
}
const command = async () => {
return await engineCommandManager.sendSceneCommand({
type: 'modeling_cmd_req',
cmd_id: uuidv4(),
cmd: {
type: 'revolve_about_edge',
angle: angleInDegrees,
edge_id: edgeSelection,
target: sketchSelection,
// Gotcha: Playwright will fail with larger tolerances, need to use a smaller one.
tolerance: 1e-7,
// WARNING: I'm not sure this is what it should be.
opposite: 'None',
},
})
}
const result = await dryRunWrapper(command)
if (result && result[0] && result[0].success) {
return true
}
const reason = parseEngineErrorMessage(result) || 'unknown'
return `Unable to revolve with the current selection. Reason: ${reason}`
}
export const loftValidator = async ({
data,
}: {
data: { [key: string]: Selections }
context: CommandBarContext
}): Promise<boolean | string> => {
if (!isSelections(data.sketches)) {
return 'Unable to loft, selections are missing'
}
const { sketches } = data
if (sketches.graphSelections.some((s) => s.artifact?.type !== 'solid2d')) {
return 'Unable to loft, some selection are not solid2ds'
}
const sectionIds = sketches.graphSelections.flatMap((s) =>
s.artifact?.type === 'solid2d' ? s.artifact.pathId : []
)
if (sectionIds.length < 2) {
return 'Unable to loft, selection contains less than two solid2ds'
}
const command = async () => {
// TODO: check what to do with these
const DEFAULT_V_DEGREE = 2
const DEFAULT_TOLERANCE = 2
const DEFAULT_BEZ_APPROXIMATE_RATIONAL = false
return await engineCommandManager.sendSceneCommand({
type: 'modeling_cmd_req',
cmd_id: uuidv4(),
cmd: {
section_ids: sectionIds,
type: 'loft',
bez_approximate_rational: DEFAULT_BEZ_APPROXIMATE_RATIONAL,
tolerance: DEFAULT_TOLERANCE,
v_degree: DEFAULT_V_DEGREE,
},
})
}
const result = await dryRunWrapper(command)
if (result && result[0] && result[0].success) {
return true
}
const reason = parseEngineErrorMessage(result) || 'unknown'
return `Unable to loft with the current selection. Reason: ${reason}`
}
export const shellValidator = async ({
data,
}: {
data: { selection: Selections }
}): Promise<boolean | string> => {
if (!isSelections(data.selection)) {
return 'Unable to shell, selections are missing'
}
// No validation on the faces, filtering is done upstream and we have the dry run validation just below
const face_ids = data.selection.graphSelections.flatMap((s) =>
s.artifact ? s.artifact.id : []
)
// We don't have the concept of solid3ds in TS yet.
// So we're listing out the sweeps as if they were solids and taking the first one, just like in Rust for Shell:
// https://github.com/KittyCAD/modeling-app/blob/e61fff115b9fa94aaace6307b1842cc15d41655e/src/wasm-lib/kcl/src/std/shell.rs#L237-L238
// TODO: This is one cheap way to make sketch-on-face supported now but will likely fail multiple solids
const object_id = kclManager.artifactGraph
.values()
.find((v) => v.type === 'sweep')?.pathId
if (!object_id) {
return "Unable to shell, couldn't find the solid"
}
const command = async () => {
// TODO: figure out something better than an arbitrarily small value
const DEFAULT_THICKNESS: Models['LengthUnit_type'] = 1e-9
const DEFAULT_HOLLOW = false
const cmdArgs = {
face_ids,
object_id,
hollow: DEFAULT_HOLLOW,
shell_thickness: DEFAULT_THICKNESS,
}
return await engineCommandManager.sendSceneCommand({
type: 'modeling_cmd_req',
cmd_id: uuidv4(),
cmd: {
type: 'solid3d_shell_face',
...cmdArgs,
},
})
}
const result = await dryRunWrapper(command)
if (result && result[0] && result[0].success) {
return true
}
const reason = parseEngineErrorMessage(result) || 'unknown'
return `Unable to shell with the current selection. Reason: ${reason}`
}
export const sweepValidator = async ({
context,
data,
}: {
context: CommandBarContext
data: { path: Selections }
}): Promise<boolean | string> => {
if (!isSelections(data.path)) {
console.log('Unable to sweep, selections are missing')
return 'Unable to sweep, selections are missing'
}
// Retrieve the parent path from the segment selection directly
const trajectoryArtifact = data.path.graphSelections[0].artifact
if (!trajectoryArtifact) {
return "Unable to sweep, couldn't find the trajectory artifact"
}
if (trajectoryArtifact.type !== 'segment') {
return "Unable to sweep, couldn't find the target from a non-segment selection"
}
const trajectory = trajectoryArtifact.pathId
// Get the former arg in the command bar flow, and retrieve the path from the solid2d directly
const targetArg = context.argumentsToSubmit['sketches'] as Selections
const targetArtifact = targetArg.graphSelections[0].artifact
if (!targetArtifact) {
return "Unable to sweep, couldn't find the profile artifact"
}
if (targetArtifact.type !== 'solid2d') {
return "Unable to sweep, couldn't find the target from a non-solid2d selection"
}
const target = targetArtifact.pathId
const command = async () => {
// TODO: second look on defaults here
const DEFAULT_TOLERANCE: Models['LengthUnit_type'] = 1e-7
const DEFAULT_SECTIONAL = false
const cmdArgs = {
target,
trajectory,
sectional: DEFAULT_SECTIONAL,
tolerance: DEFAULT_TOLERANCE,
}
return await engineCommandManager.sendSceneCommand({
type: 'modeling_cmd_req',
cmd_id: uuidv4(),
cmd: {
type: 'sweep',
...cmdArgs,
},
})
}
const result = await dryRunWrapper(command)
if (result && result[0] && result[0].success) {
return true
}
const reason = parseEngineErrorMessage(result) || 'unknown'
return `Unable to sweep with the current selection. Reason: ${reason}`
}

View File

@ -24,7 +24,6 @@ import { Themes } from '@src/lib/theme'
import { reportRejection } from '@src/lib/trap'
import { isEnumMember } from '@src/lib/types'
import { capitaliseFC, isArray, toSync } from '@src/lib/utils'
import { IS_NIGHTLY_OR_DEBUG } from '@src/routes/utils'
/**
* A setting that can be set at the user or project level
@ -212,7 +211,7 @@ export function createSettings() {
* Stream resource saving behavior toggle
*/
streamIdleMode: new Setting<number | undefined>({
defaultValue: IS_NIGHTLY_OR_DEBUG ? 30 * 1000 : 5 * MS_IN_MINUTE,
defaultValue: 5 * MS_IN_MINUTE,
hideOnLevel: 'project',
hideOnPlatform: 'both',
description: 'Save bandwidth & battery',

View File

@ -74,7 +74,6 @@ editorManager.kclManager = kclManager
// TODO: proper dependency injection.
engineCommandManager.kclManager = kclManager
engineCommandManager.codeManager = codeManager
engineCommandManager.sceneInfra = sceneInfra
engineCommandManager.rustContext = rustContext
kclManager.sceneInfraBaseUnitMultiplierSetter = (unit: BaseUnit) => {

View File

@ -4,53 +4,41 @@ import { assign, fromPromise, setup } from 'xstate'
import type { AppMachineContext } from '@src/lib/types'
export enum EngineStreamState {
WaitingForDependencies = 'waiting-for-dependencies',
WaitingForMediaStream = 'waiting-for-media-stream',
WaitingToPlay = 'waiting-to-play',
Off = 'off',
On = 'on',
WaitForMediaStream = 'wait-for-media-stream',
Playing = 'playing',
Reconfiguring = 'reconfiguring',
Paused = 'paused',
Stopped = 'stopped',
// The is the state in-between Paused and Playing *specifically that order*.
Resuming = 'resuming',
}
export enum EngineStreamTransition {
// This brings us back to the configuration loop
WaitForDependencies = 'wait-for-dependencies',
// Our dependencies to set
SetMediaStream = 'set-media-stream',
SetPool = 'set-pool',
SetAuthToken = 'set-auth-token',
SetVideoRef = 'set-video-ref',
SetCanvasRef = 'set-canvas-ref',
SetMediaStream = 'set-media-stream',
// Stream operations
Play = 'play',
Resume = 'resume',
Pause = 'pause',
Stop = 'stop',
// Used to reconfigure the stream during connection
StartOrReconfigureEngine = 'start-or-reconfigure-engine',
}
export interface EngineStreamContext {
pool: string | null
authToken: string | undefined
mediaStream: MediaStream | null
videoRef: MutableRefObject<HTMLVideoElement | null>
canvasRef: MutableRefObject<HTMLCanvasElement | null>
mediaStream: MediaStream | null
zoomToFit: boolean
}
export const engineStreamContextCreate = (): EngineStreamContext => ({
pool: null,
authToken: undefined,
mediaStream: null,
videoRef: { current: null },
canvasRef: { current: null },
mediaStream: null,
zoomToFit: true,
})
@ -89,6 +77,76 @@ export const engineStreamMachine = setup({
input: {} as EngineStreamContext,
},
actors: {
[EngineStreamTransition.Play]: fromPromise(
async ({
input: { context, params, rootContext },
}: {
input: {
context: EngineStreamContext
params: { zoomToFit: boolean }
rootContext: AppMachineContext
}
}) => {
const canvas = context.canvasRef.current
if (!canvas) return false
const video = context.videoRef.current
if (!video) return false
const mediaStream = context.mediaStream
if (!mediaStream) return false
// If the video is already playing it means we're doing a reconfigure.
// We don't want to re-run the KCL or touch the video element at all.
if (!video.paused) {
return
}
await rootContext.sceneInfra.camControls.restoreRemoteCameraStateAndTriggerSync()
video.style.display = 'block'
canvas.style.display = 'none'
video.srcObject = mediaStream
}
),
[EngineStreamTransition.Pause]: fromPromise(
async ({
input: { context, rootContext },
}: {
input: { context: EngineStreamContext; rootContext: AppMachineContext }
}) => {
const video = context.videoRef.current
if (!video) return
video.pause()
const canvas = context.canvasRef.current
if (!canvas) return
await holdOntoVideoFrameInCanvas(video, canvas)
video.style.display = 'none'
await rootContext.sceneInfra.camControls.saveRemoteCameraState()
// Make sure we're on the next frame for no flickering between canvas
// and the video elements.
window.requestAnimationFrame(
() =>
void (async () => {
// Destroy the media stream. We will re-establish it. We could
// leave everything at pausing, preventing video decoders from running
// but we can do even better by significantly reducing network
// cards also.
context.mediaStream?.getVideoTracks()[0].stop()
context.mediaStream = null
video.srcObject = null
rootContext.engineCommandManager.tearDown({ idleMode: true })
})()
)
}
),
[EngineStreamTransition.StartOrReconfigureEngine]: fromPromise(
async ({
input: { context, event, rootContext },
@ -99,17 +157,21 @@ export const engineStreamMachine = setup({
rootContext: AppMachineContext
}
}) => {
if (!context.authToken) return Promise.reject()
if (!context.videoRef.current) return Promise.reject()
if (!context.canvasRef.current) return Promise.reject()
if (!context.authToken) return
const video = context.videoRef.current
if (!video) return
const canvas = context.canvasRef.current
if (!canvas) return
const { width, height } = getDimensions(
window.innerWidth,
window.innerHeight
)
context.videoRef.current.width = width
context.videoRef.current.height = height
video.width = width
video.height = height
const settingsNext = {
// override the pool param (?pool=) to request a specific engine instance
@ -144,183 +206,51 @@ export const engineStreamMachine = setup({
})
}
),
[EngineStreamTransition.Play]: fromPromise(
async ({
input: { context, params },
}: {
input: { context: EngineStreamContext; params: { zoomToFit: boolean } }
}) => {
if (!context.canvasRef.current) return
if (!context.videoRef.current) return
if (!context.mediaStream) return
// If the video is already playing it means we're doing a reconfigure.
// We don't want to re-run the KCL or touch the video element at all.
if (!context.videoRef.current.paused) {
return
}
// In the past we'd try to play immediately, but the proper thing is to way
// for the 'canplay' event to tell us data is ready.
const onCanPlay = () => {
if (!context.videoRef.current) {
return
}
context.videoRef.current.play().catch(console.error)
// Yes, event listeners can remove themselves because of the
// lazy nature of interpreted languages :D
context.videoRef.current.removeEventListener('canplay', onCanPlay)
}
// We're receiving video frames, so show the video now.
const onPlay = () => {
// We have to give engine time to crunch all the scene setup we
// ask it to do. As far as I can tell it doesn't block until
// they are done, so we must wait.
setTimeout(() => {
if (!context.videoRef.current) {
return
}
if (!context.canvasRef.current) {
return
}
context.videoRef.current.style.display = 'block'
context.canvasRef.current.style.display = 'none'
context.videoRef.current.removeEventListener('play', onPlay)
// I've tried < 400ms and sometimes it's possible to see a flash
// and the camera snap.
}, 400)
}
context.videoRef.current.addEventListener('canplay', onCanPlay)
context.videoRef.current.addEventListener('play', onPlay)
// THIS ASSIGNMENT IS *EXTREMELY* EFFECTFUL! The amount of logic
// this triggers is quite far and wide. It drives the above events.
context.videoRef.current.srcObject = context.mediaStream
}
),
// Pause is also called when leaving the modeling scene. It's possible
// then videoRef and canvasRef are now null due to their DOM elements
// being destroyed.
[EngineStreamTransition.Pause]: fromPromise(
async ({
input: { context, rootContext },
}: {
input: {
context: EngineStreamContext
rootContext: AppMachineContext
}
}) => {
if (context.videoRef.current && context.canvasRef.current) {
await context.videoRef.current.pause()
await holdOntoVideoFrameInCanvas(
context.videoRef.current,
context.canvasRef.current
)
context.videoRef.current.style.display = 'none'
}
await rootContext.sceneInfra.camControls.saveRemoteCameraState()
// Make sure we're on the next frame for no flickering between canvas
// and the video elements.
window.requestAnimationFrame(
() =>
void (async () => {
// Destroy the media stream. We will re-establish it. We could
// leave everything at pausing, preventing video decoders from running
// but we can do even better by significantly reducing network
// cards also.
context.mediaStream?.getVideoTracks()[0].stop()
context.mediaStream = null
if (context.videoRef.current) {
context.videoRef.current.srcObject = null
}
rootContext.engineCommandManager.tearDown({ idleMode: true })
})()
)
}
),
},
}).createMachine({
initial: EngineStreamState.WaitingForDependencies,
initial: EngineStreamState.Off,
context: (initial) => initial.input,
states: {
[EngineStreamState.WaitingForDependencies]: {
[EngineStreamState.Off]: {
reenter: true,
on: {
[EngineStreamTransition.SetPool]: {
target: EngineStreamState.WaitingForDependencies,
actions: [assign({ pool: ({ context, event }) => event.pool })],
target: EngineStreamState.Off,
actions: [assign({ pool: ({ context, event }) => event.data.pool })],
},
[EngineStreamTransition.SetAuthToken]: {
target: EngineStreamState.WaitingForDependencies,
target: EngineStreamState.Off,
actions: [
assign({ authToken: ({ context, event }) => event.authToken }),
],
},
[EngineStreamTransition.SetVideoRef]: {
target: EngineStreamState.WaitingForDependencies,
actions: [
assign({ videoRef: ({ context, event }) => event.videoRef }),
],
},
[EngineStreamTransition.SetCanvasRef]: {
target: EngineStreamState.WaitingForDependencies,
actions: [
assign({ canvasRef: ({ context, event }) => event.canvasRef }),
assign({ authToken: ({ context, event }) => event.data.authToken }),
],
},
[EngineStreamTransition.StartOrReconfigureEngine]: {
target: EngineStreamState.WaitingForMediaStream,
target: EngineStreamState.On,
},
},
},
[EngineStreamState.WaitingForMediaStream]: {
[EngineStreamState.On]: {
reenter: true,
invoke: {
src: EngineStreamTransition.StartOrReconfigureEngine,
input: (args) => ({
context: args.context,
rootContext: args.self.system.get('root').getSnapshot().context,
params: { zoomToFit: args.context.zoomToFit },
event: args.event,
}),
onError: [
{
target: EngineStreamState.WaitingForDependencies,
reenter: true,
},
],
},
on: {
[EngineStreamTransition.StartOrReconfigureEngine]: {
target: EngineStreamState.WaitingForMediaStream,
reenter: true,
},
// Transition requested by engineConnection
[EngineStreamTransition.SetMediaStream]: {
target: EngineStreamState.WaitingToPlay,
target: EngineStreamState.On,
actions: [
assign({ mediaStream: ({ context, event }) => event.mediaStream }),
],
},
},
},
[EngineStreamState.WaitingToPlay]: {
on: {
[EngineStreamTransition.Play]: {
target: EngineStreamState.Playing,
},
// We actually failed inbetween needing to play and sending commands.
[EngineStreamTransition.StartOrReconfigureEngine]: {
target: EngineStreamState.WaitingForMediaStream,
reenter: true,
actions: [assign({ zoomToFit: () => true })],
},
},
},
@ -340,9 +270,6 @@ export const engineStreamMachine = setup({
[EngineStreamTransition.Pause]: {
target: EngineStreamState.Paused,
},
[EngineStreamTransition.Stop]: {
target: EngineStreamState.Stopped,
},
},
},
[EngineStreamState.Reconfiguring]: {
@ -353,7 +280,9 @@ export const engineStreamMachine = setup({
rootContext: args.self.system.get('root').getSnapshot().context,
event: args.event,
}),
onDone: [{ target: EngineStreamState.Playing }],
onDone: {
target: EngineStreamState.Playing,
},
},
},
[EngineStreamState.Paused]: {
@ -370,27 +299,8 @@ export const engineStreamMachine = setup({
},
},
},
[EngineStreamState.Stopped]: {
invoke: {
src: EngineStreamTransition.Pause,
input: (args) => ({
context: args.context,
rootContext: args.self.system.get('root').getSnapshot().context,
}),
onDone: [
{
target: EngineStreamState.WaitingForDependencies,
actions: [
assign({
videoRef: { current: null },
canvasRef: { current: null },
}),
],
},
],
},
},
[EngineStreamState.Resuming]: {
reenter: true,
invoke: {
src: EngineStreamTransition.StartOrReconfigureEngine,
input: (args) => ({
@ -405,11 +315,14 @@ export const engineStreamMachine = setup({
target: EngineStreamState.Paused,
},
[EngineStreamTransition.SetMediaStream]: {
target: EngineStreamState.Playing,
actions: [
assign({ mediaStream: ({ context, event }) => event.mediaStream }),
],
},
[EngineStreamTransition.Play]: {
target: EngineStreamState.Playing,
actions: [assign({ zoomToFit: () => false })],
},
},
},
},

File diff suppressed because it is too large Load Diff