Compare commits

...

19 Commits

Author SHA1 Message Date
777b225066 Cut release v0.21.8 (#2498)
Co-authored-by: Jess Frazelle <jessfraz@users.noreply.github.com>
2024-05-23 18:01:54 -07:00
ae6373e4f5 actually test that we dont exit sketch mode on first escape (#2501)
* actually test that we dont exit sketch mode on first escape

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

* better test

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

---------

Signed-off-by: Jess Frazelle <github@jessfraz.com>
2024-05-24 00:23:15 +00:00
87979b17cf zoom to fit on load (#2201)
* zoom to fit on load

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

* updates

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

* lint

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

* add zoom to fit calls to the correct places

* update comment

* clean up comment

* add snapshot test zoom to git

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

* updates

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

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

* subscribe to camera updates from zoom to fit

* fix types

* partial test fix

* updatges

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

* fix another test

* remove my enhancements

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

---------

Signed-off-by: Jess Frazelle <github@jessfraz.com>
Co-authored-by: Kurt Hutten Irev-Dev <k.hutten@protonmail.ch>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-05-24 00:05:54 +00:00
4be63e7331 Allow any value to start a pipe expression (#2496)
Relaxes an arbitrary restriction. Previously KCL pipelines couldn't start
with a number, e.g. `2 |> double(%)`. Now they can.
2024-05-23 16:06:30 -07:00
56d930c4f2 Handle export errors with empty scene or KCL errors (#2477)
* Update Cargo.lock

* Handle bad or empty scene export

Log error and send toast to user

* fmt
2024-05-23 16:03:34 -07:00
d48eb0c66c add plugin for saving scoped permissions will help with dialog behvior (#2497)
* build with persisted scope

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

* more scopes

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

---------

Signed-off-by: Jess Frazelle <github@jessfraz.com>
2024-05-23 14:23:14 -07:00
a69d7d03d0 Check the winnow ParseError before indexing (#2491)
* Check the winnow ParseError before indexing

From the winnow docs at[1]

> The location in ParseError::input where parsing failed
>
> Note: This is an offset, not an index, and may point to the end of input
> (input.len()) on eof errors.

This will bounds check the index before slicing into the `input` vec,
and return an EOF erorr rather than an unknown token error.

[1]: https://docs.rs/winnow/latest/winnow/error/struct.ParseError.html#method.offset

I have a hunch somewhere something is mixing up bytes and chars (more
specifically, a codepoint or grapheme), which is causing bounds to go
past the end of the list since something is talking byte indexes and
the other is dealing with char/codepoint indexes.

For now this is going to fix the crash, but the EOF error may be masking
an actual bad token error in some cases? Our code looks right, so I'm
not quite sure what is going on in the winnow internals here.

Signed-off-by: Paul R. Tagliamonte <paul@kittycad.io>
2024-05-23 16:27:54 -04:00
max
00a8273173 Add Client-Side Gizmo (#2354)
* draft #2279

Add client side gizmo #2279, work in progress

* draft #2279

unreliableSubscriptions

* draft #2279

nice Gizmo

* blue ring

give the canvas a round shape and a border, wrapping rounded div element around the canvas

* Refactor Gizmo Component

Extracted reusable constants
Modularized the code
Simplified the useEffect logic
Added TypeScript type annotations
Improved overall code structure and readability

* remove old gizmo

* fmt

* styling and relocation

 Add className "pointer-events-none" to gizmo wrapper div (for now to prevent context menu)
 Make LowerRightControls container element have these classNames: flex flex-col items-end gap-3
 Move gizmo into LowerRightControls.tsx as the first child of the section element
 Remove the fixed styling from the gizmo div so it flows in flexbox

* fmt

* fix camera up problem

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

* up tweak

* Revert "up tweak"

This reverts commit a53a0ef240.

* test tweak

* tweak test

---------

Co-authored-by: Kurt Hutten Irev-Dev <k.hutten@protonmail.ch>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Frank Noirot <frank@kittycad.io>
2024-05-24 06:02:25 +10:00
51868f892b Deduplicate executor code (#2494)
There are many places in the executor codebase which evaluate an AST expression and produce a KCL memory item. They could be deduplicated and put into one central location.

Fixes <https://github.com/KittyCAD/modeling-app/issues/1931>.
2024-05-23 14:50:22 -05:00
8e9286a747 Fix the fuzzer to work with the changed API (#2493)
Simplify the fuzzer a bit

This is the same code, but we'll offload the string parsing to the fuzz
lib, and we can inline the `if Ok(v)`

Signed-off-by: Paul R. Tagliamonte <paul@kittycad.io>
2024-05-23 09:12:40 -07:00
023ed1a687 Home page touch-ups (#2135)
* Save part images when navigating home

* Load part images in project cards if available

* Polish home page

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

* Merge branch 'main' into franknoirot/project-images

* Mostly restored link + form functionality

* Working cards with images

* Comment out project image stuff

* Little style tweaks

* Remove unused imports

* More minor styling tweaks

* Merge branch 'main' into franknoirot/project-images

* Was using the wrong imported `Project` type

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

* Revert any docs changes

* Revert wasm-lib divergences

* Move ProjectCard into its component folder

* Remove unused hook useSaveVideoFrame

* Remove "hideOnLevel" config from theme setting

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-05-23 11:47:02 -04:00
5b7d707b26 playwright test and framework for network stuff (#2480)
* playwright test and framework for network bullshit

 chrome protocol docs for params you can send

 https://chromedevtools.github.io/devtools-protocol/1-3/Network/#method-emulateNetworkConditions

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

* fmt

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

* skip on webkit

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

---------

Signed-off-by: Jess Frazelle <github@jessfraz.com>
2024-05-23 02:20:40 -07:00
5b95194aa7 fix test hotkey (#2490) 2024-05-23 15:44:19 +10:00
6080a99e73 Bump @react-hook/resize-observer from 1.2.6 to 2.0.1 (#2486)
Bumps [@react-hook/resize-observer](https://github.com/jaredLunde/react-hook) from 1.2.6 to 2.0.1.
- [Commits](https://github.com/jaredLunde/react-hook/commits)

---
updated-dependencies:
- dependency-name: "@react-hook/resize-observer"
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-05-23 05:22:24 +00:00
5106c49e21 bump kittycad (#2481) 2024-05-23 15:09:40 +10:00
25f18845c7 Update point-and-click sketch close code generation to use explicit lines (#2489)
* Modify sketch profile completion to use `profileStart` utilties

* Fix up playwright tests

* Rerun CI
2024-05-23 00:53:15 -04:00
0a7f1a41fc Bump vscode-jsonrpc from 8.2.0 to 8.2.1 (#2484)
Bumps [vscode-jsonrpc](https://github.com/Microsoft/vscode-languageserver-node/tree/HEAD/jsonrpc) from 8.2.0 to 8.2.1.
- [Release notes](https://github.com/Microsoft/vscode-languageserver-node/releases)
- [Commits](https://github.com/Microsoft/vscode-languageserver-node/commits/release/jsonrpc/8.2.1/jsonrpc)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-05-22 21:31:34 -07:00
1625b58577 Bump @tauri-apps/api from 2.0.0-beta.8 to 2.0.0-beta.12 (#2483)
Bumps [@tauri-apps/api](https://github.com/tauri-apps/tauri) from 2.0.0-beta.8 to 2.0.0-beta.12.
- [Release notes](https://github.com/tauri-apps/tauri/releases)
- [Commits](https://github.com/tauri-apps/tauri/compare/@tauri-apps/api-v2.0.0-beta.8...@tauri-apps/api-v2.0.0-beta.12)

---
updated-dependencies:
- dependency-name: "@tauri-apps/api"
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-05-22 21:31:21 -07:00
ab6115c4e2 Bump ws from 8.16.0 to 8.17.0 (#2482)
Bumps [ws](https://github.com/websockets/ws) from 8.16.0 to 8.17.0.
- [Release notes](https://github.com/websockets/ws/releases)
- [Commits](https://github.com/websockets/ws/compare/8.16.0...8.17.0)

---
updated-dependencies:
- dependency-name: ws
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-05-22 21:30:55 -07:00
55 changed files with 1731 additions and 1096 deletions

View File

@ -1,7 +1,7 @@
import { test, expect } from '@playwright/test'
import { makeTemplate, getUtils } from './test-utils'
import waitOn from 'wait-on'
import { roundOff } from 'lib/utils'
import { roundOff, uuidv4 } from 'lib/utils'
import { SaveSettingsPayload } from 'lib/settings/settingsTypes'
import { secrets } from './secrets'
import {
@ -14,6 +14,7 @@ import {
import * as TOML from '@iarna/toml'
import { Coords2d } from 'lang/std/sketch'
import { KCL_DEFAULT_LENGTH } from 'lib/constants'
import { EngineCommand } from 'lang/std/engineConnection'
/*
debug helper: unfortunately we do rely on exact coord mouse clicks in a few places
@ -59,7 +60,7 @@ test.beforeEach(async ({ context, page }) => {
test.setTimeout(60000)
test('Basic sketch', async ({ page }) => {
const u = getUtils(page)
const u = await getUtils(page)
await page.setViewportSize({ width: 1200, height: 500 })
const PUR = 400 / 37.5 //pixeltoUnitRatio
await page.goto('/')
@ -145,7 +146,7 @@ test('Basic sketch', async ({ page }) => {
test('Can moving camera', async ({ page, context }) => {
test.skip(process.platform === 'darwin', 'Can moving camera')
const u = getUtils(page)
const u = await getUtils(page)
await page.setViewportSize({ width: 1200, height: 500 })
await page.goto('/')
await u.waitForAuthSkipAppStart()
@ -165,7 +166,26 @@ test('Can moving camera', async ({ page, context }) => {
// We could break them out into separate tests, but the longest past of the test is waiting
// for the stream to start, so it can be good to bundle related things together.
await u.updateCamPosition(camPos)
const camCommand: EngineCommand = {
type: 'modeling_cmd_req',
cmd_id: uuidv4(),
cmd: {
type: 'default_camera_look_at',
center: { x: 0, y: 0, z: 0 },
vantage: { x: camPos[0], y: camPos[1], z: camPos[2] },
up: { x: 0, y: 0, z: 1 },
},
}
const updateCamCommand: EngineCommand = {
type: 'modeling_cmd_req',
cmd_id: uuidv4(),
cmd: {
type: 'default_camera_get_settings',
},
}
await u.sendCustomCmd(camCommand)
await page.waitForTimeout(100)
await u.sendCustomCmd(updateCamCommand)
await page.waitForTimeout(100)
// rotate
@ -225,9 +245,29 @@ test('Can moving camera', async ({ page, context }) => {
await page.mouse.move(700, 200, { steps: 2 })
await page.mouse.up({ button: 'right' })
await page.keyboard.up('Shift')
}, [-10, -85, -85])
}, [-19, -85, -85])
await u.updateCamPosition(camPos)
const camCommand: EngineCommand = {
type: 'modeling_cmd_req',
cmd_id: uuidv4(),
cmd: {
type: 'default_camera_look_at',
center: { x: 0, y: 0, z: 0 },
vantage: { x: camPos[0], y: camPos[1], z: camPos[2] },
up: { x: 0, y: 0, z: 1 },
},
}
const updateCamCommand: EngineCommand = {
type: 'modeling_cmd_req',
cmd_id: uuidv4(),
cmd: {
type: 'default_camera_get_settings',
},
}
await u.sendCustomCmd(camCommand)
await page.waitForTimeout(100)
await u.sendCustomCmd(updateCamCommand)
await page.waitForTimeout(100)
await u.clearCommandLogs()
await u.closeDebugPanel()
@ -263,13 +303,13 @@ test('Can moving camera', async ({ page, context }) => {
await bakeInRetries(async () => {
await page.mouse.move(700, 400)
await page.mouse.wheel(0, -100)
}, [1, -94, -94])
}, [1, -68, -68])
})
test('if you click the format button it formats your code', async ({
page,
}) => {
const u = getUtils(page)
const u = await getUtils(page)
await page.setViewportSize({ width: 1000, height: 500 })
await page.goto('/')
@ -300,7 +340,7 @@ test('if you click the format button it formats your code', async ({
test('if you use the format keyboard binding it formats your code', async ({
page,
}) => {
const u = getUtils(page)
const u = await getUtils(page)
await page.addInitScript(async () => {
localStorage.setItem(
'persistCode',
@ -358,7 +398,7 @@ test('ensure the Zoo logo is not a link in browser app', async ({ page }) => {
})
test('if you write invalid kcl you get inlined errors', async ({ page }) => {
const u = getUtils(page)
const u = await getUtils(page)
await page.setViewportSize({ width: 1000, height: 500 })
await page.goto('/')
@ -425,7 +465,7 @@ test('if you write invalid kcl you get inlined errors', async ({ page }) => {
})
test('error with 2 source ranges gets 2 diagnostics', async ({ page }) => {
const u = getUtils(page)
const u = await getUtils(page)
await page.addInitScript(async () => {
localStorage.setItem(
'persistCode',
@ -501,7 +541,7 @@ fn squareHole = (l, w) => {
test('if your kcl gets an error from the engine it is inlined', async ({
page,
}) => {
const u = getUtils(page)
const u = await getUtils(page)
await page.addInitScript(async () => {
localStorage.setItem(
'persistCode',
@ -549,7 +589,7 @@ angle: 90
})
test('executes on load', async ({ page }) => {
const u = getUtils(page)
const u = await getUtils(page)
await page.addInitScript(async () => {
localStorage.setItem(
'persistCode',
@ -585,7 +625,7 @@ test('executes on load', async ({ page }) => {
})
test('re-executes', async ({ page }) => {
const u = getUtils(page)
const u = await getUtils(page)
await page.addInitScript(async () => {
localStorage.setItem('persistCode', `const myVar = 5`)
})
@ -619,17 +659,31 @@ const sketchOnPlaneAndBackSideTest = async (
plane: string,
clickCoords: { x: number; y: number }
) => {
const u = getUtils(page)
const u = await getUtils(page)
const PUR = 400 / 37.5 //pixeltoUnitRatio
await page.setViewportSize({ width: 1200, height: 500 })
await page.goto('/')
await u.waitForAuthSkipAppStart()
await u.openDebugPanel()
const camCmdBackSide: [number, number, number] = [-100, -100, -100]
let camPos: [number, number, number] = [100, 100, 100]
if (plane === '-XY' || plane === '-YZ' || plane === 'XZ') {
camPos = camCmdBackSide
const coord =
plane === '-XY' || plane === '-YZ' || plane === 'XZ' ? -100 : 100
const camCommand: EngineCommand = {
type: 'modeling_cmd_req',
cmd_id: uuidv4(),
cmd: {
type: 'default_camera_look_at',
center: { x: 0, y: 0, z: 0 },
vantage: { x: coord, y: coord, z: coord },
up: { x: 0, y: 0, z: 1 },
},
}
const updateCamCommand: EngineCommand = {
type: 'modeling_cmd_req',
cmd_id: uuidv4(),
cmd: {
type: 'default_camera_get_settings',
},
}
const code = `const part001 = startSketchOn('${plane}')
@ -639,7 +693,10 @@ const sketchOnPlaneAndBackSideTest = async (
await u.clearCommandLogs()
await page.getByRole('button', { name: 'Start Sketch' }).click()
await u.updateCamPosition(camPos)
await u.sendCustomCmd(camCommand)
await page.waitForTimeout(100)
await u.sendCustomCmd(updateCamCommand)
await u.closeDebugPanel()
await page.mouse.click(clickCoords.x, clickCoords.y)
@ -696,7 +753,7 @@ test.describe('Can create sketches on all planes and their back sides', () => {
})
test('Auto complete works', async ({ page }) => {
const u = getUtils(page)
const u = await getUtils(page)
// const PUR = 400 / 37.5 //pixeltoUnitRatio
await page.setViewportSize({ width: 1200, height: 500 })
const lspStartPromise = page.waitForEvent('console', async (message) => {
@ -766,7 +823,7 @@ test('Auto complete works', async ({ page }) => {
test('Stored settings are validated and fall back to defaults', async ({
page,
}) => {
const u = getUtils(page)
const u = await getUtils(page)
// Override beforeEach test setup
// with corrupted settings
@ -974,7 +1031,7 @@ test('Project and user settings can be reset', async ({ page }) => {
})
test('Click through each onboarding step', async ({ page }) => {
const u = getUtils(page)
const u = await getUtils(page)
// Override beforeEach test setup
await page.addInitScript(
@ -1013,7 +1070,7 @@ test('Click through each onboarding step', async ({ page }) => {
})
test('Onboarding redirects and code updating', async ({ page }) => {
const u = getUtils(page)
const u = await getUtils(page)
// Override beforeEach test setup
await page.addInitScript(
@ -1060,7 +1117,7 @@ test('Selections work on fresh and edited sketch', async ({ page }) => {
// tests mapping works on fresh sketch and edited sketch
// tests using hovers which is the same as selections, because if
// source ranges are wrong, hovers won't work
const u = getUtils(page)
const u = await getUtils(page)
const PUR = 400 / 37.5 //pixeltoUnitRatio
await page.setViewportSize({ width: 1200, height: 500 })
await page.goto('/')
@ -1350,7 +1407,7 @@ test.describe('Command bar tests', () => {
)
})
const u = getUtils(page)
const u = await getUtils(page)
await page.setViewportSize({ width: 1200, height: 500 })
await page.goto('/')
await u.waitForAuthSkipAppStart()
@ -1423,7 +1480,7 @@ const part001 = startSketchOn('XZ')
test('Can add multiple sketches', async ({ page }) => {
test.skip(process.platform === 'darwin', 'Can add multiple sketches')
const u = getUtils(page)
const u = await getUtils(page)
await page.setViewportSize({ width: 1200, height: 500 })
const PUR = 400 / 37.5 //pixeltoUnitRatio
await page.goto('/')
@ -1566,7 +1623,7 @@ const part002 = startSketchOn('${plane}')
})
test('ProgramMemory can be serialised', async ({ page }) => {
const u = getUtils(page)
const u = await getUtils(page)
await page.addInitScript(async () => {
localStorage.setItem(
'persistCode',
@ -1605,7 +1662,7 @@ test('ProgramMemory can be serialised', async ({ page }) => {
})
test('Hovering over 3d features highlights code', async ({ page }) => {
const u = getUtils(page)
const u = await getUtils(page)
await page.addInitScript(async (KCL_DEFAULT_LENGTH) => {
localStorage.setItem(
'persistCode',
@ -1636,6 +1693,28 @@ test('Hovering over 3d features highlights code', async ({ page }) => {
await page.goto('/')
await u.waitForAuthSkipAppStart()
await page.waitForTimeout(100)
await u.openAndClearDebugPanel()
await u.sendCustomCmd({
type: 'modeling_cmd_req',
cmd_id: uuidv4(),
cmd: {
type: 'default_camera_look_at',
vantage: { x: 0, y: -1250, z: 580 },
center: { x: 0, y: 0, z: 0 },
up: { x: 0, y: 0, z: 1 },
},
})
await page.waitForTimeout(100)
await u.sendCustomCmd({
type: 'modeling_cmd_req',
cmd_id: uuidv4(),
cmd: {
type: 'default_camera_get_settings',
},
})
await page.waitForTimeout(100)
const extrusionTop: Coords2d = [800, 240]
const flatExtrusionFace: Coords2d = [960, 160]
const arc: Coords2d = [840, 160]
@ -1673,7 +1752,7 @@ test('Hovering over 3d features highlights code', async ({ page }) => {
test("Various pipe expressions should and shouldn't allow edit and or extrude", async ({
page,
}) => {
const u = getUtils(page)
const u = await getUtils(page)
const selectionsSnippets = {
extrudeAndEditBlocked: '|> startProfileAt([10.81, 32.99], %)',
extrudeAndEditBlockedInFunction: '|> startProfileAt(pos, %)',
@ -1770,7 +1849,9 @@ fn yohey = (pos) => {
// selecting an editable sketch but clicking "start sktech" should start a new sketch and not edit the existing one
await page.getByText(selectionsSnippets.extrudeAndEditAllowed).click()
await page.getByRole('button', { name: 'Start Sketch' }).click()
await page.mouse.click(700, 200)
await page.getByTestId('KCL Code').click()
await page.mouse.click(300, 500)
await page.getByTestId('KCL Code').click()
// expect main content to contain `part005` i.e. started a new sketch
await expect(page.locator('.cm-content')).toHaveText(
/part005 = startSketchOn\('XZ'\)/
@ -1780,7 +1861,7 @@ fn yohey = (pos) => {
test('Deselecting line tool should mean nothing happens on click', async ({
page,
}) => {
const u = getUtils(page)
const u = await getUtils(page)
await page.setViewportSize({ width: 1200, height: 500 })
await page.goto('/')
await u.waitForAuthSkipAppStart()
@ -1846,7 +1927,7 @@ test('multi-sketch file shows multiple Edit Sketch buttons', async ({
page,
context,
}) => {
const u = getUtils(page)
const u = await getUtils(page)
const selectionsSnippets = {
startProfileAt1:
'|> startProfileAt([-width / 4 + screwRadius, height / 2], %)',
@ -1925,7 +2006,7 @@ const part002 = startSketchOn('-XZ')
})
test('Can edit segments by dragging their handles', async ({ page }) => {
const u = getUtils(page)
const u = await getUtils(page)
await page.addInitScript(async () => {
localStorage.setItem(
'persistCode',
@ -1943,6 +2024,28 @@ test('Can edit segments by dragging their handles', async ({ page }) => {
page.getByRole('button', { name: 'Start Sketch' })
).not.toBeDisabled()
await page.waitForTimeout(100)
await u.openAndClearDebugPanel()
await u.sendCustomCmd({
type: 'modeling_cmd_req',
cmd_id: uuidv4(),
cmd: {
type: 'default_camera_look_at',
vantage: { x: 0, y: -1250, z: 580 },
center: { x: 0, y: 0, z: 0 },
up: { x: 0, y: 0, z: 1 },
},
})
await page.waitForTimeout(100)
await u.sendCustomCmd({
type: 'modeling_cmd_req',
cmd_id: uuidv4(),
cmd: {
type: 'default_camera_get_settings',
},
})
await page.waitForTimeout(100)
const startPX = [665, 458]
const lineEndPX = [842, 458]
const arcEndPX = [971, 342]
@ -2001,7 +2104,7 @@ const doSnapAtDifferentScales = async (
scale = 1,
fudge = 0
) => {
const u = getUtils(page)
const u = await getUtils(page)
await page.setViewportSize({ width: 1200, height: 500 })
await page.goto('/')
await u.waitForAuthSkipAppStart()
@ -2011,6 +2114,7 @@ const doSnapAtDifferentScales = async (
|> startProfileAt([${roundOff(scale * 87.68)}, ${roundOff(scale * 43.84)}], %)
|> line([${roundOff(scale * 175.36)}, 0], %)
|> line([0, -${roundOff(scale * 175.36) + fudge}], %)
|> lineTo([profileStartX(%), profileStartY(%)], %)
|> close(%)`
await expect(
@ -2063,6 +2167,11 @@ const doSnapAtDifferentScales = async (
prevContent = await page.locator('.cm-content').innerText()
await expect(page.locator('.cm-content')).toHaveText(code)
// Assert the tool was unequipped
await expect(page.getByRole('button', { name: 'Line' })).not.toHaveAttribute(
'aria-pressed',
'true'
)
// exit sketch
await u.openAndClearDebugPanel()
@ -2082,7 +2191,7 @@ test.describe('Snap to close works (at any scale)', () => {
})
test('Sketch on face', async ({ page }) => {
const u = getUtils(page)
const u = await getUtils(page)
await page.addInitScript(async () => {
localStorage.setItem(
'persistCode',
@ -2117,7 +2226,7 @@ test('Sketch on face', async ({ page }) => {
await u.openAndClearDebugPanel()
await u.doAndWaitForCmd(
() => page.mouse.click(793, 133),
() => page.mouse.click(625, 133),
'default_camera_get_settings',
true
)
@ -2148,9 +2257,10 @@ test('Sketch on face', async ({ page }) => {
await expect(page.locator('.cm-content'))
.toContainText(`const part002 = startSketchOn(part001, 'seg01')
|> startProfileAt([-12.83, 6.7], %)
|> line([2.87, -0.23], %)
|> line([-3.05, -1.47], %)
|> startProfileAt([-13.02, 6.52], %)
|> line([2.1, -0.18], %)
|> line([-2.23, -1.07], %)
|> lineTo([profileStartX(%), profileStartY(%)], %)
|> close(%)`)
await u.openAndClearDebugPanel()
@ -2160,7 +2270,7 @@ test('Sketch on face', async ({ page }) => {
await u.updateCamPosition([1049, 239, 686])
await u.closeDebugPanel()
await page.getByText('startProfileAt([-12.83, 6.7], %)').click()
await page.getByText('startProfileAt([-13.02, 6.52], %)').click()
await expect(page.getByRole('button', { name: 'Edit Sketch' })).toBeVisible()
await u.doAndWaitForCmd(
() => page.getByRole('button', { name: 'Edit Sketch' }).click(),
@ -2189,6 +2299,7 @@ test('Sketch on face', async ({ page }) => {
|> startProfileAt([-12.83, 6.7], %)
|> line([${[2.28, 2.35]}, -${0.07}], %)
|> line([-3.05, -1.47], %)
|> lineTo([profileStartX(%), profileStartY(%)], %)
|> close(%)`
await expect(page.locator('.cm-content')).toHaveText(result.regExp)
@ -2198,15 +2309,17 @@ test('Sketch on face', async ({ page }) => {
await page.getByRole('button', { name: 'Exit Sketch' }).click()
await u.expectCmdLog('[data-message-type="execution-done"]')
await page.getByText('startProfileAt([-12.83, 6.7], %)').click()
await page.getByText('startProfileAt([-13.02, 6.52], %)').click()
await expect(page.getByRole('button', { name: 'Extrude' })).not.toBeDisabled()
await page.waitForTimeout(100)
await page.getByRole('button', { name: 'Extrude' }).click()
await expect(page.getByTestId('command-bar')).toBeVisible()
await page.waitForTimeout(100)
await page.keyboard.press('Enter')
await page.waitForTimeout(100)
await expect(page.getByText('Confirm Extrude')).toBeVisible()
await page.keyboard.press('Enter')
@ -2228,7 +2341,7 @@ test('Can code mod a line length', async ({ page }) => {
)
})
const u = getUtils(page)
const u = await getUtils(page)
const PUR = 400 / 37.5 //pixeltoUnitRatio
await page.setViewportSize({ width: 1200, height: 500 })
await page.goto('/')
@ -2237,24 +2350,39 @@ test('Can code mod a line length', async ({ page }) => {
await u.expectCmdLog('[data-message-type="execution-done"]')
await u.closeDebugPanel()
// Click the line of code for xLine.
await page.getByText(`xLine(-20, %)`).click() // TODO remove this and reinstate // await topHorzSegmentClick()
// Click the line of code for line.
await page.getByText(`line([0, 20], %)`).click() // TODO remove this and reinstate // await topHorzSegmentClick()
await page.waitForTimeout(100)
// enter sketch again
await page.getByRole('button', { name: 'Edit Sketch' }).click()
await page.waitForTimeout(350) // wait for animation
await page.waitForTimeout(500) // wait for animation
const startXPx = 500
await page.mouse.move(startXPx + PUR * 15, 250 - PUR * 10)
await page.mouse.click(615, 102)
await page.keyboard.down('Shift')
await page.mouse.click(834, 244)
await page.keyboard.up('Shift')
await page.getByRole('button', { name: 'Constrain', exact: true }).click()
await page.getByRole('button', { name: 'length', exact: true }).click()
await page.getByText('Add constraining value').click()
await expect(page.locator('.cm-content')).toHaveText(
`const length001 = 20const part001 = startSketchOn('XY') |> startProfileAt([-10, -10], %) |> line([20, 0], %) |> line([0, 20], %) |> xLine(-length001, %)`
`const length001 = 20const part001 = startSketchOn('XY') |> startProfileAt([-10, -10], %) |> line([20, 0], %) |> angledLine([90, length001], %) |> xLine(-20, %)`
)
// Make sure we didn't pop out of sketch mode.
await expect(page.getByRole('button', { name: 'Exit Sketch' })).toBeVisible()
await page.waitForTimeout(500) // wait for animation
// Exit sketch
await page.mouse.move(startXPx + PUR * 15, 250 - PUR * 10)
await page.keyboard.press('Escape')
await expect(
page.getByRole('button', { name: 'Exit Sketch' })
).not.toBeVisible()
})
test('Extrude from command bar selects extrude line after', async ({
@ -2273,7 +2401,7 @@ test('Extrude from command bar selects extrude line after', async ({
)
})
const u = getUtils(page)
const u = await getUtils(page)
await page.setViewportSize({ width: 1200, height: 500 })
await page.goto('/')
await u.waitForAuthSkipAppStart()
@ -2296,6 +2424,68 @@ test('Extrude from command bar selects extrude line after', async ({
)
})
test('First escape in tool pops you out of tool, second exits sketch mode', async ({
page,
}) => {
// Wait for the app to be ready for use
const u = await getUtils(page)
await page.setViewportSize({ width: 1200, height: 500 })
await page.goto('/')
await u.waitForAuthSkipAppStart()
await u.openDebugPanel()
await u.expectCmdLog('[data-message-type="execution-done"]')
await u.closeDebugPanel()
const lineButton = page.getByRole('button', { name: 'Line' })
const arcButton = page.getByRole('button', { name: 'Tangential Arc' })
// Test these hotkeys perform actions when
// focus is on the canvas
await page.mouse.move(600, 250)
await page.mouse.click(600, 250)
// Start a sketch
await page.keyboard.press('s')
await page.mouse.move(800, 300)
await page.mouse.click(800, 300)
await page.waitForTimeout(1000)
await expect(lineButton).toHaveAttribute('aria-pressed', 'true')
// Draw a line
await page.mouse.move(700, 200, { steps: 5 })
await page.mouse.click(700, 200)
await page.mouse.move(800, 250, { steps: 5 })
await page.mouse.click(800, 250)
// 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(lineButton).not.toHaveAttribute('aria-pressed', 'true')
// Equip arc tool
await page.keyboard.press('a')
await expect(arcButton).toHaveAttribute('aria-pressed', 'true')
await page.mouse.move(1000, 100, { steps: 5 })
await page.mouse.click(1000, 100)
await page.keyboard.press('Escape')
await page.keyboard.press('l')
await expect(lineButton).toHaveAttribute('aria-pressed', 'true')
// Do not close the sketch.
// On close it will exit sketch mode.
// Unequip line tool
await page.keyboard.press('Escape')
await expect(lineButton).toHaveAttribute('aria-pressed', 'false')
await expect(arcButton).toHaveAttribute('aria-pressed', 'false')
// Make sure we didn't pop out of sketch mode.
await expect(page.getByRole('button', { name: 'Exit Sketch' })).toBeVisible()
// Exit sketch
await page.keyboard.press('Escape')
await expect(
page.getByRole('button', { name: 'Exit Sketch' })
).not.toBeVisible()
})
test('Basic default modeling and sketch hotkeys work', async ({ page }) => {
// This test can run long if it takes a little too long to load
// the engine.
@ -2319,7 +2509,7 @@ test('Basic default modeling and sketch hotkeys work', async ({ page }) => {
})
// Wait for the app to be ready for use
const u = getUtils(page)
const u = await getUtils(page)
await page.setViewportSize({ width: 1200, height: 500 })
await page.goto('/')
await u.waitForAuthSkipAppStart()
@ -2379,17 +2569,20 @@ test('Basic default modeling and sketch hotkeys work', async ({ page }) => {
// Close profile
await page.mouse.move(700, 200, { steps: 5 })
await page.mouse.click(700, 200)
// Unequip line tool
await page.keyboard.press('Escape')
// On close it will unequip the line tool.
await expect(lineButton).toHaveAttribute('aria-pressed', 'false')
// Exit sketch
await page.keyboard.press('Escape')
await expect(
page.getByRole('button', { name: 'Exit Sketch' })
).not.toBeVisible()
// Extrude
await page.mouse.click(750, 150)
await expect(extrudeButton).not.toBeDisabled()
await page.keyboard.press('e')
await page.mouse.move(850, 180, { steps: 5 })
await page.mouse.click(850, 180)
await page.mouse.move(730, 230, { steps: 5 })
await page.mouse.click(730, 230)
await page.waitForTimeout(100)
await page.getByRole('button', { name: 'Continue' }).click()
await page.getByRole('button', { name: 'Submit command' }).click()
@ -2397,3 +2590,64 @@ test('Basic default modeling and sketch hotkeys work', async ({ page }) => {
await codePaneButton.click()
await expect(page.locator('.cm-content')).toContainText('extrude(')
})
test('simulate network down and network little widget', async ({ page }) => {
const u = await getUtils(page)
await page.setViewportSize({ width: 1200, height: 500 })
await page.goto('/')
await u.waitForAuthSkipAppStart()
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()
// Expect the network to be up
await expect(page.getByText('Network Health (Connected)')).toBeVisible()
// Click the network widget
await networkWidget.click()
// Check the modal opened.
await expect(networkPopover).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,
})
// Expect the network to be down
await expect(page.getByText('Network Health (Offline)')).toBeVisible()
// Click the network widget
await networkWidget.click()
// Check the modal opened.
await expect(networkPopover).toBeVisible()
// Click off the modal.
await page.mouse.click(100, 100)
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,
})
// Expect the network to be up
await expect(page.getByText('Network Health (Connected)')).toBeVisible()
})

View File

@ -44,7 +44,7 @@ test.setTimeout(60_000)
test('exports of each format should work', async ({ page, context }) => {
// FYI this test doesn't work with only engine running locally
// And you will need to have the KittyCAD CLI installed
const u = getUtils(page)
const u = await getUtils(page)
await context.addInitScript(async () => {
;(window as any).playwrightSkipFilePicker = true
localStorage.setItem(
@ -369,7 +369,7 @@ const extrudeDefaultPlane = async (context: any, page: any, plane: string) => {
localStorage.setItem('persistCode', code)
})
const u = getUtils(page)
const u = await getUtils(page)
await page.setViewportSize({ width: 1200, height: 500 })
await page.goto('/')
await u.waitForAuthSkipAppStart()
@ -424,7 +424,7 @@ test.describe('extrude on default planes should be stable', () => {
})
test('Draft segments should look right', async ({ page, context }) => {
const u = getUtils(page)
const u = await getUtils(page)
await page.setViewportSize({ width: 1200, height: 500 })
const PUR = 400 / 37.5 //pixeltoUnitRatio
await page.goto('/')
@ -483,7 +483,7 @@ test('Draft segments should look right', async ({ page, context }) => {
})
test('Draft rectangles should look right', async ({ page, context }) => {
const u = getUtils(page)
const u = await getUtils(page)
await page.setViewportSize({ width: 1200, height: 500 })
const PUR = 400 / 37.5 //pixeltoUnitRatio
await page.goto('/')
@ -530,7 +530,7 @@ test('Draft rectangles should look right', async ({ page, context }) => {
test.describe('Client side scene scale should match engine scale', () => {
test('Inch scale', async ({ page }) => {
const u = getUtils(page)
const u = await getUtils(page)
await page.setViewportSize({ width: 1200, height: 500 })
const PUR = 400 / 37.5 //pixeltoUnitRatio
await page.goto('/')
@ -633,7 +633,7 @@ test.describe('Client side scene scale should match engine scale', () => {
}),
}
)
const u = getUtils(page)
const u = await getUtils(page)
await page.setViewportSize({ width: 1200, height: 500 })
const PUR = 400 / 37.5 //pixeltoUnitRatio
await page.goto('/')
@ -719,7 +719,7 @@ test.describe('Client side scene scale should match engine scale', () => {
})
test('Sketch on face with none z-up', async ({ page, context }) => {
const u = getUtils(page)
const u = await getUtils(page)
await context.addInitScript(async (KCL_DEFAULT_LENGTH) => {
localStorage.setItem(
'persistCode',
@ -773,3 +773,76 @@ const part002 = startSketchOn(part001, 'seg01')
maxDiffPixels: 100,
})
})
test('Zoom to fit on load - solid 2d', async ({ page, context }) => {
const u = await getUtils(page)
await context.addInitScript(async () => {
localStorage.setItem(
'persistCode',
`const part001 = startSketchOn('XY')
|> startProfileAt([-10, -10], %)
|> line([20, 0], %)
|> line([0, 20], %)
|> line([-20, 0], %)
|> close(%)
`
)
}, KCL_DEFAULT_LENGTH)
await page.setViewportSize({ width: 1200, height: 500 })
await page.goto('/')
await u.waitForAuthSkipAppStart()
await u.openDebugPanel()
// wait for execution done
await expect(
page.locator('[data-message-type="execution-done"]')
).toHaveCount(2)
await u.closeDebugPanel()
// Wait for the second extrusion to appear
// TODO: Find a way to truly know that the objects have finished
// rendering, because an execution-done message is not sufficient.
await page.waitForTimeout(1000)
await expect(page).toHaveScreenshot({
maxDiffPixels: 100,
})
})
test('Zoom to fit on load - solid 3d', async ({ page, context }) => {
const u = await getUtils(page)
await context.addInitScript(async () => {
localStorage.setItem(
'persistCode',
`const part001 = startSketchOn('XY')
|> startProfileAt([-10, -10], %)
|> line([20, 0], %)
|> line([0, 20], %)
|> line([-20, 0], %)
|> close(%)
|> extrude(10, %)
`
)
}, KCL_DEFAULT_LENGTH)
await page.setViewportSize({ width: 1200, height: 500 })
await page.goto('/')
await u.waitForAuthSkipAppStart()
await u.openDebugPanel()
// wait for execution done
await expect(
page.locator('[data-message-type="execution-done"]')
).toHaveCount(2)
await u.closeDebugPanel()
// Wait for the second extrusion to appear
// TODO: Find a way to truly know that the objects have finished
// rendering, because an execution-done message is not sufficient.
await page.waitForTimeout(1000)
await expect(page).toHaveScreenshot({
maxDiffPixels: 100,
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 42 KiB

View File

@ -1,8 +1,9 @@
import { expect, Page } from '@playwright/test'
import { test, expect, Page } from '@playwright/test'
import { EngineCommand } from '../../src/lang/std/engineConnection'
import fsp from 'fs/promises'
import pixelMatch from 'pixelmatch'
import { PNG } from 'pngjs'
import { Protocol } from 'playwright-core/types/protocol'
async function waitForPageLoad(page: Page) {
// wait for 'Loading stream...' spinner
@ -93,7 +94,12 @@ async function waitForCmdReceive(page: Page, commandType: string) {
.waitFor()
}
export function getUtils(page: Page) {
export async function getUtils(page: Page) {
const cdpSession =
process.platform === 'darwin'
? null
: await page.context().newCDPSession(page)
return {
waitForAuthSkipAppStart: () => waitForPageLoad(page),
removeCurrentCode: () => removeCurrentCode(page),
@ -180,6 +186,17 @@ export function getUtils(page: Page) {
}
}, 50)
}),
emulateNetworkConditions: async (
networkOptions: Protocol.Network.emulateNetworkConditionsParameters
) => {
// Skip on non-Chromium browsers, since we need to use the CDP.
test.skip(
cdpSession === null,
'Network emulation is only supported in Chromium'
)
cdpSession?.send('Network.emulateNetworkConditions', networkOptions)
},
}
}

View File

@ -1,6 +1,6 @@
{
"name": "untitled-app",
"version": "0.21.7",
"version": "0.21.8",
"private": true,
"dependencies": {
"@codemirror/autocomplete": "^6.16.0",
@ -10,12 +10,12 @@
"@fortawesome/react-fontawesome": "^0.2.0",
"@headlessui/react": "^1.7.19",
"@headlessui/tailwindcss": "^0.2.0",
"@kittycad/lib": "^0.0.60",
"@kittycad/lib": "^0.0.63",
"@lezer/javascript": "^1.4.9",
"@open-rpc/client-js": "^1.8.1",
"@react-hook/resize-observer": "^1.2.6",
"@react-hook/resize-observer": "^2.0.1",
"@replit/codemirror-interact": "^6.3.1",
"@tauri-apps/api": "2.0.0-beta.8",
"@tauri-apps/api": "2.0.0-beta.12",
"@tauri-apps/plugin-dialog": "^2.0.0-beta.2",
"@tauri-apps/plugin-fs": "^2.0.0-beta.3",
"@tauri-apps/plugin-http": "^2.0.0-beta.2",
@ -61,11 +61,11 @@
"ua-parser-js": "^1.0.37",
"uuid": "^9.0.1",
"vitest": "^1.6.0",
"vscode-jsonrpc": "^8.1.0",
"vscode-jsonrpc": "^8.2.1",
"vscode-languageserver-protocol": "^3.17.5",
"wasm-pack": "^0.12.1",
"web-vitals": "^3.5.2",
"ws": "^8.16.0",
"ws": "^8.17.0",
"xstate": "^4.38.2",
"zustand": "^4.5.2"
},

21
src-tauri/Cargo.lock generated
View File

@ -195,6 +195,7 @@ dependencies = [
"tauri-plugin-http",
"tauri-plugin-log",
"tauri-plugin-os",
"tauri-plugin-persisted-scope",
"tauri-plugin-process",
"tauri-plugin-shell",
"tauri-plugin-updater",
@ -5370,9 +5371,9 @@ dependencies = [
[[package]]
name = "tauri-plugin-fs"
version = "2.0.0-beta.6"
version = "2.0.0-beta.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "609f53d90f08808679ecdd81455d9a4d0053291b92780695569f7400fdba27d5"
checksum = "35377195c6923beda5f29482a16b492d431de964389fca9aaf81a0f7e908023f"
dependencies = [
"anyhow",
"glob",
@ -5447,6 +5448,22 @@ dependencies = [
"thiserror",
]
[[package]]
name = "tauri-plugin-persisted-scope"
version = "2.0.0-beta.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b600c870217ec082b0f3482935a8edad347d18e8cd7d422a74bc92d035c0e394"
dependencies = [
"aho-corasick",
"bincode",
"log",
"serde",
"serde_json",
"tauri",
"tauri-plugin-fs",
"thiserror",
]
[[package]]
name = "tauri-plugin-process"
version = "2.0.0-beta.3"

View File

@ -28,6 +28,7 @@ tauri-plugin-fs = { version = "2.0.0-beta.6" }
tauri-plugin-http = { version = "2.0.0-beta.6" }
tauri-plugin-log = { version = "2.0.0-beta.4" }
tauri-plugin-os = { version = "2.0.0-beta.2" }
tauri-plugin-persisted-scope = { version = "2.0.0-beta.7" }
tauri-plugin-process = { version = "2.0.0-beta.2" }
tauri-plugin-shell = { version = "2.0.0-beta.2" }
tauri-plugin-updater = { version = "2.0.0-beta.4" }

View File

@ -32,6 +32,15 @@
{
"identifier": "fs:scope",
"allow": [
{
"path": "$TEMP"
},
{
"path": "$TEMP/**/*"
},
{
"path": "$HOME"
},
{
"path": "$HOME/**/*"
},

View File

@ -425,6 +425,7 @@ fn main() -> Result<()> {
.build(),
)
.plugin(tauri_plugin_os::init())
.plugin(tauri_plugin_persisted_scope::init())
.plugin(tauri_plugin_process::init())
.plugin(tauri_plugin_shell::init())
.setup(|app| {

View File

@ -74,5 +74,5 @@
}
},
"productName": "Zoo Modeling App",
"version": "0.21.7"
"version": "0.21.8"
}

View File

@ -20,6 +20,7 @@ import {
EngineCommand,
Subscription,
EngineCommandManager,
UnreliableSubscription,
} from 'lang/std/engineConnection'
import { uuidv4 } from 'lib/utils'
import { deg2Rad } from 'lib/utils2d'
@ -232,9 +233,19 @@ export class CameraControls {
this.update()
this._usePerspectiveCamera()
const cb: Subscription<
'default_camera_zoom' | 'camera_drag_end' | 'default_camera_get_settings'
>['callback'] = ({ data, type }) => {
type CallBackParam = Parameters<
(
| Subscription<
| 'default_camera_zoom'
| 'camera_drag_end'
| 'default_camera_get_settings'
| 'zoom_to_fit'
>
| UnreliableSubscription<'camera_drag_move'>
)['callback']
>[0]
const cb = ({ data, type }: CallBackParam) => {
const camSettings = data.settings
this.camera.position.set(
camSettings.pos.x,
@ -246,7 +257,13 @@ export class CameraControls {
camSettings.center.y,
camSettings.center.z
)
this.camera.up.set(camSettings.up.x, camSettings.up.y, camSettings.up.z)
const quat = new Quaternion(
camSettings.orientation.x,
camSettings.orientation.y,
camSettings.orientation.z,
camSettings.orientation.w
).invert()
this.camera.up.copy(new Vector3(0, 1, 0).applyQuaternion(quat))
if (this.camera instanceof PerspectiveCamera && camSettings.ortho) {
this.useOrthographicCamera()
}
@ -287,6 +304,14 @@ export class CameraControls {
event: 'default_camera_get_settings',
callback: cb,
})
this.engineCommandManager.subscribeTo({
event: 'zoom_to_fit',
callback: cb,
})
this.engineCommandManager.subscribeToUnreliable({
event: 'camera_drag_move',
callback: cb,
})
})
}

View File

@ -69,6 +69,7 @@ import {
tangentialArcToSegment,
} from './segments'
import {
addCallExpressionsToPipe,
addCloseToPipe,
addNewSketchLn,
changeSketchArguments,
@ -536,10 +537,34 @@ export class SceneEntities {
let modifiedAst
if (profileStart) {
modifiedAst = addCloseToPipe({
const lastSegment = sketchGroup.value.slice(-1)[0]
modifiedAst = addCallExpressionsToPipe({
node: kclManager.ast,
programMemory: kclManager.programMemory,
pathToNode: sketchPathToNode,
expressions: [
createCallExpressionStdLib(
lastSegment.type === 'TangentialArcTo'
? 'tangentialArcTo'
: 'lineTo',
[
createArrayExpression([
createCallExpressionStdLib('profileStartX', [
createPipeSubstitution(),
]),
createCallExpressionStdLib('profileStartY', [
createPipeSubstitution(),
]),
]),
createPipeSubstitution(),
]
),
],
})
modifiedAst = addCloseToPipe({
node: modifiedAst,
programMemory: kclManager.programMemory,
pathToNode: sketchPathToNode,
})
} else if (intersection2d) {
const lastSegment = sketchGroup.value.slice(-1)[0]
@ -560,13 +585,17 @@ export class SceneEntities {
}
await kclManager.executeAstMock(modifiedAst)
this.setUpDraftSegment(
sketchPathToNode,
forward,
up,
origin,
segmentName
)
if (profileStart) {
sceneInfra.modelingSend({ type: 'CancelSketch' })
} else {
this.setUpDraftSegment(
sketchPathToNode,
forward,
up,
origin,
segmentName
)
}
},
onMove: (args) => {
this.onDragSegment({

View File

@ -28,7 +28,6 @@ import { Axis } from 'lib/selections'
import { type BaseUnit } from 'lib/settings/settingsTypes'
import { CameraControls } from './CameraControls'
import { EngineCommandManager } from 'lang/std/engineConnection'
import { settings } from 'lib/settings/initialSettings'
import { MouseState } from 'machines/modelingMachine'
import { Themes } from 'lib/theme'
@ -182,15 +181,6 @@ export class SceneInfra {
this.renderer.setClearColor(0x000000, 0) // Set clear color to black with 0 alpha (fully transparent)
window.addEventListener('resize', this.onWindowResize)
// CAMERA
const camHeightDistanceRatio = 0.5
const baseUnit: BaseUnit = settings.modeling.defaultUnit.current
const baseRadius = 5.6
const length = baseUnitTomm(baseUnit) * baseRadius
const ang = Math.atan(camHeightDistanceRatio)
const x = Math.cos(ang) * length
const y = Math.sin(ang) * length
this.camControls = new CameraControls(
false,
this.renderer.domElement,
@ -198,7 +188,6 @@ export class SceneInfra {
)
this.camControls.subscribeToCamChange(() => this.onCameraChange())
this.camControls.camera.layers.enable(SKETCH_LAYER)
this.camControls.camera.position.set(0, -x, y)
if (DEBUG_SHOW_INTERSECTION_PLANE)
this.camControls.camera.layers.enable(INTERSECTION_PLANE_LAYER)

View File

@ -395,6 +395,14 @@ const CustomIconMap = {
/>
</svg>
),
trash: (
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M8.5 6H5V8H6M8.5 6V4H11.5V6M8.5 6H11.5M11.5 6H15V8H14M6 8V15.5H8M6 8H14M14 8V15.5H12M8 15.5V10M8 15.5H10M12 15.5V10M12 15.5H10M10 15.5V12"
stroke="currentColor"
/>
</svg>
),
vertical: (
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path

146
src/components/Gizmo.tsx Normal file
View File

@ -0,0 +1,146 @@
import { sceneInfra } from 'lib/singletons'
import { useEffect, useRef } from 'react'
import {
WebGLRenderer,
Scene,
OrthographicCamera,
BoxGeometry,
SphereGeometry,
MeshBasicMaterial,
Color,
Mesh,
Clock,
Quaternion,
ColorRepresentation,
} from 'three'
const CANVAS_SIZE = 80
const FRUSTUM_SIZE = 0.5
const AXIS_LENGTH = 0.35
const AXIS_WIDTH = 0.02
const AXIS_COLORS = {
x: '#fa6668',
y: '#11eb6b',
z: '#6689ef',
gray: '#c6c7c2',
}
export default function Gizmo() {
const canvasRef = useRef<HTMLCanvasElement | null>(null)
useEffect(() => {
if (!canvasRef.current) return
const canvas = canvasRef.current
const renderer = new WebGLRenderer({ canvas, antialias: true, alpha: true })
renderer.setSize(CANVAS_SIZE, CANVAS_SIZE)
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
const scene = new Scene()
const camera = createCamera()
const { gizmoAxes, gizmoAxisHeads } = createGizmo()
scene.add(...gizmoAxes, ...gizmoAxisHeads)
const clock = new Clock()
const clientCamera = sceneInfra.camControls.camera
let currentQuaternion = new Quaternion().copy(clientCamera.quaternion)
const animate = () => {
requestAnimationFrame(animate)
updateCameraOrientation(
camera,
currentQuaternion,
sceneInfra.camControls.camera.quaternion,
clock.getDelta()
)
renderer.render(scene, camera)
}
animate()
return () => {
renderer.dispose()
}
}, [])
return (
<div className="grid place-content-center rounded-full overflow-hidden border border-solid border-primary/50 pointer-events-none">
<canvas ref={canvasRef} />
</div>
)
}
const createCamera = () => {
return new OrthographicCamera(
-FRUSTUM_SIZE,
FRUSTUM_SIZE,
FRUSTUM_SIZE,
-FRUSTUM_SIZE,
0.5,
3
)
}
const createGizmo = () => {
const gizmoAxes = [
createAxis(AXIS_LENGTH, AXIS_WIDTH, AXIS_COLORS.x, 0, 'z'),
createAxis(AXIS_LENGTH, AXIS_WIDTH, AXIS_COLORS.y, Math.PI / 2, 'z'),
createAxis(AXIS_LENGTH, AXIS_WIDTH, AXIS_COLORS.z, -Math.PI / 2, 'y'),
createAxis(AXIS_LENGTH, AXIS_WIDTH, AXIS_COLORS.gray, Math.PI, 'z'),
createAxis(AXIS_LENGTH, AXIS_WIDTH, AXIS_COLORS.gray, -Math.PI / 2, 'z'),
createAxis(AXIS_LENGTH, AXIS_WIDTH, AXIS_COLORS.gray, Math.PI / 2, 'y'),
]
const gizmoAxisHeads = [
createAxisHead(AXIS_LENGTH, AXIS_COLORS.x, 0, 'z'),
createAxisHead(AXIS_LENGTH, AXIS_COLORS.y, Math.PI / 2, 'z'),
createAxisHead(AXIS_LENGTH, AXIS_COLORS.z, -Math.PI / 2, 'y'),
createAxisHead(AXIS_LENGTH, AXIS_COLORS.gray, Math.PI, 'z'),
createAxisHead(AXIS_LENGTH, AXIS_COLORS.gray, -Math.PI / 2, 'z'),
createAxisHead(AXIS_LENGTH, AXIS_COLORS.gray, Math.PI / 2, 'y'),
]
return { gizmoAxes, gizmoAxisHeads }
}
const createAxis = (
length: number,
width: number,
color: ColorRepresentation,
rotation = 0,
axis = 'x'
) => {
const geometry = new BoxGeometry(length, width, width).translate(
length / 2,
0,
0
)
const material = new MeshBasicMaterial({ color: new Color(color) })
const mesh = new Mesh(geometry, material)
mesh.rotation[axis as 'x' | 'y' | 'z'] = rotation
return mesh
}
const createAxisHead = (
length: number,
color: ColorRepresentation,
rotation = 0,
axis = 'x'
) => {
const geometry = new SphereGeometry(0.065, 16, 8).translate(length, 0, 0)
const material = new MeshBasicMaterial({ color: new Color(color) })
const mesh = new Mesh(geometry, material)
mesh.rotation[axis as 'x' | 'y' | 'z'] = rotation
return mesh
}
const updateCameraOrientation = (
camera: OrthographicCamera,
currentQuaternion: Quaternion,
targetQuaternion: Quaternion,
deltaTime: number
) => {
const slerpFactor = 1 - Math.exp(-30 * deltaTime)
currentQuaternion.slerp(targetQuaternion, slerpFactor).normalize()
camera.position.set(0, 0, 1).applyQuaternion(currentQuaternion)
camera.quaternion.copy(currentQuaternion)
}

View File

@ -1,6 +1,7 @@
import { APP_VERSION } from 'routes/Settings'
import { CustomIcon } from 'components/CustomIcon'
import Tooltip from 'components/Tooltip'
import Gizmo from 'components/Gizmo'
import { paths } from 'lib/paths'
import { NetworkHealthIndicator } from 'components/NetworkHealthIndicator'
import { HelpMenu } from './HelpMenu'
@ -14,8 +15,9 @@ export function LowerRightControls(props: React.PropsWithChildren) {
'!text-chalkboard-70 hover:!text-chalkboard-80 dark:!text-chalkboard-40 dark:hover:!text-chalkboard-30'
return (
<section className="fixed bottom-2 right-2">
<section className="fixed bottom-2 right-2 flex flex-col items-end gap-3">
{props.children}
<Gizmo />
<menu className="flex items-center justify-end gap-3">
<a
href={`https://github.com/KittyCAD/modeling-app/releases/tag/v${APP_VERSION}`}

View File

@ -300,8 +300,23 @@ export const ModelingMachineProvider = ({
selectionRanges
)
},
'Has exportable geometry': () =>
kclManager.kclErrors.length === 0 && kclManager.ast.body.length > 0,
'Has exportable geometry': () => {
if (
kclManager.kclErrors.length === 0 &&
kclManager.ast.body.length > 0
)
return true
else {
let errorMessage = 'Unable to Export '
if (kclManager.kclErrors.length > 0)
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
}
},
},
services: {
'AST-undo-startSketchOn': async ({ sketchDetails }) => {

View File

@ -210,6 +210,7 @@ function ModelingPaneButton({
key={paneConfig.id}
className="pointer-events-auto flex items-center justify-center border-transparent dark:border-transparent p-0 m-0 rounded-sm !outline-none"
onClick={togglePane}
data-testid={paneConfig.title}
>
<ActionIcon
icon={paneConfig.icon}

View File

@ -231,7 +231,10 @@ export const NetworkHealthIndicator = () => {
Network Health ({NETWORK_HEALTH_TEXT[overallState]})
</Tooltip>
</Popover.Button>
<Popover.Panel className="absolute right-0 left-auto bottom-full mb-1 w-64 flex flex-col gap-1 align-stretch bg-chalkboard-10 dark:bg-chalkboard-90 rounded shadow-lg border border-solid border-chalkboard-20/50 dark:border-chalkboard-80/50 text-sm">
<Popover.Panel
className="absolute right-0 left-auto bottom-full mb-1 w-64 flex flex-col gap-1 align-stretch bg-chalkboard-10 dark:bg-chalkboard-90 rounded shadow-lg border border-solid border-chalkboard-20/50 dark:border-chalkboard-80/50 text-sm"
data-testid="network-popover"
>
<div
className={`flex items-center justify-between p-2 rounded-t-sm ${overallConnectionStateColor[overallState].bg} ${overallConnectionStateColor[overallState].icon}`}
>

View File

@ -1,235 +0,0 @@
import { FormEvent, useEffect, useRef, useState } from 'react'
import { paths } from 'lib/paths'
import { Link } from 'react-router-dom'
import { ActionButton } from './ActionButton'
import {
faCheck,
faPenAlt,
faTrashAlt,
faX,
} from '@fortawesome/free-solid-svg-icons'
import { FILE_EXT } from 'lib/constants'
import { Dialog } from '@headlessui/react'
import { useHotkeys } from 'react-hotkeys-hook'
import Tooltip from './Tooltip'
import { Project } from 'wasm-lib/kcl/bindings/Project'
function ProjectCard({
project,
handleRenameProject,
handleDeleteProject,
...props
}: {
project: Project
handleRenameProject: (
e: FormEvent<HTMLFormElement>,
f: Project
) => Promise<void>
handleDeleteProject: (f: Project) => Promise<void>
}) {
useHotkeys('esc', () => setIsEditing(false))
const [isEditing, setIsEditing] = useState(false)
const [isConfirmingDelete, setIsConfirmingDelete] = useState(false)
const [numberOfFiles, setNumberOfFiles] = useState(1)
const [numberOfFolders, setNumberOfFolders] = useState(0)
let inputRef = useRef<HTMLInputElement>(null)
function handleSave(e: FormEvent<HTMLFormElement>) {
e.preventDefault()
void handleRenameProject(e, project).then(() => setIsEditing(false))
}
function getDisplayedTime(dateStr: string) {
const date = new Date(dateStr)
const startOfToday = new Date()
startOfToday.setHours(0, 0, 0, 0)
return date.getTime() < startOfToday.getTime()
? date.toLocaleDateString()
: date.toLocaleTimeString()
}
useEffect(() => {
async function getNumberOfFiles() {
setNumberOfFiles(project.kcl_file_count)
setNumberOfFolders(project.directory_count)
}
void getNumberOfFiles()
}, [project.kcl_file_count, project.directory_count])
useEffect(() => {
if (inputRef.current) {
inputRef.current.focus()
inputRef.current.select()
}
}, [inputRef.current])
return (
<li
{...props}
className="group relative min-h-[5em] p-1 rounded-sm border border-chalkboard-20 dark:border-chalkboard-80 hover:!border-primary"
>
{isEditing ? (
<form onSubmit={handleSave} className="flex items-center gap-2">
<input
className="min-w-0 p-1 dark:bg-chalkboard-80 dark:border-chalkboard-40 focus:outline-none"
type="text"
id="newProjectName"
name="newProjectName"
autoCorrect="off"
autoCapitalize="off"
defaultValue={project.name}
ref={inputRef}
/>
<div className="flex items-center gap-1">
<ActionButton
Element="button"
type="submit"
iconStart={{
icon: faCheck,
size: 'sm',
className: 'p-1',
bgClassName: '!bg-transparent',
}}
className="!p-0"
>
<Tooltip position="left" delay={1000}>
Rename project
</Tooltip>
</ActionButton>
<ActionButton
Element="button"
iconStart={{
icon: faX,
size: 'sm',
iconClassName: 'dark:!text-chalkboard-20',
bgClassName: '!bg-transparent',
className: 'p-1',
}}
className="!p-0"
onClick={() => setIsEditing(false)}
>
<Tooltip position="left" delay={1000}>
Cancel
</Tooltip>
</ActionButton>
</div>
</form>
) : (
<>
<Link
className="relative z-0 flex flex-col h-full gap-2 p-1 !no-underline !text-chalkboard-110 dark:!text-chalkboard-10"
to={`${paths.FILE}/${encodeURIComponent(project.default_file)}`}
data-testid="project-link"
>
<div className="flex-1">{project.name?.replace(FILE_EXT, '')}</div>
<span className="text-xs text-chalkboard-60">
{numberOfFiles} file{numberOfFiles === 1 ? '' : 's'}{' '}
{numberOfFolders > 0 &&
`/ ${numberOfFolders} folder${
numberOfFolders === 1 ? '' : 's'
}`}
</span>
<span className="text-xs text-chalkboard-60">
Edited{' '}
{project.metadata && project.metadata?.modified
? getDisplayedTime(project.metadata.modified)
: 'never'}
</span>
</Link>
<div className="absolute z-10 flex items-center gap-1 opacity-0 bottom-2 right-2 group-hover:opacity-100 group-focus-within:opacity-100">
<ActionButton
Element="button"
iconStart={{
icon: faPenAlt,
className: 'p-1',
iconClassName: 'dark:!text-chalkboard-20',
bgClassName: '!bg-transparent',
size: 'xs',
}}
onClick={(e) => {
e.stopPropagation()
e.nativeEvent.stopPropagation()
setIsEditing(true)
}}
className="!p-0"
>
<Tooltip position="left" delay={1000}>
Rename project
</Tooltip>
</ActionButton>
<ActionButton
Element="button"
iconStart={{
icon: faTrashAlt,
className: 'p-1',
size: 'xs',
bgClassName: '!bg-transparent',
iconClassName: '!text-destroy-70',
}}
className="!p-0 hover:border-destroy-40 dark:hover:border-destroy-40"
onClick={(e) => {
e.stopPropagation()
e.nativeEvent.stopPropagation()
setIsConfirmingDelete(true)
}}
>
<Tooltip position="left" delay={1000}>
Delete project
</Tooltip>
</ActionButton>
</div>
<Dialog
open={isConfirmingDelete}
onClose={() => setIsConfirmingDelete(false)}
className="relative z-50"
>
<div className="fixed inset-0 grid bg-chalkboard-110/80 place-content-center">
<Dialog.Panel className="max-w-2xl p-4 border rounded bg-chalkboard-10 dark:bg-chalkboard-100 border-destroy-80">
<Dialog.Title as="h2" className="mb-4 text-2xl font-bold">
Delete File
</Dialog.Title>
<Dialog.Description>
This will permanently delete "{project.name || 'this file'}".
</Dialog.Description>
<p className="my-4">
Are you sure you want to delete "{project.name || 'this file'}
"? This action cannot be undone.
</p>
<div className="flex justify-between">
<ActionButton
Element="button"
onClick={async () => {
await handleDeleteProject(project)
setIsConfirmingDelete(false)
}}
iconStart={{
icon: faTrashAlt,
bgClassName: 'bg-destroy-80',
className: 'p-1',
size: 'sm',
iconClassName: '!text-destroy-70 dark:!text-destroy-40',
}}
className="hover:border-destroy-40 dark:hover:border-destroy-40"
>
Delete
</ActionButton>
<ActionButton
Element="button"
onClick={() => setIsConfirmingDelete(false)}
>
Cancel
</ActionButton>
</div>
</Dialog.Panel>
</div>
</Dialog>
</>
)}
</li>
)
}
export default ProjectCard

View File

@ -0,0 +1,53 @@
import { Dialog } from '@headlessui/react'
import { ActionButton } from 'components/ActionButton'
interface DeleteProjectDialogProps {
projectName: string
onConfirm: () => void
onDismiss: () => void
}
export function DeleteProjectDialog({
projectName,
onConfirm,
onDismiss,
}: DeleteProjectDialogProps) {
return (
<Dialog open={true} onClose={onDismiss} className="relative z-50">
<div className="fixed inset-0 grid bg-chalkboard-110/80 place-content-center">
<Dialog.Panel className="max-w-2xl p-4 border rounded bg-chalkboard-10 dark:bg-chalkboard-100 border-destroy-80">
<Dialog.Title as="h2" className="mb-4 text-2xl font-bold">
Delete File
</Dialog.Title>
<Dialog.Description>
This will permanently delete "{projectName || 'this file'}
".
</Dialog.Description>
<p className="my-4">
Are you sure you want to delete "{projectName || 'this file'}
"? This action cannot be undone.
</p>
<div className="flex justify-between">
<ActionButton
Element="button"
onClick={onConfirm}
iconStart={{
icon: 'trash',
bgClassName: 'bg-destroy-10 dark:bg-destroy-80',
iconClassName: '!text-destroy-80 dark:!text-destroy-20',
}}
className="hover:border-destroy-40 dark:hover:border-destroy-40 hover:bg-destroy-10/20 dark:hover:bg-destroy-80/20"
>
Delete
</ActionButton>
<ActionButton Element="button" onClick={onDismiss}>
Cancel
</ActionButton>
</div>
</Dialog.Panel>
</div>
</Dialog>
)
}

View File

@ -0,0 +1,176 @@
import { FormEvent, useEffect, useRef, useState } from 'react'
import { paths } from 'lib/paths'
import { Link } from 'react-router-dom'
import { ActionButton } from '../ActionButton'
import { FILE_EXT } from 'lib/constants'
import { useHotkeys } from 'react-hotkeys-hook'
import Tooltip from '../Tooltip'
import { DeleteProjectDialog } from './DeleteProjectDialog'
import { ProjectCardRenameForm } from './ProjectCardRenameForm'
import { Project } from 'wasm-lib/kcl/bindings/Project'
function ProjectCard({
project,
handleRenameProject,
handleDeleteProject,
...props
}: {
project: Project
handleRenameProject: (
e: FormEvent<HTMLFormElement>,
f: Project
) => Promise<void>
handleDeleteProject: (f: Project) => Promise<void>
}) {
useHotkeys('esc', () => setIsEditing(false))
const [isEditing, setIsEditing] = useState(false)
const [isConfirmingDelete, setIsConfirmingDelete] = useState(false)
const [numberOfFiles, setNumberOfFiles] = useState(1)
const [numberOfFolders, setNumberOfFolders] = useState(0)
// const [imageUrl, setImageUrl] = useState('')
let inputRef = useRef<HTMLInputElement>(null)
function handleSave(e: FormEvent<HTMLFormElement>) {
e.preventDefault()
void handleRenameProject(e, project).then(() => setIsEditing(false))
}
function getDisplayedTime(dateStr: string) {
const date = new Date(dateStr)
const startOfToday = new Date()
startOfToday.setHours(0, 0, 0, 0)
return date.getTime() < startOfToday.getTime()
? date.toLocaleDateString()
: date.toLocaleTimeString()
}
useEffect(() => {
async function getNumberOfFiles() {
setNumberOfFiles(project.kcl_file_count)
setNumberOfFolders(project.directory_count)
}
// async function setupImageUrl() {
// const projectImagePath = await join(project.path, PROJECT_IMAGE_NAME)
// if (await exists(projectImagePath)) {
// const imageData = await readFile(projectImagePath)
// const blob = new Blob([imageData], { type: 'image/jpg' })
// const imageUrl = URL.createObjectURL(blob)
// setImageUrl(imageUrl)
// }
// }
void getNumberOfFiles()
// void setupImageUrl()
}, [project.kcl_file_count, project.directory_count])
useEffect(() => {
if (inputRef.current && isEditing) {
inputRef.current.focus()
inputRef.current.select()
}
}, [isEditing, inputRef.current])
return (
<li
{...props}
className="group relative flex flex-col rounded-sm border border-primary/40 dark:border-chalkboard-80 hover:!border-primary"
>
<Link
data-testid="project-link"
to={`${paths.FILE}/${encodeURIComponent(project.default_file)}`}
className="flex flex-col flex-1 !no-underline !text-chalkboard-110 dark:!text-chalkboard-10 group-hover:!hue-rotate-0 min-h-[5em] divide-y divide-primary/40 dark:divide-chalkboard-80 group-hover:!divide-primary"
>
{/* <div className="h-36 relative overflow-hidden bg-gradient-to-b from-transparent to-primary/10 rounded-t-sm">
{imageUrl && (
<img
src={imageUrl}
alt=""
className="h-full w-full transition-transform group-hover:scale-105 object-cover"
/>
)}
</div> */}
<div className="pb-2 flex flex-col flex-grow flex-auto gap-2 rounded-b-sm">
{isEditing ? (
<ProjectCardRenameForm
onSubmit={handleSave}
className="flex items-center gap-2 p-2"
onClick={(e) => e.stopPropagation()}
project={project}
onDismiss={() => setIsEditing(false)}
ref={inputRef}
/>
) : (
<h3 className="font-sans relative z-0 p-2">
{project.name?.replace(FILE_EXT, '')}
</h3>
)}
<span className="px-2 text-chalkboard-60 text-xs">
{numberOfFiles} file{numberOfFiles === 1 ? '' : 's'}{' '}
{numberOfFolders > 0 &&
`/ ${numberOfFolders} folder${numberOfFolders === 1 ? '' : 's'}`}
</span>
<span className="px-2 text-chalkboard-60 text-xs">
Edited{' '}
{project.metadata && project.metadata?.modified
? getDisplayedTime(project.metadata.modified)
: 'never'}
</span>
</div>
</Link>
{!isEditing && (
<div className="absolute z-10 flex items-center gap-1 opacity-0 bottom-2 right-2 group-hover:opacity-100 group-focus-within:opacity-100">
<ActionButton
Element="button"
iconStart={{
icon: 'sketch',
iconClassName: 'dark:!text-chalkboard-20',
bgClassName: '!bg-transparent',
}}
onClick={(e) => {
e.stopPropagation()
e.nativeEvent.stopPropagation()
setIsEditing(true)
}}
className="!p-0"
>
<Tooltip position="top-right" delay={1000}>
Rename project
</Tooltip>
</ActionButton>
<ActionButton
Element="button"
iconStart={{
icon: 'trash',
iconClassName: 'dark:!text-chalkboard-30',
bgClassName: '!bg-transparent',
}}
className="!p-0"
onClick={(e) => {
e.stopPropagation()
e.nativeEvent.stopPropagation()
setIsConfirmingDelete(true)
}}
>
<Tooltip position="top-right" delay={1000}>
Delete project
</Tooltip>
</ActionButton>
</div>
)}
{isConfirmingDelete && (
<DeleteProjectDialog
projectName={project.name}
onConfirm={async () => {
await handleDeleteProject(project)
setIsConfirmingDelete(false)
}}
onDismiss={() => setIsConfirmingDelete(false)}
/>
)}
</li>
)
}
export default ProjectCard

View File

@ -0,0 +1,67 @@
import { ActionButton } from 'components/ActionButton'
import Tooltip from 'components/Tooltip'
import { HTMLProps, forwardRef } from 'react'
import { Project } from 'wasm-lib/kcl/bindings/Project'
interface ProjectCardRenameFormProps extends HTMLProps<HTMLFormElement> {
project: Project
onDismiss: () => void
}
export const ProjectCardRenameForm = forwardRef(
(
{ project, onDismiss, ...props }: ProjectCardRenameFormProps,
ref: React.Ref<HTMLInputElement>
) => {
return (
<form {...props}>
<input
className="min-w-0 dark:bg-chalkboard-80 dark:border-chalkboard-40 focus:outline-none"
type="text"
id="newProjectName"
onClickCapture={(e) => e.preventDefault()}
name="newProjectName"
required
autoCorrect="off"
autoCapitalize="off"
defaultValue={project.name}
ref={ref}
onKeyDown={(e) => {
if (e.key === 'Escape') {
onDismiss()
}
}}
/>
<div className="flex items-center gap-1">
<ActionButton
Element="button"
type="submit"
iconStart={{
icon: 'checkmark',
bgClassName: '!bg-transparent',
}}
className="!p-0"
>
<Tooltip position="left" delay={1000}>
Rename project
</Tooltip>
</ActionButton>
<ActionButton
Element="button"
iconStart={{
icon: 'close',
iconClassName: 'dark:!text-chalkboard-20',
bgClassName: '!bg-transparent',
}}
className="!p-0"
onClick={onDismiss}
>
<Tooltip position="left" delay={1000}>
Cancel
</Tooltip>
</ActionButton>
</div>
</form>
)
}
)

View File

@ -590,6 +590,8 @@ class EngineConnection {
) {
this.engineCommandManager.inSequence = result.data.sequence
callback(result)
} else if (result.type !== 'highlight_set_entity') {
callback(result)
}
}
)
@ -907,7 +909,7 @@ type UnreliableResponses = Extract<
Models['OkModelingCmdResponse_type'],
{ type: 'highlight_set_entity' | 'camera_drag_move' }
>
interface UnreliableSubscription<T extends UnreliableResponses['type']> {
export interface UnreliableSubscription<T extends UnreliableResponses['type']> {
event: T
callback: (data: Extract<UnreliableResponses, { type: T }>) => void
}
@ -1119,24 +1121,6 @@ export class EngineCommandManager {
},
})
// Make the axis gizmo.
// We do this after the connection opened to avoid a race condition.
// Connected opened is the last thing that happens when the stream
// is ready.
// We also do this here because we want to ensure we create the gizmo
// and execute the code everytime the stream is restarted.
const gizmoId = uuidv4()
void this.sendSceneCommand({
type: 'modeling_cmd_req',
cmd_id: gizmoId,
cmd: {
type: 'make_axes_gizmo',
clobber: false,
// If true, axes gizmo will be placed in the corner of the screen.
// If false, it will be placed at the origin of the scene.
gizmo_mode: true,
},
})
this._camControlsCameraChange()
this.sendSceneCommand({
// CameraControls subscribes to default_camera_get_settings response events
@ -1148,10 +1132,26 @@ export class EngineCommandManager {
},
})
this.initPlanes().then(() => {
this.initPlanes().then(async () => {
this.resolveReady()
setIsStreamReady(true)
executeCode()
await executeCode()
await this.sendSceneCommand({
type: 'modeling_cmd_req',
cmd_id: uuidv4(),
cmd: {
type: 'zoom_to_fit',
object_ids: [], // leave empty to zoom to all objects
padding: 0.1, // padding around the objects
},
})
// make sure client camera syncs after zoom to fit since zoom to fit doesn't return camera settings
// TODO: https://github.com/KittyCAD/engine/issues/2098
await this.sendSceneCommand({
type: 'modeling_cmd_req',
cmd_id: uuidv4(),
cmd: { type: 'default_camera_get_settings' },
})
})
},
onClose: () => {
@ -1420,17 +1420,6 @@ export class EngineCommandManager {
this.lastArtifactMap = this.artifactMap
this.artifactMap = {}
await this.initPlanes()
await this.sendSceneCommand({
type: 'modeling_cmd_req',
cmd_id: uuidv4(),
cmd: {
type: 'make_axes_gizmo',
clobber: false,
// If true, axes gizmo will be placed in the corner of the screen.
// If false, it will be placed at the origin of the scene.
gizmo_mode: true,
},
})
}
subscribeTo<T extends ModelTypes>({
event,

View File

@ -1114,6 +1114,28 @@ export function addNewSketchLn({
})
}
export function addCallExpressionsToPipe({
node,
pathToNode,
expressions,
}: {
node: Program
programMemory: ProgramMemory
pathToNode: PathToNode
expressions: CallExpression[]
}) {
const _node = { ...node }
const pipeExpression = getNodeFromPath<PipeExpression>(
_node,
pathToNode,
'PipeExpression'
).node
if (pipeExpression.type !== 'PipeExpression')
throw new Error('not a pipe expression')
pipeExpression.body = [...pipeExpression.body, ...expressions]
return _node
}
export function addCloseToPipe({
node,
pathToNode,

View File

@ -24,6 +24,8 @@ export const PROJECT_FOLDER = 'zoo-modeling-app-projects'
export const FILE_EXT = '.kcl'
/** Default file to open when a project is opened */
export const PROJECT_ENTRYPOINT = `main${FILE_EXT}` as const
/** Thumbnail file name */
export const PROJECT_IMAGE_NAME = `main.jpg` as const
/** The localStorage key for last-opened projects */
export const FILE_PERSIST_KEY = `${PROJECT_FOLDER}-last-opened` as const
/** The default name given to new kcl files in a project */

View File

@ -12,7 +12,7 @@ import { loadAndValidateSettings } from './settings/settingsUtils'
import makeUrlPathRelative from './makeUrlPathRelative'
import { sep } from '@tauri-apps/api/path'
import { readTextFile } from '@tauri-apps/plugin-fs'
import { codeManager, kclManager } from 'lib/singletons'
import { codeManager, engineCommandManager, kclManager } from 'lib/singletons'
import { fileSystemManager } from 'lang/std/fileSystemManager'
import {
getProjectInfo,
@ -20,6 +20,7 @@ import {
listProjects,
} from './tauri'
import { createSettings } from './settings/initialSettings'
import { uuidv4 } from './utils'
// The root loader simply resolves the settings and any errors that
// occurred during the settings load
@ -105,6 +106,22 @@ export const fileLoader: LoaderFunction = async ({
codeManager.updateCurrentFilePath(current_file_path)
codeManager.updateCodeStateEditor(code)
kclManager.executeCode(true)
await engineCommandManager.sendSceneCommand({
type: 'modeling_cmd_req',
cmd_id: uuidv4(),
cmd: {
type: 'zoom_to_fit',
object_ids: [], // leave empty to zoom to all objects
padding: 0.1, // padding around the objects
},
})
// make sure client camera syncs after zoom to fit since zoom to fit doesn't return camera settings
// TODO: https://github.com/KittyCAD/engine/issues/2098
await engineCommandManager.sendSceneCommand({
type: 'modeling_cmd_req',
cmd_id: uuidv4(),
cmd: { type: 'default_camera_get_settings' },
})
// Set the file system manager to the project path
// So that WASM gets an updated path for operations

View File

@ -1,19 +1,18 @@
import {
faArrowDown,
faArrowUp,
faCircle,
} from '@fortawesome/free-solid-svg-icons'
import { CustomIconName } from 'components/CustomIcon'
import { Project } from 'wasm-lib/kcl/bindings/Project'
const DESC = ':desc'
export function getSortIcon(currentSort: string, newSort: string) {
export function getSortIcon(
currentSort: string,
newSort: string
): CustomIconName {
if (currentSort === newSort) {
return faArrowUp
return 'arrowUp'
} else if (currentSort === newSort + DESC) {
return faArrowDown
return 'arrowDown'
}
return faCircle
return 'horizontalDash'
}
export function getNextSearchParams(currentSort: string, newSort: string) {

View File

@ -4,16 +4,15 @@ import {
getNextProjectIndex,
interpolateProjectNameWithIndex,
doesProjectNameNeedInterpolated,
} from '../lib/tauriFS'
import { ActionButton } from '../components/ActionButton'
import { faArrowDown, faPlus } from '@fortawesome/free-solid-svg-icons'
} from 'lib/tauriFS'
import { ActionButton } from 'components/ActionButton'
import { toast } from 'react-hot-toast'
import { AppHeader } from '../components/AppHeader'
import ProjectCard from '../components/ProjectCard'
import { AppHeader } from 'components/AppHeader'
import ProjectCard from 'components/ProjectCard/ProjectCard'
import { useLoaderData, useNavigate, useSearchParams } from 'react-router-dom'
import { Link } from 'react-router-dom'
import { type HomeLoaderData } from 'lib/types'
import Loading from '../components/Loading'
import Loading from 'components/Loading'
import { useMachine } from '@xstate/react'
import { homeMachine } from '../machines/homeMachine'
import { ContextFrom, EventFrom } from 'xstate'
@ -187,9 +186,11 @@ const Home = () => {
new FormData(e.target as HTMLFormElement)
)
send('Rename project', {
data: { oldName: project.name, newName: newProjectName },
})
if (newProjectName !== project.name) {
send('Rename project', {
data: { oldName: project.name, newName: newProjectName },
})
}
}
async function handleDeleteProject(project: Project) {
@ -199,71 +200,93 @@ const Home = () => {
return (
<div className="relative flex flex-col h-screen overflow-hidden">
<AppHeader showToolbar={false} />
<div className="w-full max-w-5xl px-4 mx-auto my-24 overflow-y-auto lg:px-0">
<section className="flex justify-between">
<h1 className="text-3xl font-bold">Your Projects</h1>
<div className="flex gap-2 items-center">
<small>Sort by</small>
<ActionButton
Element="button"
className={
'text-sm ' +
(!sort.includes('name')
? 'text-chalkboard-80 dark:text-chalkboard-40'
: '')
}
onClick={() => setSearchParams(getNextSearchParams(sort, 'name'))}
iconStart={{
icon: getSortIcon(sort, 'name'),
className: 'p-1.5',
iconClassName: !sort.includes('name')
? '!text-chalkboard-40'
: '',
size: 'sm',
}}
>
Name
</ActionButton>
<ActionButton
Element="button"
className={
'text-sm ' +
(!isSortByModified
? 'text-chalkboard-80 dark:text-chalkboard-40'
: '')
}
onClick={() =>
setSearchParams(getNextSearchParams(sort, 'modified'))
}
iconStart={{
icon: sort ? getSortIcon(sort, 'modified') : faArrowDown,
className: 'p-1.5',
iconClassName: !isSortByModified ? '!text-chalkboard-40' : '',
size: 'sm',
}}
>
Last Modified
</ActionButton>
<div className="w-full flex flex-col overflow-hidden max-w-5xl px-4 mx-auto mt-24 lg:px-2">
<section>
<div className="flex justify-between items-baseline select-none">
<div className="flex gap-8 items-baseline">
<h1 className="text-3xl font-bold">Your Projects</h1>
<ActionButton
Element="button"
onClick={() => send('Create project')}
className="group !bg-primary !text-chalkboard-10 !border-primary hover:shadow-inner hover:hue-rotate-15"
iconStart={{
icon: 'plus',
bgClassName: '!bg-transparent rounded-sm',
iconClassName:
'!text-chalkboard-10 transition-transform group-active:rotate-90',
}}
data-testid="home-new-file"
>
New project
</ActionButton>
</div>
<div className="flex gap-2 items-center">
<small>Sort by</small>
<ActionButton
Element="button"
className={
'text-xs border-primary/10 ' +
(!sort.includes('name')
? 'text-chalkboard-80 dark:text-chalkboard-40'
: '')
}
onClick={() =>
setSearchParams(getNextSearchParams(sort, 'name'))
}
iconStart={{
icon: getSortIcon(sort, 'name'),
bgClassName: 'bg-transparent',
iconClassName: !sort.includes('name')
? '!text-chalkboard-90 dark:!text-chalkboard-30'
: '',
}}
>
Name
</ActionButton>
<ActionButton
Element="button"
className={
'text-xs border-primary/10 ' +
(!isSortByModified
? 'text-chalkboard-80 dark:text-chalkboard-40'
: '')
}
onClick={() =>
setSearchParams(getNextSearchParams(sort, 'modified'))
}
iconStart={{
icon: sort ? getSortIcon(sort, 'modified') : 'arrowDown',
bgClassName: 'bg-transparent',
iconClassName: !isSortByModified
? '!text-chalkboard-90 dark:!text-chalkboard-30'
: '',
}}
>
Last Modified
</ActionButton>
</div>
</div>
</section>
<section data-testid="home-section">
<p className="my-4 text-sm text-chalkboard-80 dark:text-chalkboard-30">
Loaded from{' '}
<span className="text-chalkboard-90 dark:text-chalkboard-20">
<Link
to="settings?tab=user#projectDirectory"
className="text-chalkboard-90 dark:text-chalkboard-20 underline underline-offset-2"
>
{settings.app.projectDirectory.current}
</span>
.{' '}
<Link to="settings" className="underline underline-offset-2">
Edit in settings
</Link>
.
</p>
</section>
<section
data-testid="home-section"
className="flex-1 overflow-y-auto pr-2 pb-24"
>
{state.matches('Reading projects') ? (
<Loading>Loading your Projects...</Loading>
) : (
<>
{projects.length > 0 ? (
<ul className="grid w-full grid-cols-4 gap-4 my-8">
<ul className="grid w-full grid-cols-4 gap-4">
{projects.sort(getSortFunction(sort)).map((project) => (
<ProjectCard
key={project.name}
@ -278,14 +301,6 @@ const Home = () => {
No Projects found, ready to make your first one?
</p>
)}
<ActionButton
Element="button"
onClick={() => send('Create project')}
iconStart={{ icon: faPlus, iconClassName: 'p-1 w-4' }}
data-testid="home-new-file"
>
New project
</ActionButton>
</>
)}
</section>

File diff suppressed because it is too large Load Diff

View File

@ -1,14 +1,9 @@
#![no_main]
#[macro_use]
extern crate libfuzzer_sys;
extern crate kcl_lib;
fuzz_target!(|data: &[u8]| {
if let Ok(s) = std::str::from_utf8(data) {
let tokens = kcl_lib::tokeniser::lexer(s);
let parser = kcl_lib::parser::Parser::new(tokens);
if let Ok(_) = parser.ast() {
println!("OK");
}
use libfuzzer_sys::fuzz_target;
fuzz_target!(|data: &str| {
if let Ok(v) = kcl_lib::token::lexer(data) {
let _ = kcl_lib::parser::Parser::new(v).ast();
}
});

View File

@ -16,7 +16,9 @@ pub use crate::ast::types::{literal_value::LiteralValue, none::KclNone};
use crate::{
docs::StdLibFn,
errors::{KclError, KclErrorDetails},
executor::{BodyType, ExecutorContext, MemoryItem, Metadata, PipeInfo, ProgramMemory, SourceRange, UserVal},
executor::{
BodyType, ExecutorContext, MemoryItem, Metadata, PipeInfo, ProgramMemory, SourceRange, StatementKind, UserVal,
},
parser::PIPE_OPERATOR,
std::{kcl_stdlib::KclStdLibFn, FunctionKind},
};
@ -1096,45 +1098,12 @@ impl CallExpression {
let mut fn_args: Vec<MemoryItem> = Vec::with_capacity(self.arguments.len());
for arg in &self.arguments {
let result: MemoryItem = match arg {
Value::None(none) => none.into(),
Value::Literal(literal) => literal.into(),
Value::Identifier(identifier) => {
let value = memory.get(&identifier.name, identifier.into())?;
value.clone()
}
Value::BinaryExpression(binary_expression) => {
binary_expression.get_result(memory, pipe_info, ctx).await?
}
Value::CallExpression(call_expression) => call_expression.execute(memory, pipe_info, ctx).await?,
Value::UnaryExpression(unary_expression) => unary_expression.get_result(memory, pipe_info, ctx).await?,
Value::ObjectExpression(object_expression) => object_expression.execute(memory, pipe_info, ctx).await?,
Value::ArrayExpression(array_expression) => array_expression.execute(memory, pipe_info, ctx).await?,
Value::PipeExpression(pipe_expression) => {
return Err(KclError::Semantic(KclErrorDetails {
message: format!("PipeExpression not implemented here: {:?}", pipe_expression),
source_ranges: vec![pipe_expression.into()],
}));
}
Value::PipeSubstitution(pipe_substitution) => pipe_info
.previous_results
.as_ref()
.ok_or_else(|| {
KclError::Semantic(KclErrorDetails {
message: format!("PipeSubstitution index out of bounds: {:?}", pipe_info),
source_ranges: vec![pipe_substitution.into()],
})
})?
.clone(),
Value::MemberExpression(member_expression) => member_expression.get_result(memory)?,
Value::FunctionExpression(function_expression) => {
return Err(KclError::Semantic(KclErrorDetails {
message: format!("FunctionExpression not implemented here: {:?}", function_expression),
source_ranges: vec![function_expression.into()],
}));
}
let metadata = Metadata {
source_range: SourceRange([arg.start(), arg.end()]),
};
let result = ctx
.arg_into_mem_item(arg, memory, pipe_info, &metadata, StatementKind::Expression)
.await?;
fn_args.push(result);
}
@ -2773,6 +2742,7 @@ impl PipeExpression {
}
}
#[async_recursion::async_recursion]
async fn execute_pipe_body(
memory: &mut ProgramMemory,
body: &[Value],
@ -2791,18 +2761,12 @@ async fn execute_pipe_body(
// They use the `pipe_info` from some AST node above this, so that if pipe expression is nested in a larger pipe expression,
// they use the % from the parent. After all, this pipe expression hasn't been executed yet, so it doesn't have any % value
// of its own.
let output = match first {
Value::BinaryExpression(binary_expression) => binary_expression.get_result(memory, pipe_info, ctx).await?,
Value::CallExpression(call_expression) => call_expression.execute(memory, pipe_info, ctx).await?,
Value::Identifier(identifier) => memory.get(&identifier.name, identifier.into())?.clone(),
_ => {
// Return an error this should not happen.
return Err(KclError::Semantic(KclErrorDetails {
message: format!("PipeExpression not implemented here: {:?}", first),
source_ranges: vec![first.into()],
}));
}
let meta = Metadata {
source_range: SourceRange([first.start(), first.end()]),
};
let output = ctx
.arg_into_mem_item(first, memory, pipe_info, &meta, StatementKind::Expression)
.await?;
// Now that we've evaluated the first child expression in the pipeline, following child expressions
// should use the previous child expression for %.
// This means there's no more need for the previous `pipe_info` from the parent AST node above this one.
@ -2819,7 +2783,7 @@ async fn execute_pipe_body(
_ => {
// Return an error this should not happen.
return Err(KclError::Semantic(KclErrorDetails {
message: format!("PipeExpression not implemented here: {:?}", expression),
message: format!("This cannot be in a PipeExpression: {:?}", expression),
source_ranges: vec![expression.into()],
}));
}

View File

@ -1135,35 +1135,13 @@ impl ExecutorContext {
let fn_name = call_expr.callee.name.to_string();
let mut args: Vec<MemoryItem> = Vec::new();
for arg in &call_expr.arguments {
match arg {
Value::Literal(literal) => args.push(literal.into()),
Value::Identifier(identifier) => {
let memory_item = memory.get(&identifier.name, identifier.into())?;
args.push(memory_item.clone());
}
Value::CallExpression(call_expr) => {
let result = call_expr.execute(memory, &pipe_info, self).await?;
args.push(result);
}
Value::BinaryExpression(binary_expression) => {
let result = binary_expression.get_result(memory, &pipe_info, self).await?;
args.push(result);
}
Value::UnaryExpression(unary_expression) => {
let result = unary_expression.get_result(memory, &pipe_info, self).await?;
args.push(result);
}
Value::ObjectExpression(object_expression) => {
let result = object_expression.execute(memory, &pipe_info, self).await?;
args.push(result);
}
Value::ArrayExpression(array_expression) => {
let result = array_expression.execute(memory, &pipe_info, self).await?;
args.push(result);
}
// We do nothing for the rest.
_ => (),
}
let metadata = Metadata {
source_range: SourceRange([arg.start(), arg.end()]),
};
let mem_item = self
.arg_into_mem_item(arg, memory, &pipe_info, &metadata, StatementKind::Expression)
.await?;
args.push(mem_item);
}
match self.stdlib.get_either(&call_expr.callee.name) {
FunctionKind::Core(func) => {
@ -1199,88 +1177,16 @@ impl ExecutorContext {
let source_range: SourceRange = declaration.init.clone().into();
let metadata = Metadata { source_range };
match &declaration.init {
Value::None(none) => {
memory.add(&var_name, none.into(), source_range)?;
}
Value::Literal(literal) => {
memory.add(&var_name, literal.into(), source_range)?;
}
Value::Identifier(identifier) => {
let value = memory.get(&identifier.name, identifier.into())?;
memory.add(&var_name, value.clone(), source_range)?;
}
Value::BinaryExpression(binary_expression) => {
let result = binary_expression.get_result(memory, &pipe_info, self).await?;
memory.add(&var_name, result, source_range)?;
}
Value::FunctionExpression(function_expression) => {
let mem_func = force_memory_function(
|args: Vec<MemoryItem>,
memory: ProgramMemory,
function_expression: Box<FunctionExpression>,
_metadata: Vec<Metadata>,
ctx: ExecutorContext| {
Box::pin(async move {
let mut fn_memory =
assign_args_to_params(&function_expression, args, memory.clone())?;
let result = ctx
.inner_execute(
function_expression.body.clone(),
&mut fn_memory,
BodyType::Block,
)
.await?;
Ok(result.return_)
})
},
);
memory.add(
&var_name,
MemoryItem::Function {
expression: function_expression.clone(),
meta: vec![metadata],
func: Some(mem_func),
},
source_range,
)?;
}
Value::CallExpression(call_expression) => {
let result = call_expression.execute(memory, &pipe_info, self).await?;
memory.add(&var_name, result, source_range)?;
}
Value::PipeExpression(pipe_expression) => {
let result = pipe_expression.get_result(memory, &pipe_info, self).await?;
memory.add(&var_name, result, source_range)?;
}
Value::PipeSubstitution(pipe_substitution) => {
return Err(KclError::Semantic(KclErrorDetails {
message: format!(
"pipe substitution not implemented for declaration of variable {}",
var_name
),
source_ranges: vec![pipe_substitution.into()],
}));
}
Value::ArrayExpression(array_expression) => {
let result = array_expression.execute(memory, &pipe_info, self).await?;
memory.add(&var_name, result, source_range)?;
}
Value::ObjectExpression(object_expression) => {
let result = object_expression.execute(memory, &pipe_info, self).await?;
memory.add(&var_name, result, source_range)?;
}
Value::MemberExpression(member_expression) => {
let result = member_expression.get_result(memory)?;
memory.add(&var_name, result, source_range)?;
}
Value::UnaryExpression(unary_expression) => {
let result = unary_expression.get_result(memory, &pipe_info, self).await?;
memory.add(&var_name, result, source_range)?;
}
}
let memory_item = self
.arg_into_mem_item(
&declaration.init,
memory,
&pipe_info,
&metadata,
StatementKind::Declaration { name: &var_name },
)
.await?;
memory.add(&var_name, memory_item, source_range)?;
}
}
BodyItem::ReturnStatement(return_statement) => match &return_statement.argument {
@ -1336,6 +1242,77 @@ impl ExecutorContext {
Ok(memory.clone())
}
pub async fn arg_into_mem_item<'a>(
&self,
init: &Value,
memory: &mut ProgramMemory,
pipe_info: &PipeInfo,
metadata: &Metadata,
statement_kind: StatementKind<'a>,
) -> Result<MemoryItem, KclError> {
let item = match init {
Value::None(none) => none.into(),
Value::Literal(literal) => literal.into(),
Value::Identifier(identifier) => {
let value = memory.get(&identifier.name, identifier.into())?;
value.clone()
}
Value::BinaryExpression(binary_expression) => binary_expression.get_result(memory, pipe_info, self).await?,
Value::FunctionExpression(function_expression) => {
let mem_func = force_memory_function(
|args: Vec<MemoryItem>,
memory: ProgramMemory,
function_expression: Box<FunctionExpression>,
_metadata: Vec<Metadata>,
ctx: ExecutorContext| {
Box::pin(async move {
let mut fn_memory = assign_args_to_params(&function_expression, args, memory.clone())?;
let result = ctx
.inner_execute(function_expression.body.clone(), &mut fn_memory, BodyType::Block)
.await?;
Ok(result.return_)
})
},
);
MemoryItem::Function {
expression: function_expression.clone(),
meta: vec![metadata.to_owned()],
func: Some(mem_func),
}
}
Value::CallExpression(call_expression) => call_expression.execute(memory, pipe_info, self).await?,
Value::PipeExpression(pipe_expression) => pipe_expression.get_result(memory, pipe_info, self).await?,
Value::PipeSubstitution(pipe_substitution) => match statement_kind {
StatementKind::Declaration { name } => {
let message = format!(
"you cannot declare variable {name} as %, because % can only be used in function calls"
);
return Err(KclError::Semantic(KclErrorDetails {
message,
source_ranges: vec![pipe_substitution.into()],
}));
}
StatementKind::Expression => match pipe_info.previous_results.clone() {
Some(x) => x,
None => {
return Err(KclError::Semantic(KclErrorDetails {
message: "cannot use % outside a pipe expression".to_owned(),
source_ranges: vec![pipe_substitution.into()],
}));
}
},
},
Value::ArrayExpression(array_expression) => array_expression.execute(memory, pipe_info, self).await?,
Value::ObjectExpression(object_expression) => object_expression.execute(memory, pipe_info, self).await?,
Value::MemberExpression(member_expression) => member_expression.get_result(memory)?,
Value::UnaryExpression(unary_expression) => unary_expression.get_result(memory, pipe_info, self).await?,
};
Ok(item)
}
/// Update the units for the executor.
pub fn update_units(&mut self, units: crate::settings::types::UnitLength) {
self.settings.units = units;
@ -1397,6 +1374,11 @@ fn assign_args_to_params(
Ok(fn_memory)
}
pub enum StatementKind<'a> {
Declaration { name: &'a str },
Expression,
}
#[cfg(test)]
mod tests {
use std::sync::Arc;

View File

@ -5,6 +5,7 @@ use crate::{
token::{Token, TokenType},
};
mod bad_inputs;
mod math;
pub(crate) mod parser_impl;

View File

@ -0,0 +1,17 @@
#[cfg(test)]
mod tests {
macro_rules! parse_and_lex {
($func_name:ident, $test_kcl_program:expr) => {
#[test]
fn $func_name() {
if let Ok(v) = $crate::token::lexer($test_kcl_program) {
let _ = $crate::parser::Parser::new(v).ast();
}
}
};
}
parse_and_lex!(crash_eof_1, "{\"ގގ\0\0\0\"\".");
parse_and_lex!(crash_eof_2, "(/=e\"\u{616}ݝ\"\"");
}

View File

@ -23,6 +23,18 @@ impl From<ParseError<Located<&str>, winnow::error::ContextError>> for KclError {
fn from(err: ParseError<Located<&str>, winnow::error::ContextError>) -> Self {
let (input, offset): (Vec<char>, usize) = (err.input().chars().collect(), err.offset());
if offset >= input.len() {
// From the winnow docs:
//
// This is an offset, not an index, and may point to
// the end of input (input.len()) on eof errors.
return KclError::Lexical(KclErrorDetails {
source_ranges: vec![SourceRange([offset, offset])],
message: "unexpected EOF while parsing".to_string(),
});
}
// TODO: Add the Winnow tokenizer context to the error.
// See https://github.com/KittyCAD/modeling-app/issues/784
let bad_token = &input[offset];

View File

@ -0,0 +1,22 @@
fn cube = (length, center) => {
let l = length/2
let x = center[0]
let y = center[1]
let p0 = [-l + x, -l + y]
let p1 = [-l + x, l + y]
let p2 = [ l + x, l + y]
let p3 = [ l + x, -l + y]
return startSketchAt(p0)
|> lineTo(p1, %)
|> lineTo(p2, %)
|> lineTo(p3, %)
|> lineTo(p0, %)
|> close(%)
|> extrude(length, %)
}
fn double = (x) => { return x * 2}
fn width = () => { return 200 }
const myCube = cube(200 |> double(%), [0,0])

View File

@ -128,6 +128,15 @@ async fn serial_test_lego() {
twenty_twenty::assert_image("tests/executor/outputs/lego.png", &result, 0.999);
}
#[tokio::test(flavor = "multi_thread")]
async fn serial_test_pipe_as_arg() {
let code = include_str!("inputs/pipe_as_arg.kcl");
let result = execute_and_snapshot(code, kcl_lib::settings::types::UnitLength::Mm)
.await
.unwrap();
twenty_twenty::assert_image("tests/executor/outputs/pipe_as_arg.png", &result, 0.999);
}
#[tokio::test(flavor = "multi_thread")]
async fn serial_test_pentagon_fillet_sugar() {
let code = include_str!("inputs/pentagon_fillet_sugar.kcl");

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

View File

@ -1831,10 +1831,10 @@
resolved "https://registry.yarnpkg.com/@juggle/resize-observer/-/resize-observer-3.4.0.tgz#08d6c5e20cf7e4cc02fd181c4b0c225cd31dbb60"
integrity sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==
"@kittycad/lib@^0.0.60":
version "0.0.60"
resolved "https://registry.yarnpkg.com/@kittycad/lib/-/lib-0.0.60.tgz#478aa1f750ab05cd4e67503de96f2f3bbc075329"
integrity sha512-LW9NFy2gv0pm1GJyquMXPiFKOBSdJJxYGkmacDton6jluGhAa8Qtcuj3O5vqUEeq9ObSM1Jt8gp39P9nvXG9yg==
"@kittycad/lib@^0.0.63":
version "0.0.63"
resolved "https://registry.yarnpkg.com/@kittycad/lib/-/lib-0.0.63.tgz#cc70cf1c0780543bbca6f55aae40d0904cfd45d7"
integrity sha512-fDpGnycumT1xI/tSubRZzU9809/7s+m06w2EuJzxowgFrdIlvThnIHVf3EYvSujdFb0bHR/LZjodAw2ocXkXZw==
dependencies:
node-fetch "3.3.2"
openapi-types "^12.0.0"
@ -1961,10 +1961,10 @@
resolved "https://registry.yarnpkg.com/@react-hook/passive-layout-effect/-/passive-layout-effect-1.2.1.tgz#c06dac2d011f36d61259aa1c6df4f0d5e28bc55e"
integrity sha512-IwEphTD75liO8g+6taS+4oqz+nnroocNfWVHWz7j+N+ZO2vYrc6PV1q7GQhuahL0IOR7JccFTsFKQ/mb6iZWAg==
"@react-hook/resize-observer@^1.2.6":
version "1.2.6"
resolved "https://registry.yarnpkg.com/@react-hook/resize-observer/-/resize-observer-1.2.6.tgz#9a8cf4c5abb09becd60d1d65f6bf10eec211e291"
integrity sha512-DlBXtLSW0DqYYTW3Ft1/GQFZlTdKY5VAFIC4+km6IK5NiPPDFchGbEJm1j6pSgMqPRHbUQgHJX7RaR76ic1LWA==
"@react-hook/resize-observer@^2.0.1":
version "2.0.1"
resolved "https://registry.yarnpkg.com/@react-hook/resize-observer/-/resize-observer-2.0.1.tgz#b1b090be25c74d89b371183e5e2bc0d03f94986f"
integrity sha512-9PCX9grWfxdPizY8ohr+X4IkV1JhGMWr2Nm4ngbg6IcAIv0WBs7YoJcNBqYl22OqPHr5eOMItGcStZrmj2mbmQ==
dependencies:
"@juggle/resize-observer" "^3.3.1"
"@react-hook/latest" "^1.0.2"
@ -2111,16 +2111,16 @@
resolved "https://registry.yarnpkg.com/@tauri-apps/api/-/api-2.0.0-beta.11.tgz#7ec6f6f007da4e55315270619448ce39fd11b4e3"
integrity sha512-wJRY+fBUm3KpqZDHMIz5HRv+1vlnvRJ/dFxiyY3NlINTx2qXqDou5qWYcP1CuZXsd39InWVPV3FAZvno/kGCkA==
"@tauri-apps/api@2.0.0-beta.12":
version "2.0.0-beta.12"
resolved "https://registry.yarnpkg.com/@tauri-apps/api/-/api-2.0.0-beta.12.tgz#0b552086e6382cfd5798537b304d00cbf42db7a1"
integrity sha512-77OvAnsExtiprnjQcvmDyZGfnIvMF/zVL5+8Vkl1R8o8E3iDtvEJZpbbH1F4dPtNa3gr4byp/5dm8hAa1+r3AA==
"@tauri-apps/api@2.0.0-beta.4":
version "2.0.0-beta.4"
resolved "https://registry.yarnpkg.com/@tauri-apps/api/-/api-2.0.0-beta.4.tgz#7688950f6e03f38b3bac73585f8f4cdd61be6aa6"
integrity sha512-Nxtj28NYUo5iwYkpYslxmOPkdI2WkELU2e3UH9nbJm9Ydki2CQwJVGQxx4EANtdZcMNsEsUzRqaDTvEUYH1l6w==
"@tauri-apps/api@2.0.0-beta.8":
version "2.0.0-beta.8"
resolved "https://registry.yarnpkg.com/@tauri-apps/api/-/api-2.0.0-beta.8.tgz#36ac5365360312928bdf593d561cd490a8185a48"
integrity sha512-fN5u+9HsSfhRaXGOdD2kPGbqDgyX+nkm1XF/4d/LNuM4WiNfvHjjRAqWQYBhQsg1aF9nsTPmSW+tmy+Yn5T5+A==
"@tauri-apps/cli-darwin-arm64@2.0.0-beta.13":
version "2.0.0-beta.13"
resolved "https://registry.yarnpkg.com/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.0.0-beta.13.tgz#4926b310f5c39f967753c1c6b9aa20916011ebb6"
@ -8185,16 +8185,7 @@ string-natural-compare@^3.0.1:
resolved "https://registry.yarnpkg.com/string-natural-compare/-/string-natural-compare-3.0.1.tgz#7a42d58474454963759e8e8b7ae63d71c1e7fdf4"
integrity sha512-n3sPwynL1nwKi3WJ6AIsClwBMa0zTi54fn2oLU6ndfTSIO05xaznjSf15PcBZU6FNWbmN5Q6cxT4V5hGvB4taw==
"string-width-cjs@npm:string-width@^4.2.0":
version "4.2.3"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
dependencies:
emoji-regex "^8.0.0"
is-fullwidth-code-point "^3.0.0"
strip-ansi "^6.0.1"
string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
version "4.2.3"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
@ -8267,14 +8258,7 @@ string_decoder@~1.1.1:
dependencies:
safe-buffer "~5.1.0"
"strip-ansi-cjs@npm:strip-ansi@^6.0.1":
version "6.0.1"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
dependencies:
ansi-regex "^5.0.1"
strip-ansi@^6.0.0, strip-ansi@^6.0.1:
"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1:
version "6.0.1"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
@ -8941,11 +8925,16 @@ vitest@^1.6.0:
vite-node "1.6.0"
why-is-node-running "^2.2.2"
vscode-jsonrpc@8.2.0, vscode-jsonrpc@^8.1.0:
vscode-jsonrpc@8.2.0:
version "8.2.0"
resolved "https://registry.yarnpkg.com/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz#f43dfa35fb51e763d17cd94dcca0c9458f35abf9"
integrity sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==
vscode-jsonrpc@^8.2.1:
version "8.2.1"
resolved "https://registry.yarnpkg.com/vscode-jsonrpc/-/vscode-jsonrpc-8.2.1.tgz#a322cc0f1d97f794ffd9c4cd2a898a0bde097f34"
integrity sha512-kdjOSJ2lLIn7r1rtrMbbNCHjyMPfRnowdKjBQ+mGq6NAW5QY2bEZC/khaC5OR8svbbjvLEaIXkOq45e2X9BIbQ==
vscode-languageserver-protocol@^3.17.5:
version "3.17.5"
resolved "https://registry.yarnpkg.com/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.5.tgz#864a8b8f390835572f4e13bd9f8313d0e3ac4bea"
@ -9251,7 +9240,7 @@ workerpool@6.2.1:
resolved "https://registry.yarnpkg.com/workerpool/-/workerpool-6.2.1.tgz#46fc150c17d826b86a008e5a4508656777e9c343"
integrity sha512-ILEIE97kDZvF9Wb9f6h5aXK4swSlKGUcOEGiIYb2OOu/IrDU9iwj0fD//SsA6E5ibwJxpEvhullJY4Sl4GcpAw==
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0":
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0:
version "7.0.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
@ -9269,15 +9258,6 @@ wrap-ansi@^6.2.0:
string-width "^4.1.0"
strip-ansi "^6.0.0"
wrap-ansi@^7.0.0:
version "7.0.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
dependencies:
ansi-styles "^4.0.0"
string-width "^4.1.0"
strip-ansi "^6.0.0"
wrap-ansi@^8.1.0:
version "8.1.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"
@ -9302,10 +9282,10 @@ ws@^7.0.0:
resolved "https://registry.yarnpkg.com/ws/-/ws-7.5.9.tgz#54fa7db29f4c7cec68b1ddd3a89de099942bb591"
integrity sha512-F+P9Jil7UiSKSkppIiD94dN07AwvFixvLIj1Og1Rl9GGMuNipJnV9JzjD6XuqmAeiswGvUmNLjr5cFuXwNS77Q==
ws@^8.16.0, ws@^8.8.0:
version "8.16.0"
resolved "https://registry.yarnpkg.com/ws/-/ws-8.16.0.tgz#d1cd774f36fbc07165066a60e40323eab6446fd4"
integrity sha512-HS0c//TP7Ina87TfiPUz1rQzMhHrl/SG2guqRcTOIUYD2q8uhUdNHZYJUaQ8aTGPzCh+c6oawMKW35nFl1dxyQ==
ws@^8.17.0, ws@^8.8.0:
version "8.17.0"
resolved "https://registry.yarnpkg.com/ws/-/ws-8.17.0.tgz#d145d18eca2ed25aaf791a183903f7be5e295fea"
integrity sha512-uJq6108EgZMAl20KagGkzCKfMEjxmKvZHG7Tlq0Z6nOky7YF7aq4mOx6xK8TJ/i1LeK4Qus7INktacctDgY8Ow==
"xstate-beta@npm:xstate@beta":
version "5.0.0-beta.54"