Compare commits

..

1 Commits

Author SHA1 Message Date
bf5287faab Bump execution plan crates 2024-03-01 23:52:01 -06:00
48 changed files with 1012 additions and 1346 deletions

View File

@ -85,6 +85,7 @@ jobs:
playwright-macos: playwright-macos:
timeout-minutes: 60 timeout-minutes: 60
runs-on: macos-14 runs-on: macos-14
needs: playwright-ubuntu
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4

1
.gitignore vendored
View File

@ -33,7 +33,6 @@ src/wasm-lib/bindings
src/wasm-lib/kcl/bindings src/wasm-lib/kcl/bindings
public/wasm_lib_bg.wasm public/wasm_lib_bg.wasm
src/wasm-lib/lcov.info src/wasm-lib/lcov.info
src/wasm-lib/grackle/test_json_output
e2e/playwright/playwright-secrets.env e2e/playwright/playwright-secrets.env
e2e/playwright/temp1.png e2e/playwright/temp1.png

View File

@ -4,7 +4,6 @@ import { getUtils } from './test-utils'
import waitOn from 'wait-on' import waitOn from 'wait-on'
import { Themes } from '../../src/lib/theme' import { Themes } from '../../src/lib/theme'
import { roundOff } from 'lib/utils' import { roundOff } from 'lib/utils'
import { platform } from 'node:os'
/* /*
debug helper: unfortunately we do rely on exact coord mouse clicks in a few places debug helper: unfortunately we do rely on exact coord mouse clicks in a few places
@ -135,126 +134,6 @@ test('Basic sketch', async ({ page }) => {
|> angledLine([180, segLen('seg01', %)], %)`) |> angledLine([180, segLen('seg01', %)], %)`)
}) })
test('Can moving camera', async ({ page, context }) => {
test.skip(process.platform === 'darwin', 'Can moving camera')
const u = getUtils(page)
await page.setViewportSize({ width: 1200, height: 500 })
await page.goto('/')
await u.waitForAuthSkipAppStart()
await u.openAndClearDebugPanel()
const camPos: [number, number, number] = [0, 85, 85]
const bakeInRetries = async (
mouseActions: any,
xyz: [number, number, number],
cnt = 0
) => {
// hack that we're implemented our own retry instead of using retries built into playwright.
// however each of these camera drags can be flaky, because of udp
// and so putting them together means only one needs to fail to make this test extra flaky.
// this way we can retry within the test
// 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)
await page.waitForTimeout(100)
// rotate
await u.closeDebugPanel()
await page.getByRole('button', { name: 'Start Sketch' }).click()
await page.waitForTimeout(100)
// const yo = page.getByTestId('cam-x-position').inputValue()
await u.doAndWaitForImageDiff(async () => {
await mouseActions()
await u.openAndClearDebugPanel()
await u.closeDebugPanel()
await page.waitForTimeout(100)
}, 300)
await u.openAndClearDebugPanel()
const vals = await Promise.all([
page.getByTestId('cam-x-position').inputValue(),
page.getByTestId('cam-y-position').inputValue(),
page.getByTestId('cam-z-position').inputValue(),
])
const xError = Math.abs(Number(vals[0]) + xyz[0])
const yError = Math.abs(Number(vals[1]) + xyz[1])
const zError = Math.abs(Number(vals[2]) + xyz[2])
let shouldRetry = false
if (xError > 5 || yError > 5 || zError > 5) {
if (cnt > 2) {
console.log('xVal', vals[0], 'xError', xError)
console.log('yVal', vals[1], 'yError', yError)
console.log('zVal', vals[2], 'zError', zError)
throw new Error('Camera position not as expected')
}
shouldRetry = true
}
await page.getByRole('button', { name: 'Exit Sketch' }).click()
await page.waitForTimeout(100)
if (shouldRetry) await bakeInRetries(mouseActions, xyz, cnt + 1)
}
await bakeInRetries(async () => {
await page.mouse.move(700, 200)
await page.mouse.down({ button: 'right' })
await page.mouse.move(600, 303)
await page.mouse.up({ button: 'right' })
}, [4, -10.5, -120])
await bakeInRetries(async () => {
await page.keyboard.down('Shift')
await page.mouse.move(600, 200)
await page.mouse.down({ button: 'right' })
await page.mouse.move(700, 200, { steps: 2 })
await page.mouse.up({ button: 'right' })
await page.keyboard.up('Shift')
}, [-10, -85, -85])
await u.updateCamPosition(camPos)
await u.clearCommandLogs()
await u.closeDebugPanel()
await page.getByRole('button', { name: 'Start Sketch' }).click()
await page.waitForTimeout(200)
// zoom
await u.doAndWaitForImageDiff(async () => {
await page.keyboard.down('Control')
await page.mouse.move(700, 400)
await page.mouse.down({ button: 'right' })
await page.mouse.move(700, 300)
await page.mouse.up({ button: 'right' })
await page.keyboard.up('Control')
await u.openDebugPanel()
await page.waitForTimeout(300)
await u.clearCommandLogs()
await u.closeDebugPanel()
}, 300)
// zoom with scroll
await u.openAndClearDebugPanel()
// TODO, it appears we don't get the cam setting back from the engine when the interaction is zoom into `backInRetries` once the information is sent back on zoom
// await expect(Math.abs(Number(await page.getByTestId('cam-x-position').inputValue()) + 12)).toBeLessThan(1.5)
// await expect(Math.abs(Number(await page.getByTestId('cam-y-position').inputValue()) - 85)).toBeLessThan(1.5)
// await expect(Math.abs(Number(await page.getByTestId('cam-z-position').inputValue()) - 85)).toBeLessThan(1.5)
await page.getByRole('button', { name: 'Exit Sketch' }).click()
await bakeInRetries(async () => {
await page.mouse.move(700, 400)
await page.mouse.wheel(0, -100)
}, [1, -94, -94])
})
test('if you write invalid kcl you get inlined errors', async ({ page }) => { test('if you write invalid kcl you get inlined errors', async ({ page }) => {
const u = getUtils(page) const u = getUtils(page)
await page.setViewportSize({ width: 1000, height: 500 }) await page.setViewportSize({ width: 1000, height: 500 })
@ -743,12 +622,12 @@ test('Command bar works and can change a setting', async ({ page }) => {
const themeOption = page.getByRole('option', { name: 'Set Theme' }) const themeOption = page.getByRole('option', { name: 'Set Theme' })
await expect(themeOption).toBeVisible() await expect(themeOption).toBeVisible()
await themeOption.click() await themeOption.click()
const themeInput = page.getByPlaceholder('system') const themeInput = page.getByPlaceholder('Select an option')
await expect(themeInput).toBeVisible() await expect(themeInput).toBeVisible()
await expect(themeInput).toBeFocused() await expect(themeInput).toBeFocused()
// Select dark theme // Select dark theme
await page.keyboard.press('ArrowDown') await page.keyboard.press('ArrowDown')
await page.keyboard.press('ArrowUp') await page.keyboard.press('ArrowDown')
await expect(page.getByRole('option', { name: Themes.Dark })).toHaveAttribute( await expect(page.getByRole('option', { name: Themes.Dark })).toHaveAttribute(
'data-headlessui-state', 'data-headlessui-state',
'active' 'active'
@ -1116,6 +995,7 @@ test('Deselecting line tool should mean nothing happens on click', async ({
}) => { }) => {
const u = getUtils(page) const u = getUtils(page)
await page.setViewportSize({ width: 1200, height: 500 }) await page.setViewportSize({ width: 1200, height: 500 })
const PUR = 400 / 37.5 //pixeltoUnitRatio
await page.goto('/') await page.goto('/')
await u.waitForAuthSkipAppStart() await u.waitForAuthSkipAppStart()
await u.openDebugPanel() await u.openDebugPanel()
@ -1173,160 +1053,3 @@ test('Deselecting line tool should mean nothing happens on click', async ({
await expect(page.locator('.cm-content')).not.toHaveText(previousCodeContent) await expect(page.locator('.cm-content')).not.toHaveText(previousCodeContent)
previousCodeContent = await page.locator('.cm-content').innerText() previousCodeContent = await page.locator('.cm-content').innerText()
}) })
test('Can edit segments by dragging their handles', async ({
page,
context,
}) => {
const u = getUtils(page)
await context.addInitScript(async () => {
localStorage.setItem(
'persistCode',
`const part001 = startSketchOn('-XZ')
|> startProfileAt([4.61, -14.01], %)
|> line([12.73, -0.09], %)
|> tangentialArcTo([24.95, -5.38], %)`
)
})
await page.setViewportSize({ width: 1200, height: 500 })
await page.goto('/')
await u.waitForAuthSkipAppStart()
await expect(
page.getByRole('button', { name: 'Start Sketch' })
).not.toBeDisabled()
const startPX = [652, 418]
const lineEndPX = [794, 416]
const arcEndPX = [893, 318]
const dragPX = 30
await page.getByText('startProfileAt([4.61, -14.01], %)').click()
await expect(page.getByRole('button', { name: 'Edit Sketch' })).toBeVisible()
await page.getByRole('button', { name: 'Edit Sketch' }).click()
await page.waitForTimeout(100)
let prevContent = await page.locator('.cm-content').innerText()
const step5 = { steps: 5 }
// drag startProfieAt handle
await page.mouse.move(startPX[0], startPX[1])
await page.mouse.down()
await page.mouse.move(startPX[0] + dragPX, startPX[1] - dragPX, step5)
await page.mouse.up()
await page.waitForTimeout(100)
await expect(page.locator('.cm-content')).not.toHaveText(prevContent)
prevContent = await page.locator('.cm-content').innerText()
// drag line handle
await page.mouse.move(lineEndPX[0] + dragPX, lineEndPX[1] - dragPX)
await page.mouse.down()
await page.mouse.move(
lineEndPX[0] + dragPX * 2,
lineEndPX[1] - dragPX * 2,
step5
)
await page.mouse.up()
await page.waitForTimeout(100)
await expect(page.locator('.cm-content')).not.toHaveText(prevContent)
prevContent = await page.locator('.cm-content').innerText()
// drag tangentialArcTo handle
await page.mouse.move(arcEndPX[0], arcEndPX[1])
await page.mouse.down()
await page.mouse.move(arcEndPX[0] + dragPX, arcEndPX[1] - dragPX, step5)
await page.mouse.up()
await page.waitForTimeout(100)
await expect(page.locator('.cm-content')).not.toHaveText(prevContent)
// expect the code to have changed
await expect(page.locator('.cm-content'))
.toHaveText(`const part001 = startSketchOn('-XZ')
|> startProfileAt([7.01, -11.79], %)
|> line([14.69, 2.73], %)
|> tangentialArcTo([27.6, -3.25], %)`)
})
test('Snap to close works (at any scale)', async ({ page }) => {
const u = getUtils(page)
await page.setViewportSize({ width: 1200, height: 500 })
await page.goto('/')
await u.waitForAuthSkipAppStart()
await u.openDebugPanel()
await expect(
page.getByRole('button', { name: 'Start Sketch' })
).not.toBeDisabled()
await expect(page.getByRole('button', { name: 'Start Sketch' })).toBeVisible()
const doSnapAtDifferentScales = async (
camPos: [number, number, number],
expectedCode: string
) => {
await u.clearCommandLogs()
await page.getByRole('button', { name: 'Start Sketch' }).click()
await page.waitForTimeout(100)
await u.openAndClearDebugPanel()
await u.updateCamPosition(camPos)
await u.closeDebugPanel()
// select a plane
await page.mouse.click(700, 200)
await expect(page.locator('.cm-content')).toHaveText(
`const part001 = startSketchOn('XZ')`
)
let prevContent = await page.locator('.cm-content').innerText()
const pointA = [700, 200]
const pointB = [900, 200]
const pointC = [900, 400]
// draw three lines
await page.mouse.click(pointA[0], pointA[1])
await page.waitForTimeout(100)
await expect(page.locator('.cm-content')).not.toHaveText(prevContent)
prevContent = await page.locator('.cm-content').innerText()
await page.mouse.click(pointB[0], pointB[1])
await page.waitForTimeout(100)
await expect(page.locator('.cm-content')).not.toHaveText(prevContent)
prevContent = await page.locator('.cm-content').innerText()
await page.mouse.click(pointC[0], pointC[1])
await page.waitForTimeout(100)
await expect(page.locator('.cm-content')).not.toHaveText(prevContent)
prevContent = await page.locator('.cm-content').innerText()
await page.mouse.move(pointA[0] - 12, pointA[1] + 12)
const pointNotQuiteA = [pointA[0] - 7, pointA[1] + 7]
await page.mouse.move(pointNotQuiteA[0], pointNotQuiteA[1], { steps: 10 })
await page.mouse.click(pointNotQuiteA[0], pointNotQuiteA[1])
await expect(page.locator('.cm-content')).not.toHaveText(prevContent)
prevContent = await page.locator('.cm-content').innerText()
await expect(page.locator('.cm-content')).toHaveText(expectedCode)
// exit sketch
await u.openAndClearDebugPanel()
await page.getByRole('button', { name: 'Exit Sketch' }).click()
await u.expectCmdLog('[data-message-type="execution-done"]')
await u.removeCurrentCode()
}
const codeTemplate = (
scale = 1,
fudge = 0
) => `const part001 = startSketchOn('XZ')
|> startProfileAt([${roundOff(scale * 87.68)}, ${roundOff(scale * 43.84)}], %)
|> line([${roundOff(scale * 175.36)}, 0], %)
|> line([0, -${roundOff(scale * 175.37) + fudge}], %)
|> close(%)`
await doSnapAtDifferentScales([0, 100, 100], codeTemplate(0.01, 0.01))
await doSnapAtDifferentScales([0, 10000, 10000], codeTemplate())
})

View File

@ -29,10 +29,98 @@ test.beforeEach(async ({ context, page }) => {
await page.emulateMedia({ reducedMotion: 'reduce' }) await page.emulateMedia({ reducedMotion: 'reduce' })
}) })
test.setTimeout(60_000) test.setTimeout(60000)
const commonPoints = {
startAt: '[26.38, -35.59]',
num1: 26.63,
num2: 53.01,
}
test('change camera, show planes', async ({ page, context }) => {
const u = getUtils(page)
await page.setViewportSize({ width: 1200, height: 500 })
await page.goto('/')
await u.waitForAuthSkipAppStart()
await u.openAndClearDebugPanel()
const camPos: [number, number, number] = [0, 85, 85]
await u.updateCamPosition(camPos)
// rotate
await u.closeDebugPanel()
await page.mouse.move(700, 200)
await page.mouse.down({ button: 'right' })
await page.mouse.move(600, 300)
await page.mouse.up({ button: 'right' })
await u.openDebugPanel()
await page.waitForTimeout(500)
await u.clearCommandLogs()
await page.getByRole('button', { name: 'Start Sketch' }).click()
await u.closeDebugPanel()
await expect(page).toHaveScreenshot({
maxDiffPixels: 100,
})
await u.openAndClearDebugPanel()
await page.getByRole('button', { name: 'Exit Sketch' }).click()
await u.updateCamPosition(camPos)
await u.clearCommandLogs()
await u.closeDebugPanel()
// pan
await page.keyboard.down('Shift')
await page.mouse.move(600, 200)
await page.mouse.down({ button: 'right' })
await page.mouse.move(700, 200)
await page.mouse.up({ button: 'right' })
await page.keyboard.up('Shift')
await u.openDebugPanel()
await page.waitForTimeout(300)
await u.clearCommandLogs()
await page.getByRole('button', { name: 'Start Sketch' }).click()
await u.closeDebugPanel()
await expect(page).toHaveScreenshot({
maxDiffPixels: 100,
})
await u.openAndClearDebugPanel()
await page.getByRole('button', { name: 'Exit Sketch' }).click()
await u.updateCamPosition(camPos)
await u.clearCommandLogs()
await u.closeDebugPanel()
// zoom
await page.keyboard.down('Control')
await page.mouse.move(700, 400)
await page.mouse.down({ button: 'right' })
await page.mouse.move(700, 300)
await page.mouse.up({ button: 'right' })
await page.keyboard.up('Control')
await u.openDebugPanel()
await page.waitForTimeout(300)
await u.clearCommandLogs()
await page.getByRole('button', { name: 'Start Sketch' }).click()
await u.closeDebugPanel()
await expect(page).toHaveScreenshot({
maxDiffPixels: 100,
})
})
test('exports of each format should work', async ({ page, context }) => { test('exports of each format should work', async ({ page, context }) => {
test.setTimeout(120_000)
// FYI this test doesn't work with only engine running locally // FYI this test doesn't work with only engine running locally
// And you will need to have the KittyCAD CLI installed // And you will need to have the KittyCAD CLI installed
const u = getUtils(page) const u = getUtils(page)
@ -91,6 +179,8 @@ const part001 = startSketchOn('-XZ')
await page.waitForTimeout(1000) await page.waitForTimeout(1000)
await u.clearAndCloseDebugPanel() await u.clearAndCloseDebugPanel()
await page.getByRole('button', { name: APP_NAME }).click()
interface Paths { interface Paths {
modelPath: string modelPath: string
imagePath: string imagePath: string
@ -99,21 +189,19 @@ const part001 = startSketchOn('-XZ')
const doExport = async ( const doExport = async (
output: Models['OutputFormat_type'] output: Models['OutputFormat_type']
): Promise<Paths> => { ): Promise<Paths> => {
await page.getByRole('button', { name: APP_NAME }).click() await page.getByRole('button', { name: 'Export Model' }).click()
await page.getByRole('button', { name: 'Export Part' }).click()
const exportSelect = page.getByTestId('export-type')
await exportSelect.selectOption({ label: output.type })
// Go through export via command bar
await page.getByRole('option', { name: output.type, exact: false }).click()
if ('storage' in output) { if ('storage' in output) {
await page.getByRole('button', { name: 'storage', exact: false }).click() const storageSelect = page.getByTestId('export-storage')
await page await storageSelect.selectOption({ label: output.storage })
.getByRole('option', { name: output.storage, exact: false })
.click()
} }
await page.getByRole('button', { name: 'Submit command' }).click()
// Handle download const downloadPromise = page.waitForEvent('download')
const download = await page.waitForEvent('download') await page.getByRole('button', { name: 'Export', exact: true }).click()
const download = await downloadPromise
const downloadLocationer = (extra = '', isImage = false) => const downloadLocationer = (extra = '', isImage = false) =>
`./e2e/playwright/export-snapshots/${output.type}-${ `./e2e/playwright/export-snapshots/${output.type}-${
'storage' in output ? output.storage : '' 'storage' in output ? output.storage : ''

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

View File

@ -1,23 +1,16 @@
import { expect, Page, errors } from '@playwright/test' import { expect, Page } from '@playwright/test'
import { EngineCommand } from '../../src/lang/std/engineConnection' import { EngineCommand } from '../../src/lang/std/engineConnection'
import fsp from 'fs/promises' import fsp from 'fs/promises'
import pixelMatch from 'pixelmatch' import pixelMatch from 'pixelmatch'
import { PNG } from 'pngjs' import { PNG } from 'pngjs'
async function waitForPageLoad(page: Page) { async function waitForPageLoad(page: Page) {
try { // wait for 'Loading stream...' spinner
// wait for 'Loading stream...' spinner await page.getByTestId('loading-stream').waitFor()
await page.getByTestId('loading-stream').waitFor() // wait for all spinners to be gone
// wait for all spinners to be gone await page.getByTestId('loading').waitFor({ state: 'detached' })
await page.getByTestId('loading').waitFor({ state: 'detached' })
await page.getByTestId('start-sketch').waitFor() await page.getByTestId('start-sketch').waitFor()
} catch (e) {
if (e instanceof errors.TimeoutError) {
console.log('Timeout while waiting for page load.')
} else {
throw e // re-throw the error if it is not a TimeoutError
}
}
} }
async function removeCurrentCode(page: Page) { async function removeCurrentCode(page: Page) {

View File

@ -18,7 +18,7 @@ export default defineConfig({
/* Retry on CI only */ /* Retry on CI only */
retries: process.env.CI ? 3 : 0, retries: process.env.CI ? 3 : 0,
/* Opt out of parallel tests on CI. */ /* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : 1, workers: process.env.CI ? 2 : 1,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */ /* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: 'html', reporter: 'html',
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */

View File

@ -1,34 +1,16 @@
import re
import os
import requests import requests
import os
webhook_url = os.getenv('DISCORD_WEBHOOK_URL') webhook_url = os.getenv('DISCORD_WEBHOOK_URL')
release_version = os.getenv('RELEASE_VERSION') release_version = os.getenv('RELEASE_VERSION')
release_body = os.getenv('RELEASE_BODY') release_body = os.getenv('RELEASE_BODY')
# Regular expression to match URLs # message to send to Discord
url_pattern = r'(http[s]?://(?:[a-zA-Z]|[0-9]|[$-_@.&+]|[!*\\(\\),]|(?:%[0-9a-fA-F][0-9a-fA-F]))+)'
# Function to encase URLs in <>
def encase_urls_with_angle_brackets(match):
url = match.group(0)
return f'<{url}>'
# Replace all URLs in the release_body with their <> enclosed version
modified_release_body = re.sub(url_pattern, encase_urls_with_angle_brackets, release_body)
# Ensure the modified_release_body does not exceed Discord's character limit
max_length = 500 # Adjust as needed
if len(modified_release_body) > max_length:
modified_release_body = modified_release_body[:max_length].rsplit(' ', 1)[0] # Avoid cutting off in the middle of a word
modified_release_body += "... for full changelog, check out the link above."
# Message to send to Discord
data = { data = {
"content": "content":
f''' f'''
**{release_version}** is now available! Check out the latest features and improvements here: <https://zoo.dev/modeling-app/download> **{release_version}** is now available! Check out the latest features and improvements here: https://zoo.dev/modeling-app/download
{modified_release_body} {release_body}
''', ''',
"username": "Modeling App Release Updates", "username": "Modeling App Release Updates",
"avatar_url": "https://raw.githubusercontent.com/KittyCAD/modeling-app/main/public/discord-avatar.png" "avatar_url": "https://raw.githubusercontent.com/KittyCAD/modeling-app/main/public/discord-avatar.png"
@ -41,7 +23,4 @@ response = requests.post(webhook_url, json=data)
if response.status_code == 204: if response.status_code == 204:
print("Successfully sent the message to Discord.") print("Successfully sent the message to Discord.")
else: else:
print(f"Failed to send the message to Discord. Status code: {response.status_code}, Response: {response.text}") print("Failed to send the message to Discord.")
print(modified_release_body)
print(data["content"])

View File

@ -7,6 +7,7 @@ use std::io::Read;
use anyhow::Result; use anyhow::Result;
use oauth2::TokenResponse; use oauth2::TokenResponse;
use std::process::Command;
use tauri::{InvokeError, Manager}; use tauri::{InvokeError, Manager};
const DEFAULT_HOST: &str = "https://api.kittycad.io"; const DEFAULT_HOST: &str = "https://api.kittycad.io";

View File

@ -3,8 +3,15 @@ import {
createBrowserRouter, createBrowserRouter,
Outlet, Outlet,
redirect, redirect,
useLocation,
RouterProvider, RouterProvider,
} from 'react-router-dom' } from 'react-router-dom'
import {
matchRoutes,
createRoutesFromChildren,
useNavigationType,
} from 'react-router'
import { useEffect } from 'react'
import { ErrorPage } from './components/ErrorPage' import { ErrorPage } from './components/ErrorPage'
import { Settings } from './routes/Settings' import { Settings } from './routes/Settings'
import Onboarding, { onboardingRoutes } from './routes/Onboarding' import Onboarding, { onboardingRoutes } from './routes/Onboarding'

View File

@ -209,7 +209,6 @@ export class CameraControls {
this.camera.zoom = camProps.zoom || 1 this.camera.zoom = camProps.zoom || 1
} }
this.camera.updateProjectionMatrix() this.camera.updateProjectionMatrix()
console.log('doing this thing', camProps)
this.update(true) this.update(true)
} }
@ -570,7 +569,7 @@ export class CameraControls {
update = (forceUpdate = false) => { update = (forceUpdate = false) => {
// If there are any changes that need to be applied to the camera, apply them here. // If there are any changes that need to be applied to the camera, apply them here.
let didChange = false let didChange = forceUpdate
if (this.pendingRotation) { if (this.pendingRotation) {
this.rotateCamera(this.pendingRotation.x, this.pendingRotation.y) this.rotateCamera(this.pendingRotation.x, this.pendingRotation.y)
this.pendingRotation = null // Clear the pending rotation after applying it this.pendingRotation = null // Clear the pending rotation after applying it
@ -622,8 +621,8 @@ export class CameraControls {
// Update the camera's matrices // Update the camera's matrices
this.camera.updateMatrixWorld() this.camera.updateMatrixWorld()
if (didChange || forceUpdate) { if (didChange) {
this.onCameraChange(forceUpdate) this.onCameraChange()
} }
// damping would be implemented here in update if we choose to add it. // damping would be implemented here in update if we choose to add it.
@ -899,7 +898,7 @@ export class CameraControls {
this.reactCameraPropertiesCallback(a) this.reactCameraPropertiesCallback(a)
}, 200) }, 200)
onCameraChange = (forceUpdate = false) => { onCameraChange = () => {
const distance = this.target.distanceTo(this.camera.position) const distance = this.target.distanceTo(this.camera.position)
if (this.camera.far / 2.1 < distance || this.camera.far / 1.9 > distance) { if (this.camera.far / 2.1 < distance || this.camera.far / 1.9 > distance) {
this.camera.far = distance * 2 this.camera.far = distance * 2
@ -907,7 +906,7 @@ export class CameraControls {
this.camera.updateProjectionMatrix() this.camera.updateProjectionMatrix()
} }
if (this.syncDirection === 'clientToEngine' || forceUpdate) if (this.syncDirection === 'clientToEngine')
throttledUpdateEngineCamera({ throttledUpdateEngineCamera({
quaternion: this.camera.quaternion, quaternion: this.camera.quaternion,
position: this.camera.position, position: this.camera.position,

View File

@ -3,13 +3,10 @@ import {
DoubleSide, DoubleSide,
ExtrudeGeometry, ExtrudeGeometry,
Group, Group,
Intersection,
LineCurve3, LineCurve3,
Matrix4, Matrix4,
Mesh, Mesh,
MeshBasicMaterial, MeshBasicMaterial,
Object3D,
Object3DEventMap,
OrthographicCamera, OrthographicCamera,
PerspectiveCamera, PerspectiveCamera,
PlaneGeometry, PlaneGeometry,
@ -27,7 +24,6 @@ import {
defaultPlaneColor, defaultPlaneColor,
getSceneScale, getSceneScale,
INTERSECTION_PLANE_LAYER, INTERSECTION_PLANE_LAYER,
OnMouseEnterLeaveArgs,
RAYCASTABLE_PLANE, RAYCASTABLE_PLANE,
sceneInfra, sceneInfra,
SKETCH_GROUP_SEGMENTS, SKETCH_GROUP_SEGMENTS,
@ -68,6 +64,7 @@ import {
addCloseToPipe, addCloseToPipe,
addNewSketchLn, addNewSketchLn,
changeSketchArguments, changeSketchArguments,
compareVec2Epsilon2,
updateStartProfileAtArgs, updateStartProfileAtArgs,
} from 'lang/std/sketch' } from 'lang/std/sketch'
import { isReducedMotion, throttle } from 'lib/utils' import { isReducedMotion, throttle } from 'lib/utils'
@ -303,8 +300,8 @@ class SceneEntities {
: perspScale(sceneInfra.camControls.camera, dummy)) / : perspScale(sceneInfra.camControls.camera, dummy)) /
sceneInfra._baseUnitMultiplier sceneInfra._baseUnitMultiplier
const segPathToNode = getNodePathFromSourceRange( let segPathToNode = getNodePathFromSourceRange(
kclManager.ast, draftSegment ? truncatedAst : kclManager.ast,
sketchGroup.start.__geoMeta.sourceRange sketchGroup.start.__geoMeta.sourceRange
) )
const _profileStart = profileStart({ const _profileStart = profileStart({
@ -322,31 +319,12 @@ class SceneEntities {
sketchGroup.value.forEach((segment, index) => { sketchGroup.value.forEach((segment, index) => {
let segPathToNode = getNodePathFromSourceRange( let segPathToNode = getNodePathFromSourceRange(
kclManager.ast, draftSegment ? truncatedAst : kclManager.ast,
segment.__geoMeta.sourceRange segment.__geoMeta.sourceRange
) )
if (draftSegment && (sketchGroup.value[index - 1] || sketchGroup.start)) {
const previousSegment =
sketchGroup.value[index - 1] || sketchGroup.start
const previousSegmentPathToNode = getNodePathFromSourceRange(
kclManager.ast,
previousSegment.__geoMeta.sourceRange
)
const bodyIndex = previousSegmentPathToNode[1][0]
segPathToNode = getNodePathFromSourceRange(
truncatedAst,
segment.__geoMeta.sourceRange
)
segPathToNode[1][0] = bodyIndex
}
const isDraftSegment = const isDraftSegment =
draftSegment && index === sketchGroup.value.length - 1 draftSegment && index === sketchGroup.value.length - 1
let seg let seg
const callExpName = getNodeFromPath<CallExpression>(
kclManager.ast,
segPathToNode,
'CallExpression'
)?.node?.callee?.name
if (segment.type === 'TangentialArcTo') { if (segment.type === 'TangentialArcTo') {
seg = tangentialArcToSegment({ seg = tangentialArcToSegment({
prevSegment: sketchGroup.value[index - 1], prevSegment: sketchGroup.value[index - 1],
@ -365,7 +343,6 @@ class SceneEntities {
pathToNode: segPathToNode, pathToNode: segPathToNode,
isDraftSegment, isDraftSegment,
scale: factor, scale: factor,
callExpName,
}) })
} }
seg.layers.set(SKETCH_LAYER) seg.layers.set(SKETCH_LAYER)
@ -387,19 +364,17 @@ class SceneEntities {
this.scene.add(group) this.scene.add(group)
if (!draftSegment) { if (!draftSegment) {
sceneInfra.setCallbacks({ sceneInfra.setCallbacks({
onDrag: ({ selected, intersectionPoint, mouseEvent, intersects }) => { onDrag: (args) => {
if (mouseEvent.which !== 1) return if (args.event.which !== 1) return
this.onDragSegment({ this.onDragSegment({
object: selected, ...args,
intersection2d: intersectionPoint.twoD,
intersects,
sketchPathToNode, sketchPathToNode,
}) })
}, },
onMove: () => {}, onMove: () => {},
onClick: (args) => { onClick: (args) => {
if (args?.mouseEvent.which !== 1) return if (args?.event.which !== 1) return
if (!args || !args.selected) { if (!args || !args.object) {
sceneInfra.modelingSend({ sceneInfra.modelingSend({
type: 'Set selection', type: 'Set selection',
data: { data: {
@ -408,32 +383,77 @@ class SceneEntities {
}) })
return return
} }
const { selected } = args const { object } = args
const event = getEventForSegmentSelection(selected) const event = getEventForSegmentSelection(object)
if (!event) return if (!event) return
sceneInfra.modelingSend(event) sceneInfra.modelingSend(event)
}, },
...mouseEnterLeaveCallbacks(), onMouseEnter: ({ object }) => {
// TODO change the color of the segment to yellow?
// Give a few pixels grace around each of the segments
// for hover.
if ([X_AXIS, Y_AXIS].includes(object?.userData?.type)) {
const obj = object as Mesh
const mat = obj.material as MeshBasicMaterial
mat.color.set(obj.userData.baseColor)
mat.color.offsetHSL(0, 0, 0.5)
}
const parent = getParentGroup(object, [
STRAIGHT_SEGMENT,
TANGENTIAL_ARC_TO_SEGMENT,
PROFILE_START,
])
if (parent?.userData?.pathToNode) {
const updatedAst = parse(recast(kclManager.ast))
const node = getNodeFromPath<CallExpression>(
updatedAst,
parent.userData.pathToNode,
'CallExpression'
).node
sceneInfra.highlightCallback([node.start, node.end])
const yellow = 0xffff00
colorSegment(object, yellow)
return
}
sceneInfra.highlightCallback([0, 0])
},
onMouseLeave: ({ object }) => {
sceneInfra.highlightCallback([0, 0])
const parent = getParentGroup(object)
const isSelected = parent?.userData?.isSelected
colorSegment(object, isSelected ? 0x0000ff : 0xffffff)
if ([X_AXIS, Y_AXIS].includes(object?.userData?.type)) {
const obj = object as Mesh
const mat = obj.material as MeshBasicMaterial
mat.color.set(obj.userData.baseColor)
if (obj.userData.isSelected) mat.color.offsetHSL(0, 0, 0.2)
}
},
}) })
} else { } else {
sceneInfra.setCallbacks({ sceneInfra.setCallbacks({
onDrag: () => {},
onClick: async (args) => { onClick: async (args) => {
if (!args) return if (!args) return
if (args.mouseEvent.which !== 1) return if (args.event.which !== 1) return
const { intersectionPoint } = args const { intersection2d } = args
let intersection2d = intersectionPoint?.twoD if (!intersection2d) return
const profileStart = args.intersects
.map(({ object }) => getParentGroup(object, [PROFILE_START]))
.find((a) => a?.name === PROFILE_START)
const firstSeg = sketchGroup.value[0]
const isClosingSketch = compareVec2Epsilon2(
firstSeg.from,
[intersection2d.x, intersection2d.y],
0.5
)
let modifiedAst let modifiedAst
if (profileStart) { if (isClosingSketch) {
// TODO close needs a better UX
modifiedAst = addCloseToPipe({ modifiedAst = addCloseToPipe({
node: kclManager.ast, node: kclManager.ast,
programMemory: kclManager.programMemory, programMemory: kclManager.programMemory,
pathToNode: sketchPathToNode, pathToNode: sketchPathToNode,
}) })
} else if (intersection2d) { } else {
const lastSegment = sketchGroup.value.slice(-1)[0] const lastSegment = sketchGroup.value.slice(-1)[0]
modifiedAst = addNewSketchLn({ modifiedAst = addNewSketchLn({
node: kclManager.ast, node: kclManager.ast,
@ -446,9 +466,6 @@ class SceneEntities {
: 'line', : 'line',
pathToNode: sketchPathToNode, pathToNode: sketchPathToNode,
}).modifiedAst }).modifiedAst
} else {
// return early as we didn't modify the ast
return
} }
kclManager.executeAstMock(modifiedAst, { updates: 'code' }) kclManager.executeAstMock(modifiedAst, { updates: 'code' })
@ -457,9 +474,8 @@ class SceneEntities {
}, },
onMove: (args) => { onMove: (args) => {
this.onDragSegment({ this.onDragSegment({
intersection2d: args.intersectionPoint.twoD, ...args,
object: Object.values(this.activeSegments).slice(-1)[0], object: Object.values(this.activeSegments).slice(-1)[0],
intersects: args.intersects,
sketchPathToNode, sketchPathToNode,
draftInfo: { draftInfo: {
draftSegment, draftSegment,
@ -469,7 +485,6 @@ class SceneEntities {
}, },
}) })
}, },
...mouseEnterLeaveCallbacks(),
}) })
} }
sceneInfra.camControls.enableRotate = false sceneInfra.camControls.enableRotate = false
@ -506,15 +521,17 @@ class SceneEntities {
) )
onDragSegment({ onDragSegment({
object, object,
intersection2d: _intersection2d, event,
intersectPoint,
intersection2d,
sketchPathToNode, sketchPathToNode,
draftInfo, draftInfo,
intersects,
}: { }: {
object: any object: any
event: any
intersectPoint: Vector3
intersection2d: Vector2 intersection2d: Vector2
sketchPathToNode: PathToNode sketchPathToNode: PathToNode
intersects: Intersection<Object3D<Object3DEventMap>>[]
draftInfo?: { draftInfo?: {
draftSegment: DraftSegment draftSegment: DraftSegment
truncatedAst: Program truncatedAst: Program
@ -522,15 +539,6 @@ class SceneEntities {
variableDeclarationName: string variableDeclarationName: string
} }
}) { }) {
const profileStart =
draftInfo &&
intersects
.map(({ object }) => getParentGroup(object, [PROFILE_START]))
.find((a) => a?.name === PROFILE_START)
const intersection2d = profileStart
? new Vector2(profileStart.position.x, profileStart.position.y)
: _intersection2d
const group = getParentGroup(object, [ const group = getParentGroup(object, [
STRAIGHT_SEGMENT, STRAIGHT_SEGMENT,
TANGENTIAL_ARC_TO_SEGMENT, TANGENTIAL_ARC_TO_SEGMENT,
@ -745,18 +753,16 @@ class SceneEntities {
shape.lineTo(0, 0.08 * scale) // The width of the line shape.lineTo(0, 0.08 * scale) // The width of the line
const arrowGroup = group.getObjectByName(ARROWHEAD) as Group const arrowGroup = group.getObjectByName(ARROWHEAD) as Group
if (arrowGroup) { arrowGroup.position.set(to[0], to[1], 0)
arrowGroup.position.set(to[0], to[1], 0)
const dir = new Vector3() const dir = new Vector3()
.subVectors( .subVectors(
new Vector3(to[0], to[1], 0), new Vector3(to[0], to[1], 0),
new Vector3(from[0], from[1], 0) new Vector3(from[0], from[1], 0)
) )
.normalize() .normalize()
arrowGroup.quaternion.setFromUnitVectors(new Vector3(0, 1, 0), dir) arrowGroup.quaternion.setFromUnitVectors(new Vector3(0, 1, 0), dir)
arrowGroup.scale.set(scale, scale, scale) arrowGroup.scale.set(scale, scale, scale)
}
const straightSegmentBody = group.children.find( const straightSegmentBody = group.children.find(
(child) => child.userData.type === STRAIGHT_SEGMENT_BODY (child) => child.userData.type === STRAIGHT_SEGMENT_BODY
@ -841,24 +847,22 @@ class SceneEntities {
} }
setupDefaultPlaneHover() { setupDefaultPlaneHover() {
sceneInfra.setCallbacks({ sceneInfra.setCallbacks({
onMouseEnter: ({ selected }) => { onMouseEnter: ({ object }) => {
if (!(selected instanceof Mesh && selected.parent)) return if (object.parent.userData.type !== DEFAULT_PLANES) return
if (selected.parent.userData.type !== DEFAULT_PLANES) return const type: DefaultPlane = object.userData.type
const type: DefaultPlane = selected.userData.type object.material.color = defaultPlaneColor(type, 0.5, 1)
selected.material.color = defaultPlaneColor(type, 0.5, 1)
}, },
onMouseLeave: ({ selected }) => { onMouseLeave: ({ object }) => {
if (!(selected instanceof Mesh && selected.parent)) return if (object.parent.userData.type !== DEFAULT_PLANES) return
if (selected.parent.userData.type !== DEFAULT_PLANES) return const type: DefaultPlane = object.userData.type
const type: DefaultPlane = selected.userData.type object.material.color = defaultPlaneColor(type)
selected.material.color = defaultPlaneColor(type)
}, },
onClick: (args) => { onClick: (args) => {
if (!args || !args.intersects?.[0]) return if (!args || !args.object) return
if (args.mouseEvent.which !== 1) return if (args.event.which !== 1) return
const { intersects } = args const { intersection } = args
const type = intersects?.[0].object.name || '' const type = intersection.object.name || ''
const posNorm = Number(intersects?.[0]?.normal?.z) > 0 const posNorm = Number(intersection.normal?.z) > 0
let planeString: DefaultPlaneStr = posNorm ? 'XY' : '-XY' let planeString: DefaultPlaneStr = posNorm ? 'XY' : '-XY'
let normal: [number, number, number] = posNorm ? [0, 0, 1] : [0, 0, -1] let normal: [number, number, number] = posNorm ? [0, 0, 1] : [0, 0, -1]
if (type === YZ_PLANE) { if (type === YZ_PLANE) {
@ -1088,53 +1092,3 @@ function massageFormats(a: any): Vector3 {
? new Vector3(a[0], a[1], a[2]) ? new Vector3(a[0], a[1], a[2])
: new Vector3(a.x, a.y, a.z) : new Vector3(a.x, a.y, a.z)
} }
function mouseEnterLeaveCallbacks() {
return {
onMouseEnter: ({ selected }: OnMouseEnterLeaveArgs) => {
if ([X_AXIS, Y_AXIS].includes(selected?.userData?.type)) {
const obj = selected as Mesh
const mat = obj.material as MeshBasicMaterial
mat.color.set(obj.userData.baseColor)
mat.color.offsetHSL(0, 0, 0.5)
}
const parent = getParentGroup(selected, [
STRAIGHT_SEGMENT,
TANGENTIAL_ARC_TO_SEGMENT,
PROFILE_START,
])
if (parent?.userData?.pathToNode) {
const updatedAst = parse(recast(kclManager.ast))
const node = getNodeFromPath<CallExpression>(
updatedAst,
parent.userData.pathToNode,
'CallExpression'
).node
sceneInfra.highlightCallback([node.start, node.end])
const yellow = 0xffff00
colorSegment(selected, yellow)
return
}
sceneInfra.highlightCallback([0, 0])
},
onMouseLeave: ({ selected }: OnMouseEnterLeaveArgs) => {
sceneInfra.highlightCallback([0, 0])
const parent = getParentGroup(selected, [
STRAIGHT_SEGMENT,
TANGENTIAL_ARC_TO_SEGMENT,
PROFILE_START,
])
const isSelected = parent?.userData?.isSelected
colorSegment(
selected,
isSelected ? 0x0000ff : parent?.userData?.baseColor || 0xffffff
)
if ([X_AXIS, Y_AXIS].includes(selected?.userData?.type)) {
const obj = selected as Mesh
const mat = obj.material as MeshBasicMaterial
mat.color.set(obj.userData.baseColor)
if (obj.userData.isSelected) mat.color.offsetHSL(0, 0, 0.2)
}
},
}
}

View File

@ -19,7 +19,7 @@ import {
Object3D, Object3D,
Object3DEventMap, Object3DEventMap,
} from 'three' } from 'three'
import { compareVec2Epsilon2 } from 'lang/std/sketch' import { Coords2d, compareVec2Epsilon2 } from 'lang/std/sketch'
import { useModelingContext } from 'hooks/useModelingContext' import { useModelingContext } from 'hooks/useModelingContext'
import * as TWEEN from '@tweenjs/tween.js' import * as TWEEN from '@tweenjs/tween.js'
import { SourceRange } from 'lang/wasm' import { SourceRange } from 'lang/wasm'
@ -48,36 +48,31 @@ export const AXIS_GROUP = 'axisGroup'
export const SKETCH_GROUP_SEGMENTS = 'sketch-group-segments' export const SKETCH_GROUP_SEGMENTS = 'sketch-group-segments'
export const ARROWHEAD = 'arrowhead' export const ARROWHEAD = 'arrowhead'
export interface OnMouseEnterLeaveArgs { interface BaseCallbackArgs2 {
selected: Object3D<Object3DEventMap> object: any
mouseEvent: MouseEvent event: any
}
interface BaseCallbackArgs {
event: any
}
interface OnDragCallbackArgs extends BaseCallbackArgs {
object: any
intersection2d: Vector2
intersectPoint: Vector3
intersection: Intersection<Object3D<Object3DEventMap>>
}
interface OnClickCallbackArgs extends BaseCallbackArgs {
intersection2d?: Vector2
intersectPoint: Vector3
intersection: Intersection<Object3D<Object3DEventMap>>
object?: any
} }
interface OnDragCallbackArgs extends OnMouseEnterLeaveArgs { interface onMoveCallbackArgs {
intersectionPoint: { event: any
twoD: Vector2 intersection2d: Vector2
threeD: Vector3 intersectPoint: Vector3
} intersection: Intersection<Object3D<Object3DEventMap>>
intersects: Intersection<Object3D<Object3DEventMap>>[]
}
interface OnClickCallbackArgs {
mouseEvent: MouseEvent
intersectionPoint?: {
twoD: Vector2
threeD: Vector3
}
intersects: Intersection<Object3D<Object3DEventMap>>[]
selected?: Object3D<Object3DEventMap>
}
interface OnMoveCallbackArgs {
mouseEvent: MouseEvent
intersectionPoint: {
twoD: Vector2
threeD: Vector3
}
intersects: Intersection<Object3D<Object3DEventMap>>[]
selected?: Object3D<Object3DEventMap>
} }
// This singleton class is responsible for all of the under the hood setup for the client side scene. // This singleton class is responsible for all of the under the hood setup for the client side scene.
@ -95,16 +90,16 @@ class SceneInfra {
_baseUnit: BaseUnit = 'mm' _baseUnit: BaseUnit = 'mm'
_baseUnitMultiplier = 1 _baseUnitMultiplier = 1
onDragCallback: (arg: OnDragCallbackArgs) => void = () => {} onDragCallback: (arg: OnDragCallbackArgs) => void = () => {}
onMoveCallback: (arg: OnMoveCallbackArgs) => void = () => {} onMoveCallback: (arg: onMoveCallbackArgs) => void = () => {}
onClickCallback: (arg?: OnClickCallbackArgs) => void = () => {} onClickCallback: (arg?: OnClickCallbackArgs) => void = () => {}
onMouseEnter: (arg: OnMouseEnterLeaveArgs) => void = () => {} onMouseEnter: (arg: BaseCallbackArgs2) => void = () => {}
onMouseLeave: (arg: OnMouseEnterLeaveArgs) => void = () => {} onMouseLeave: (arg: BaseCallbackArgs2) => void = () => {}
setCallbacks = (callbacks: { setCallbacks = (callbacks: {
onDrag?: (arg: OnDragCallbackArgs) => void onDrag?: (arg: OnDragCallbackArgs) => void
onMove?: (arg: OnMoveCallbackArgs) => void onMove?: (arg: onMoveCallbackArgs) => void
onClick?: (arg?: OnClickCallbackArgs) => void onClick?: (arg?: OnClickCallbackArgs) => void
onMouseEnter?: (arg: OnMouseEnterLeaveArgs) => void onMouseEnter?: (arg: BaseCallbackArgs2) => void
onMouseLeave?: (arg: OnMouseEnterLeaveArgs) => void onMouseLeave?: (arg: BaseCallbackArgs2) => void
}) => { }) => {
this.onDragCallback = callbacks.onDrag || this.onDragCallback this.onDragCallback = callbacks.onDrag || this.onDragCallback
this.onMoveCallback = callbacks.onMove || this.onMoveCallback this.onMoveCallback = callbacks.onMove || this.onMoveCallback
@ -147,9 +142,10 @@ class SceneInfra {
currentMouseVector = new Vector2() currentMouseVector = new Vector2()
selected: { selected: {
mouseDownVector: Vector2 mouseDownVector: Vector2
object: Object3D<Object3DEventMap> object: any
hasBeenDragged: boolean hasBeenDragged: boolean
} | null = null } | null = null
selectedObject: null | any = null
mouseDownVector: null | Vector2 = null mouseDownVector: null | Vector2 = null
constructor() { constructor() {
@ -246,8 +242,8 @@ class SceneInfra {
// Dispose of any other resources like geometries, materials, textures // Dispose of any other resources like geometries, materials, textures
} }
getPlaneIntersectPoint = (): { getPlaneIntersectPoint = (): {
twoD?: Vector2 intersection2d?: Vector2
threeD?: Vector3 intersectPoint: Vector3
intersection: Intersection<Object3D<Object3DEventMap>> intersection: Intersection<Object3D<Object3DEventMap>>
} | null => { } | null => {
this.planeRaycaster.setFromCamera( this.planeRaycaster.setFromCamera(
@ -258,11 +254,23 @@ class SceneInfra {
this.scene.children, this.scene.children,
true true
) )
const recastablePlaneIntersect = planeIntersects.find( if (
(intersect) => intersect.object.name === RAYCASTABLE_PLANE planeIntersects.length > 0 &&
planeIntersects[0].object.userData.type !== RAYCASTABLE_PLANE
) {
const intersect = planeIntersects[0]
return {
intersectPoint: intersect.point,
intersection: intersect,
}
}
if (
!(
planeIntersects.length > 0 &&
planeIntersects[0].object.userData.type === RAYCASTABLE_PLANE
)
) )
if (!planeIntersects.length) return null return null
if (!recastablePlaneIntersect) return { intersection: planeIntersects[0] }
const planePosition = planeIntersects[0].object.position const planePosition = planeIntersects[0].object.position
const inversePlaneQuaternion = planeIntersects[0].object.quaternion const inversePlaneQuaternion = planeIntersects[0].object.quaternion
.clone() .clone()
@ -277,21 +285,19 @@ class SceneInfra {
} }
return { return {
twoD: new Vector2( intersection2d: new Vector2(
transformedPoint.x / this._baseUnitMultiplier, transformedPoint.x / this._baseUnitMultiplier,
transformedPoint.y / this._baseUnitMultiplier transformedPoint.y / this._baseUnitMultiplier
), // z should be 0 ), // z should be 0
threeD: intersectPoint.divideScalar(this._baseUnitMultiplier), intersectPoint: intersectPoint.divideScalar(this._baseUnitMultiplier),
intersection: planeIntersects[0], intersection: planeIntersects[0],
} }
} }
onMouseMove = (mouseEvent: MouseEvent) => { onMouseMove = (event: MouseEvent) => {
this.currentMouseVector.x = (mouseEvent.clientX / window.innerWidth) * 2 - 1 this.currentMouseVector.x = (event.clientX / window.innerWidth) * 2 - 1
this.currentMouseVector.y = this.currentMouseVector.y = -(event.clientY / window.innerHeight) * 2 + 1
-(mouseEvent.clientY / window.innerHeight) * 2 + 1
const planeIntersectPoint = this.getPlaneIntersectPoint() const planeIntersectPoint = this.getPlaneIntersectPoint()
const intersects = this.raycastRing()
if (this.selected) { if (this.selected) {
const hasBeenDragged = !compareVec2Epsilon2( const hasBeenDragged = !compareVec2Epsilon2(
@ -307,56 +313,47 @@ class SceneInfra {
if ( if (
hasBeenDragged && hasBeenDragged &&
planeIntersectPoint && planeIntersectPoint &&
planeIntersectPoint.twoD && planeIntersectPoint.intersection2d
planeIntersectPoint.threeD
) { ) {
// // console.log('onDrag', this.selected) // // console.log('onDrag', this.selected)
this.onDragCallback({ this.onDragCallback({
mouseEvent, object: this.selected.object,
intersectionPoint: { event,
twoD: planeIntersectPoint.twoD, intersection2d: planeIntersectPoint.intersection2d,
threeD: planeIntersectPoint.threeD, ...planeIntersectPoint,
},
intersects,
selected: this.selected.object,
}) })
} }
} else if ( } else if (planeIntersectPoint && planeIntersectPoint.intersection2d) {
planeIntersectPoint &&
planeIntersectPoint.twoD &&
planeIntersectPoint.threeD
) {
this.onMoveCallback({ this.onMoveCallback({
mouseEvent, event,
intersectionPoint: { intersection2d: planeIntersectPoint.intersection2d,
twoD: planeIntersectPoint.twoD, ...planeIntersectPoint,
threeD: planeIntersectPoint.threeD,
},
intersects,
}) })
} }
if (intersects[0]) { const intersect = this.raycastRing()
const firstIntersectObject = intersects[0].object
if (intersect) {
const firstIntersectObject = intersect.object
if (this.hoveredObject !== firstIntersectObject) { if (this.hoveredObject !== firstIntersectObject) {
if (this.hoveredObject) { if (this.hoveredObject) {
this.onMouseLeave({ this.onMouseLeave({
selected: this.hoveredObject, object: this.hoveredObject,
mouseEvent: mouseEvent, event,
}) })
} }
this.hoveredObject = firstIntersectObject this.hoveredObject = firstIntersectObject
this.onMouseEnter({ this.onMouseEnter({
selected: this.hoveredObject, object: this.hoveredObject,
mouseEvent: mouseEvent, event,
}) })
} }
} else { } else {
if (this.hoveredObject) { if (this.hoveredObject) {
this.onMouseLeave({ this.onMouseLeave({
selected: this.hoveredObject, object: this.hoveredObject,
mouseEvent: mouseEvent, event,
}) })
this.hoveredObject = null this.hoveredObject = null
} }
@ -366,38 +363,41 @@ class SceneInfra {
raycastRing = ( raycastRing = (
pixelRadius = 8, pixelRadius = 8,
rayRingCount = 32 rayRingCount = 32
): Intersection<Object3D<Object3DEventMap>>[] => { ): Intersection<Object3D<Object3DEventMap>> | undefined => {
const mouseDownVector = this.currentMouseVector.clone() const mouseDownVector = this.currentMouseVector.clone()
const intersectionsMap = new Map< let closestIntersection:
Object3D, | Intersection<Object3D<Object3DEventMap>>
Intersection<Object3D<Object3DEventMap>> | undefined = undefined
>() let closestDistance = Infinity
const updateIntersectionsMap = ( const updateClosestIntersection = (
intersections: Intersection<Object3D<Object3DEventMap>>[] intersections: Intersection<Object3D<Object3DEventMap>>[]
) => { ) => {
intersections.forEach((intersection) => { let intersection = null
if (intersection.object.type !== 'GridHelper') { for (let i = 0; i < intersections.length; i++) {
const existingIntersection = intersectionsMap.get(intersection.object) if (intersections[i].object.type !== 'GridHelper') {
if ( intersection = intersections[i]
!existingIntersection || break
existingIntersection.distance > intersection.distance
) {
intersectionsMap.set(intersection.object, intersection)
}
} }
}) }
if (!intersection) return
if (intersection.distance < closestDistance) {
closestDistance = intersection.distance
closestIntersection = intersection
}
} }
// Check the center point // Check the center point
this.raycaster.setFromCamera(mouseDownVector, this.camControls.camera) this.raycaster.setFromCamera(mouseDownVector, this.camControls.camera)
updateIntersectionsMap( updateClosestIntersection(
this.raycaster.intersectObjects(this.scene.children, true) this.raycaster.intersectObjects(this.scene.children, true)
) )
// Check the ring points // Check the ring points
for (let i = 0; i < rayRingCount; i++) { for (let i = 0; i < rayRingCount; i++) {
const angle = (i / rayRingCount) * Math.PI * 2 const angle = (i / rayRingCount) * Math.PI * 2
const offsetX = ((pixelRadius * Math.cos(angle)) / window.innerWidth) * 2 const offsetX = ((pixelRadius * Math.cos(angle)) / window.innerWidth) * 2
const offsetY = ((pixelRadius * Math.sin(angle)) / window.innerHeight) * 2 const offsetY = ((pixelRadius * Math.sin(angle)) / window.innerHeight) * 2
const ringVector = new Vector2( const ringVector = new Vector2(
@ -405,15 +405,11 @@ class SceneInfra {
mouseDownVector.y - offsetY mouseDownVector.y - offsetY
) )
this.raycaster.setFromCamera(ringVector, this.camControls.camera) this.raycaster.setFromCamera(ringVector, this.camControls.camera)
updateIntersectionsMap( updateClosestIntersection(
this.raycaster.intersectObjects(this.scene.children, true) this.raycaster.intersectObjects(this.scene.children, true)
) )
} }
return closestIntersection
// Convert the map values to an array and sort by distance
return Array.from(intersectionsMap.values()).sort(
(a, b) => a.distance - b.distance
)
} }
onMouseDown = (event: MouseEvent) => { onMouseDown = (event: MouseEvent) => {
@ -421,60 +417,45 @@ class SceneInfra {
this.currentMouseVector.y = -(event.clientY / window.innerHeight) * 2 + 1 this.currentMouseVector.y = -(event.clientY / window.innerHeight) * 2 + 1
const mouseDownVector = this.currentMouseVector.clone() const mouseDownVector = this.currentMouseVector.clone()
const intersect = this.raycastRing()[0] const intersect = this.raycastRing()
if (intersect) { if (intersect) {
const intersectParent = intersect?.object?.parent as Group const intersectParent = intersect?.object?.parent as Group
this.selected = intersectParent.isGroup this.selected = intersectParent.isGroup
? { ? {
mouseDownVector, mouseDownVector,
object: intersect.object, object: intersect?.object,
hasBeenDragged: false, hasBeenDragged: false,
} }
: null : null
} }
} }
onMouseUp = (mouseEvent: MouseEvent) => { onMouseUp = (event: MouseEvent) => {
this.currentMouseVector.x = (mouseEvent.clientX / window.innerWidth) * 2 - 1 this.currentMouseVector.x = (event.clientX / window.innerWidth) * 2 - 1
this.currentMouseVector.y = this.currentMouseVector.y = -(event.clientY / window.innerHeight) * 2 + 1
-(mouseEvent.clientY / window.innerHeight) * 2 + 1
const planeIntersectPoint = this.getPlaneIntersectPoint() const planeIntersectPoint = this.getPlaneIntersectPoint()
const intersects = this.raycastRing()
if (this.selected) { if (this.selected) {
if (this.selected.hasBeenDragged) { if (this.selected.hasBeenDragged) {
// this is where we could fire a onDragEnd event // this is where we could fire a onDragEnd event
// console.log('onDragEnd', this.selected) // console.log('onDragEnd', this.selected)
} else if (planeIntersectPoint?.twoD && planeIntersectPoint?.threeD) { } else if (planeIntersectPoint) {
// fire onClick event as there was no drags // fire onClick event as there was no drags
this.onClickCallback({ this.onClickCallback({
mouseEvent, object: this.selected?.object,
intersectionPoint: { event,
twoD: planeIntersectPoint.twoD, ...planeIntersectPoint,
threeD: planeIntersectPoint.threeD,
},
intersects,
selected: this.selected.object,
})
} else if (planeIntersectPoint) {
this.onClickCallback({
mouseEvent,
intersects,
}) })
} else { } else {
this.onClickCallback() this.onClickCallback()
} }
// Clear the selected state whether it was dragged or not // Clear the selected state whether it was dragged or not
this.selected = null this.selected = null
} else if (planeIntersectPoint?.twoD && planeIntersectPoint?.threeD) { } else if (planeIntersectPoint) {
this.onClickCallback({ this.onClickCallback({
mouseEvent, event,
intersectionPoint: { ...planeIntersectPoint,
twoD: planeIntersectPoint.twoD,
threeD: planeIntersectPoint.threeD,
},
intersects,
}) })
} else { } else {
this.onClickCallback() this.onClickCallback()

View File

@ -70,7 +70,6 @@ export function straightSegment({
pathToNode, pathToNode,
isDraftSegment, isDraftSegment,
scale = 1, scale = 1,
callExpName,
}: { }: {
from: Coords2d from: Coords2d
to: Coords2d to: Coords2d
@ -78,7 +77,6 @@ export function straightSegment({
pathToNode: PathToNode pathToNode: PathToNode
isDraftSegment?: boolean isDraftSegment?: boolean
scale?: number scale?: number
callExpName: string
}): Group { }): Group {
const group = new Group() const group = new Group()
@ -102,8 +100,7 @@ export function straightSegment({
}) })
} }
const baseColor = callExpName === 'close' ? 0x444444 : 0xffffff const body = new MeshBasicMaterial({ color: 0xffffff })
const body = new MeshBasicMaterial({ color: baseColor })
const mesh = new Mesh(geometry, body) const mesh = new Mesh(geometry, body)
mesh.userData.type = isDraftSegment mesh.userData.type = isDraftSegment
? STRAIGHT_SEGMENT_DASH ? STRAIGHT_SEGMENT_DASH
@ -117,10 +114,7 @@ export function straightSegment({
to, to,
pathToNode, pathToNode,
isSelected: false, isSelected: false,
callExpName,
baseColor,
} }
group.name = STRAIGHT_SEGMENT
const arrowGroup = createArrowhead(scale) const arrowGroup = createArrowhead(scale)
arrowGroup.position.set(to[0], to[1], 0) arrowGroup.position.set(to[0], to[1], 0)
@ -129,8 +123,7 @@ export function straightSegment({
.normalize() .normalize()
arrowGroup.quaternion.setFromUnitVectors(new Vector3(0, 1, 0), dir) arrowGroup.quaternion.setFromUnitVectors(new Vector3(0, 1, 0), dir)
group.add(mesh) group.add(mesh, arrowGroup)
if (callExpName !== 'close') group.add(arrowGroup)
return group return group
} }
@ -210,7 +203,6 @@ export function tangentialArcToSegment({
pathToNode, pathToNode,
isSelected: false, isSelected: false,
} }
group.name = TANGENTIAL_ARC_TO_SEGMENT
const arrowGroup = createArrowhead(scale) const arrowGroup = createArrowhead(scale)
arrowGroup.position.set(to[0], to[1], 0) arrowGroup.position.set(to[0], to[1], 0)

View File

@ -1,8 +1,8 @@
import { Combobox } from '@headlessui/react' import { Combobox } from '@headlessui/react'
import Fuse from 'fuse.js' import Fuse from 'fuse.js'
import { useCommandsContext } from 'hooks/useCommandsContext' import { useCommandsContext } from 'hooks/useCommandsContext'
import { CommandArgument, CommandArgumentOption } from 'lib/commandTypes' import { CommandArgumentOption } from 'lib/commandTypes'
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
function CommandArgOptionInput({ function CommandArgOptionInput({
options, options,
@ -11,89 +11,51 @@ function CommandArgOptionInput({
onSubmit, onSubmit,
placeholder, placeholder,
}: { }: {
options: (CommandArgument<unknown> & { inputType: 'options' })['options'] options: CommandArgumentOption<unknown>[]
argName: string argName: string
stepBack: () => void stepBack: () => void
onSubmit: (data: unknown) => void onSubmit: (data: unknown) => void
placeholder?: string placeholder?: string
}) { }) {
const { commandBarSend, commandBarState } = useCommandsContext() const { commandBarSend, commandBarState } = useCommandsContext()
const resolvedOptions = useMemo(
() =>
typeof options === 'function'
? options(commandBarState.context)
: options,
[argName, options, commandBarState.context]
)
// The initial current option is either an already-input value or the configured default
const currentOption = useMemo(
() =>
resolvedOptions.find(
(o) => o.value === commandBarState.context.argumentsToSubmit[argName]
) || resolvedOptions.find((o) => o.isCurrent),
[commandBarState.context.argumentsToSubmit, argName, resolvedOptions]
)
const inputRef = useRef<HTMLInputElement>(null) const inputRef = useRef<HTMLInputElement>(null)
const formRef = useRef<HTMLFormElement>(null) const formRef = useRef<HTMLFormElement>(null)
const [selectedOption, setSelectedOption] = useState< const [argValue, setArgValue] = useState<(typeof options)[number]['value']>(
CommandArgumentOption<unknown> options.find((o) => 'isCurrent' in o && o.isCurrent)?.value ||
>(currentOption || resolvedOptions[0]) commandBarState.context.argumentsToSubmit[argName] ||
const initialQuery = useMemo(() => '', [options, argName]) options[0].value
const [query, setQuery] = useState(initialQuery)
const [filteredOptions, setFilteredOptions] =
useState<typeof resolvedOptions>()
// Create a new Fuse instance when the options change
const fuse = useMemo(
() =>
new Fuse(resolvedOptions, {
keys: ['name', 'description'],
threshold: 0.3,
}),
[argName, resolvedOptions]
) )
const [query, setQuery] = useState('')
const [filteredOptions, setFilteredOptions] = useState<typeof options>()
// Reset the query and selected option when the argName changes const fuse = new Fuse(options, {
useEffect(() => { keys: ['name', 'description'],
setQuery(initialQuery) threshold: 0.3,
setSelectedOption(currentOption || resolvedOptions[0]) })
}, [argName])
// Auto focus and select the input when the component mounts
useEffect(() => { useEffect(() => {
inputRef.current?.focus() inputRef.current?.focus()
inputRef.current?.select() inputRef.current?.select()
}, [inputRef]) }, [inputRef])
// Filter the options based on the query,
// resetting the query when the options change
useEffect(() => { useEffect(() => {
const results = fuse.search(query).map((result) => result.item) const results = fuse.search(query).map((result) => result.item)
setFilteredOptions(query.length > 0 ? results : resolvedOptions) setFilteredOptions(query.length > 0 ? results : options)
}, [query, resolvedOptions, fuse]) }, [query])
function handleSelectOption(option: CommandArgumentOption<unknown>) { function handleSelectOption(option: CommandArgumentOption<unknown>) {
// We deal with the whole option object internally setArgValue(option)
setSelectedOption(option)
// But we only submit the value
onSubmit(option.value) onSubmit(option.value)
} }
function handleSubmit(e: React.FormEvent<HTMLFormElement>) { function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault() e.preventDefault()
onSubmit(argValue)
// We submit the value of the selected option, not the whole object
onSubmit(selectedOption.value)
} }
return ( return (
<form id="arg-form" onSubmit={handleSubmit} ref={formRef}> <form id="arg-form" onSubmit={handleSubmit} ref={formRef}>
<Combobox <Combobox value={argValue} onChange={handleSelectOption} name="options">
value={selectedOption}
onChange={handleSelectOption}
name="options"
>
<div className="flex items-center mx-4 mt-4 mb-2"> <div className="flex items-center mx-4 mt-4 mb-2">
<label <label
htmlFor="option-input" htmlFor="option-input"
@ -113,12 +75,10 @@ function CommandArgOptionInput({
stepBack() stepBack()
} }
}} }}
value={query}
placeholder={ placeholder={
currentOption?.name || (argValue as CommandArgumentOption<unknown>)?.name ||
placeholder || placeholder ||
argName || 'Select an option for ' + argName
'Select an option'
} }
autoCapitalize="off" autoCapitalize="off"
autoComplete="off" autoComplete="off"
@ -138,7 +98,7 @@ function CommandArgOptionInput({
className="flex items-center gap-2 px-4 py-1 first:mt-2 last:mb-2 ui-active:bg-energy-10/50 dark:ui-active:bg-chalkboard-90" className="flex items-center gap-2 px-4 py-1 first:mt-2 last:mb-2 ui-active:bg-energy-10/50 dark:ui-active:bg-chalkboard-90"
> >
<p className="flex-grow">{option.name} </p> <p className="flex-grow">{option.name} </p>
{option.value === currentOption?.value && ( {'isCurrent' in option && option.isCurrent && (
<small className="text-chalkboard-70 dark:text-chalkboard-50"> <small className="text-chalkboard-70 dark:text-chalkboard-50">
current current
</small> </small>

View File

@ -29,6 +29,12 @@ export const CommandBarProvider = ({
const [commandBarState, commandBarSend] = useMachine(commandBarMachine, { const [commandBarState, commandBarSend] = useMachine(commandBarMachine, {
devTools: true, devTools: true,
guards: { guards: {
'Arguments are ready': (context, _) => {
return context.selectedCommand?.args
? context.argumentsToSubmit.length ===
Object.keys(context.selectedCommand.args)?.length
: false
},
'Command has no arguments': (context, _event) => { 'Command has no arguments': (context, _event) => {
return ( return (
!context.selectedCommand?.args || !context.selectedCommand?.args ||
@ -75,12 +81,7 @@ export const CommandBar = () => {
function stepBack() { function stepBack() {
if (!currentArgument) { if (!currentArgument) {
if (commandBarState.matches('Review')) { if (commandBarState.matches('Review')) {
const entries = Object.entries(selectedCommand?.args || {}).filter( const entries = Object.entries(selectedCommand?.args || {})
([_, argConfig]) =>
typeof argConfig.required === 'function'
? argConfig.required(commandBarState.context)
: argConfig.required
)
const currentArgName = entries[entries.length - 1][0] const currentArgName = entries[entries.length - 1][0]
const currentArg = { const currentArg = {
@ -88,12 +89,19 @@ export const CommandBar = () => {
...entries[entries.length - 1][1], ...entries[entries.length - 1][1],
} }
commandBarSend({ if (commandBarState.matches('Review')) {
type: 'Edit argument', commandBarSend({
data: { type: 'Edit argument',
arg: currentArg, data: {
}, arg: currentArg,
}) },
})
} else {
commandBarSend({
type: 'Remove argument',
data: { [currentArgName]: currentArg },
})
}
} else { } else {
commandBarSend({ type: 'Deselect command' }) commandBarSend({ type: 'Deselect command' })
} }
@ -116,6 +124,11 @@ export const CommandBar = () => {
} }
} }
useEffect(
() => console.log(commandBarState.context.argumentsToSubmit),
[commandBarState.context.argumentsToSubmit]
)
return ( return (
<Transition.Root <Transition.Root
show={!commandBarState.matches('Closed') || false} show={!commandBarState.matches('Closed') || false}

View File

@ -76,82 +76,72 @@ function CommandBarHeader({ children }: React.PropsWithChildren<{}>) {
)} )}
{selectedCommand?.name} {selectedCommand?.name}
</p> </p>
{Object.entries(selectedCommand?.args || {}) {Object.entries(selectedCommand?.args || {}).map(
.filter(([_, argConfig]) => ([argName, arg], i) => (
typeof argConfig.required === 'function' <button
? argConfig.required(commandBarState.context) disabled={!isReviewing && currentArgument?.name === argName}
: argConfig.required onClick={() => {
) commandBarSend({
.map(([argName, arg], i) => { type: isReviewing
const argValue = ? 'Edit argument'
(typeof argumentsToSubmit[argName] === 'function' : 'Change current argument',
? (argumentsToSubmit[argName] as Function)( data: { arg: { ...arg, name: argName } },
commandBarState.context })
}}
key={argName}
className={`relative w-fit px-2 py-1 rounded-sm flex gap-2 items-center border ${
argName === currentArgument?.name
? 'disabled:bg-energy-10/50 dark:disabled:bg-energy-10/20 disabled:border-energy-10 dark:disabled:border-energy-10 disabled:text-chalkboard-100 dark:disabled:text-chalkboard-10'
: 'bg-chalkboard-20/50 dark:bg-chalkboard-80/50 border-chalkboard-20 dark:border-chalkboard-80'
}`}
>
<span className="capitalize">{argName}</span>
{argumentsToSubmit[argName] ? (
arg.inputType === 'selection' ? (
getSelectionTypeDisplayText(
argumentsToSubmit[argName] as Selections
) )
: argumentsToSubmit[argName]) || '' ) : arg.inputType === 'kcl' ? (
roundOff(
return ( Number(
<button (argumentsToSubmit[argName] as KclCommandValue)
disabled={!isReviewing && currentArgument?.name === argName} .valueCalculated
onClick={() => { ),
commandBarSend({ 4
type: isReviewing
? 'Edit argument'
: 'Change current argument',
data: { arg: { ...arg, name: argName } },
})
}}
key={argName}
className={`relative w-fit px-2 py-1 rounded-sm flex gap-2 items-center border ${
argName === currentArgument?.name
? 'disabled:bg-energy-10/50 dark:disabled:bg-energy-10/20 disabled:border-energy-10 dark:disabled:border-energy-10 disabled:text-chalkboard-100 dark:disabled:text-chalkboard-10'
: 'bg-chalkboard-20/50 dark:bg-chalkboard-80/50 border-chalkboard-20 dark:border-chalkboard-80'
}`}
>
<span className="capitalize">{argName}</span>
{argValue ? (
arg.inputType === 'selection' ? (
getSelectionTypeDisplayText(argValue as Selections)
) : arg.inputType === 'kcl' ? (
roundOff(
Number((argValue as KclCommandValue).valueCalculated),
4
)
) : typeof argValue === 'object' ? (
JSON.stringify(argValue)
) : (
<em>{argValue}</em>
) )
) : null} ) : typeof argumentsToSubmit[argName] === 'object' ? (
{showShortcuts && ( JSON.stringify(argumentsToSubmit[argName])
<small className="absolute -top-[1px] right-full translate-x-1/2 px-0.5 rounded-sm bg-chalkboard-80 text-chalkboard-10 dark:bg-energy-10 dark:text-chalkboard-100"> ) : (
<span className="sr-only">Hotkey: </span> <em>{argumentsToSubmit[argName] as ReactNode}</em>
{i + 1} )
</small> ) : null}
{showShortcuts && (
<small className="absolute -top-[1px] right-full translate-x-1/2 px-0.5 rounded-sm bg-chalkboard-80 text-chalkboard-10 dark:bg-energy-10 dark:text-chalkboard-100">
<span className="sr-only">Hotkey: </span>
{i + 1}
</small>
)}
{arg.inputType === 'kcl' &&
!!argumentsToSubmit[argName] &&
'variableName' in
(argumentsToSubmit[argName] as KclCommandValue) && (
<>
<CustomIcon name="make-variable" className="w-4 h-4" />
<Tooltip position="blockEnd">
New variable:{' '}
{
(
argumentsToSubmit[
argName
] as KclExpressionWithVariable
).variableName
}
</Tooltip>
</>
)} )}
{arg.inputType === 'kcl' && </button>
!!argValue && )
'variableName' in (argValue as KclCommandValue) && ( )}
<>
<CustomIcon
name="make-variable"
className="w-4 h-4"
/>
<Tooltip position="blockEnd">
New variable:{' '}
{
(
argumentsToSubmit[
argName
] as KclExpressionWithVariable
).variableName
}
</Tooltip>
</>
)}
</button>
)
})}
</div> </div>
{isReviewing ? <ReviewingButton /> : <GatheringArgsButton />} {isReviewing ? <ReviewingButton /> : <GatheringArgsButton />}
</div> </div>

View File

@ -48,8 +48,7 @@ function CommandBarReview({ stepBack }: { stepBack: () => void }) {
if (!arg) return if (!arg) return
}) })
function submitCommand(e: React.FormEvent<HTMLFormElement>) { function submitCommand() {
e.preventDefault()
commandBarSend({ commandBarSend({
type: 'Submit command', type: 'Submit command',
data: argumentsToSubmit, data: argumentsToSubmit,

View File

@ -29,7 +29,7 @@ function CommandBarSelectionInput({
const inputRef = useRef<HTMLInputElement>(null) const inputRef = useRef<HTMLInputElement>(null)
const { commandBarState, commandBarSend } = useCommandsContext() const { commandBarState, commandBarSend } = useCommandsContext()
const [hasSubmitted, setHasSubmitted] = useState(false) const [hasSubmitted, setHasSubmitted] = useState(false)
const selection = useSelector(arg.machineActor, selectionSelector) const selection = useSelector(arg.actor, selectionSelector)
const [selectionsByType, setSelectionsByType] = useState< const [selectionsByType, setSelectionsByType] = useState<
'none' | ResolvedSelectionType[] 'none' | ResolvedSelectionType[]
>( >(

View File

@ -9,7 +9,6 @@ export type CustomIconName =
| 'clipboardCheckmark' | 'clipboardCheckmark'
| 'close' | 'close'
| 'equal' | 'equal'
| 'exportFile'
| 'extrude' | 'extrude'
| 'file' | 'file'
| 'filePlus' | 'filePlus'
@ -195,22 +194,6 @@ export const CustomIcon = ({
/> />
</svg> </svg>
) )
case 'exportFile':
return (
<svg
{...props}
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M4 3H4.5H11H11.2071L11.3536 3.14645L15.8536 7.64646L16 7.7929V8.00001V11.3773C15.6992 11.1362 15.3628 10.9376 15 10.7908V8.50001H11H10.5V8.00001V4H5V16H9.79076C9.93763 16.3628 10.1362 16.6992 10.3773 17H4.5H4V16.5V3.5V3ZM11.5 4.70711L14.2929 7.50001H11.5V4.70711ZM16.3904 14.1877L14.3904 11.6877L13.6096 12.3124L14.9597 14H11V15H14.9597L13.6096 16.6877L14.3904 17.3124L16.3904 14.8124L16.6403 14.5L16.3904 14.1877Z"
fill="currentColor"
/>
</svg>
)
case 'extrude': case 'extrude':
return ( return (
<svg <svg

View File

@ -0,0 +1,238 @@
import { v4 as uuidv4 } from 'uuid'
import { faFileExport, faXmark } from '@fortawesome/free-solid-svg-icons'
import { ActionButton } from './ActionButton'
import Modal from 'react-modal'
import React from 'react'
import { useFormik } from 'formik'
import { Models } from '@kittycad/lib'
import { engineCommandManager } from '../lang/std/engineConnection'
import { useGlobalStateContext } from 'hooks/useGlobalStateContext'
type OutputFormat = Models['OutputFormat_type']
type OutputTypeKey = OutputFormat['type']
type ExtractStorageTypes<T> = T extends { storage: infer U } ? U : never
type StorageUnion = ExtractStorageTypes<OutputFormat>
export interface ExportButtonProps extends React.PropsWithChildren {
className?: {
button?: string
icon?: string
bg?: string
}
}
export const ExportButton = ({ children, className }: ExportButtonProps) => {
const [modalIsOpen, setIsOpen] = React.useState(false)
const {
settings: {
state: {
context: { baseUnit },
},
},
} = useGlobalStateContext()
const defaultType = 'gltf'
const [type, setType] = React.useState<OutputTypeKey>(defaultType)
const defaultStorage = 'embedded'
const [storage, setStorage] = React.useState<StorageUnion>(defaultStorage)
function openModal() {
setIsOpen(true)
}
function closeModal() {
setIsOpen(false)
}
// Default to gltf and embedded.
const initialValues: OutputFormat = {
type: defaultType,
storage: defaultStorage,
presentation: 'pretty',
}
const formik = useFormik({
initialValues,
onSubmit: (values: OutputFormat) => {
// Set the default coords.
if (
values.type === 'obj' ||
values.type === 'ply' ||
values.type === 'step' ||
values.type === 'stl'
) {
// Set the default coords.
// In the future we can make this configurable.
// But for now, its probably best to keep it consistent with the
// UI.
values.coords = {
forward: {
axis: 'y',
direction: 'negative',
},
up: {
axis: 'z',
direction: 'positive',
},
}
}
if (
values.type === 'obj' ||
values.type === 'stl' ||
values.type === 'ply'
) {
values.units = baseUnit
}
if (
values.type === 'ply' ||
values.type === 'stl' ||
values.type === 'gltf'
) {
// Set the storage type.
values.storage = storage
}
if (values.type === 'ply' || values.type === 'stl') {
values.selection = { type: 'default_scene' }
}
engineCommandManager.sendSceneCommand({
type: 'modeling_cmd_req',
cmd: {
type: 'export',
// By default let's leave this blank to export the whole scene.
// In the future we might want to let the user choose which entities
// in the scene to export. In that case, you'd pass the IDs thru here.
entity_ids: [],
format: values,
source_unit: baseUnit,
},
cmd_id: uuidv4(),
})
closeModal()
},
})
return (
<>
<ActionButton
onClick={openModal}
Element="button"
icon={{
icon: faFileExport,
className: 'p-1',
size: 'sm',
iconClassName: className?.icon,
bgClassName: className?.bg,
}}
className={className?.button}
>
{children || 'Export'}
</ActionButton>
<Modal
isOpen={modalIsOpen}
onRequestClose={closeModal}
contentLabel="Export"
overlayClassName="z-40 fixed inset-0 grid place-items-center"
className="rounded p-4 bg-chalkboard-10 dark:bg-chalkboard-100 border max-w-xl w-full"
>
<h1 className="text-2xl font-bold">Export your design</h1>
<form onSubmit={formik.handleSubmit}>
<div className="flex flex-wrap justify-between gap-8 items-center w-full my-8">
<label htmlFor="type" className="flex-1">
<p className="mb-2">Type</p>
<select
id="type"
name="type"
data-testid="export-type"
onChange={(e) => {
setType(e.target.value as OutputTypeKey)
if (e.target.value === 'gltf') {
// Set default to embedded.
setStorage('embedded')
} else if (e.target.value === 'ply') {
// Set default to ascii.
setStorage('ascii')
} else if (e.target.value === 'stl') {
// Set default to ascii.
setStorage('ascii')
}
formik.handleChange(e)
}}
className="bg-chalkboard-20 dark:bg-chalkboard-90 w-full"
>
<option value="gltf">gltf</option>
<option value="obj">obj</option>
<option value="ply">ply</option>
<option value="step">step</option>
<option value="stl">stl</option>
</select>
</label>
{(type === 'gltf' || type === 'ply' || type === 'stl') && (
<label htmlFor="storage" className="flex-1">
<p className="mb-2">Storage</p>
<select
id="storage"
name="storage"
data-testid="export-storage"
onChange={(e) => {
setStorage(e.target.value as StorageUnion)
formik.handleChange(e)
}}
className="bg-chalkboard-20 dark:bg-chalkboard-90 w-full"
>
{type === 'gltf' && (
<>
<option value="embedded">embedded</option>
<option value="binary">binary</option>
<option value="standard">standard</option>
</>
)}
{type === 'stl' && (
<>
<option value="ascii">ascii</option>
<option value="binary">binary</option>
</>
)}
{type === 'ply' && (
<>
<option value="ascii">ascii</option>
<option value="binary_little_endian">
binary_little_endian
</option>
<option value="binary_big_endian">
binary_big_endian
</option>
</>
)}
</select>
</label>
)}
</div>
<div className="flex justify-between mt-6">
<ActionButton
Element="button"
onClick={closeModal}
icon={{
icon: faXmark,
className: 'p-1',
bgClassName: 'bg-destroy-80',
iconClassName:
'text-destroy-20 group-hover:text-destroy-10 hover:text-destroy-10',
}}
className="hover:border-destroy-40"
>
Close
</ActionButton>
<ActionButton
Element="button"
type="submit"
icon={{ icon: faFileExport, className: 'p-1' }}
>
Export
</ActionButton>
</div>
</form>
</Modal>
</>
)
}

View File

@ -57,30 +57,27 @@ export const GlobalStateProvider = ({
> >
) )
const [settingsState, settingsSend, settingsActor] = useMachine( const [settingsState, settingsSend] = useMachine(settingsMachine, {
settingsMachine, context: persistedSettings,
{ actions: {
context: persistedSettings, toastSuccess: (context, event) => {
actions: { const truncatedNewValue =
toastSuccess: (context, event) => { 'data' in event && event.data instanceof Object
const truncatedNewValue = ? (context[Object.keys(event.data)[0] as keyof typeof context]
'data' in event && event.data instanceof Object .toString()
? (String( .substring(0, 28) as any)
context[Object.keys(event.data)[0] as keyof typeof context] : undefined
).substring(0, 28) as any) toast.success(
: undefined event.type +
toast.success( (truncatedNewValue
event.type + ? ` to "${truncatedNewValue}${
(truncatedNewValue truncatedNewValue.length === 28 ? '...' : ''
? ` to "${truncatedNewValue}${ }"`
truncatedNewValue.length === 28 ? '...' : '' : '')
}"` )
: '')
)
},
}, },
} },
) })
settingsStateRef = settingsState.context settingsStateRef = settingsState.context
useStateMachineCommands({ useStateMachineCommands({
@ -88,7 +85,6 @@ export const GlobalStateProvider = ({
state: settingsState, state: settingsState,
send: settingsSend, send: settingsSend,
commandBarConfig: settingsCommandBarConfig, commandBarConfig: settingsCommandBarConfig,
actor: settingsActor,
}) })
// Listen for changes to the system theme and update the app theme accordingly // Listen for changes to the system theme and update the app theme accordingly
@ -109,7 +105,7 @@ export const GlobalStateProvider = ({
}, [settingsState.context]) }, [settingsState.context])
// Auth machine setup // Auth machine setup
const [authState, authSend, authActor] = useMachine(authMachine, { const [authState, authSend] = useMachine(authMachine, {
actions: { actions: {
goToSignInPage: () => { goToSignInPage: () => {
navigate(paths.SIGN_IN) navigate(paths.SIGN_IN)
@ -129,7 +125,6 @@ export const GlobalStateProvider = ({
state: authState, state: authState,
send: authSend, send: authSend,
commandBarConfig: authCommandBarConfig, commandBarConfig: authCommandBarConfig,
actor: authActor,
}) })
return ( return (

View File

@ -38,10 +38,6 @@ import { getSketchQuaternion } from 'clientSideScene/sceneEntities'
import { startSketchOnDefault } from 'lang/modifyAst' import { startSketchOnDefault } from 'lang/modifyAst'
import { Program } from 'lang/wasm' import { Program } from 'lang/wasm'
import { isSingleCursorInPipe } from 'lang/queryAst' import { isSingleCursorInPipe } from 'lang/queryAst'
import { TEST } from 'env'
import { exportFromEngine } from 'lib/exportFromEngine'
import { Models } from '@kittycad/lib/dist/types/src'
import toast from 'react-hot-toast'
type MachineContext<T extends AnyStateMachine> = { type MachineContext<T extends AnyStateMachine> = {
state: StateFrom<T> state: StateFrom<T>
@ -58,12 +54,7 @@ export const ModelingMachineProvider = ({
}: { }: {
children: React.ReactNode children: React.ReactNode
}) => { }) => {
const { const { auth } = useGlobalStateContext()
auth,
settings: {
context: { baseUnit },
},
} = useGlobalStateContext()
const { code } = useKclContext() const { code } = useKclContext()
const token = auth?.context?.token const token = auth?.context?.token
const streamRef = useRef<HTMLDivElement>(null) const streamRef = useRef<HTMLDivElement>(null)
@ -179,56 +170,6 @@ export const ModelingMachineProvider = ({
} }
return { selectionRangeTypeMap } return { selectionRangeTypeMap }
}), }),
'Engine export': (_, event) => {
if (event.type !== 'Export' || TEST) return
const format = {
...event.data,
} as Partial<Models['OutputFormat_type']>
// Set all the un-configurable defaults here.
if (format.type === 'gltf') {
format.presentation = 'pretty'
}
if (
format.type === 'obj' ||
format.type === 'ply' ||
format.type === 'step' ||
format.type === 'stl'
) {
// Set the default coords.
// In the future we can make this configurable.
// But for now, its probably best to keep it consistent with the
// UI.
format.coords = {
forward: {
axis: 'y',
direction: 'negative',
},
up: {
axis: 'z',
direction: 'positive',
},
}
}
if (
format.type === 'obj' ||
format.type === 'stl' ||
format.type === 'ply'
) {
format.units = baseUnit
}
if (format.type === 'ply' || format.type === 'stl') {
format.selection = { type: 'default_scene' }
}
exportFromEngine({
source_unit: baseUnit,
format: format as Models['OutputFormat_type'],
}).catch((e) => toast.error('Error while exporting', e)) // TODO I think we need to throw the error from engineCommandManager
},
}, },
guards: { guards: {
'has valid extrude selection': ({ selectionRanges }) => { 'has valid extrude selection': ({ selectionRanges }) => {
@ -251,8 +192,6 @@ export const ModelingMachineProvider = ({
selectionRanges selectionRanges
) )
}, },
'Has exportable geometry': () =>
kclManager.kclErrors.length === 0 && kclManager.ast.body.length > 0,
}, },
services: { services: {
'AST-undo-startSketchOn': async ({ sketchPathToNode }) => { 'AST-undo-startSketchOn': async ({ sketchPathToNode }) => {

View File

@ -5,6 +5,7 @@ import { type ProjectWithEntryPointMetadata } from 'lib/types'
import { GlobalStateProvider } from './GlobalStateProvider' import { GlobalStateProvider } from './GlobalStateProvider'
import { APP_NAME } from 'lib/constants' import { APP_NAME } from 'lib/constants'
import { vi } from 'vitest' import { vi } from 'vitest'
import { ExportButtonProps } from './ExportButton'
const now = new Date() const now = new Date()
const projectWellFormed = { const projectWellFormed = {
@ -37,6 +38,15 @@ const projectWellFormed = {
}, },
} satisfies ProjectWithEntryPointMetadata } satisfies ProjectWithEntryPointMetadata
const mockExportButton = vi.fn()
vi.mock('/src/components/ExportButton', () => ({
// engineCommandManager method call in ExportButton causes vitest to hang
ExportButton: (props: ExportButtonProps) => {
mockExportButton(props)
return <button>Fake export button</button>
},
}))
describe('ProjectSidebarMenu tests', () => { describe('ProjectSidebarMenu tests', () => {
test('Renders the project name', () => { test('Renders the project name', () => {
render( render(

View File

@ -5,12 +5,12 @@ import { type IndexLoaderData } from 'lib/types'
import { paths } from 'lib/paths' import { paths } from 'lib/paths'
import { isTauri } from '../lib/isTauri' import { isTauri } from '../lib/isTauri'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { ExportButton } from './ExportButton'
import { Fragment } from 'react' import { Fragment } from 'react'
import { FileTree } from './FileTree' import { FileTree } from './FileTree'
import { sep } from '@tauri-apps/api/path' import { sep } from '@tauri-apps/api/path'
import { Logo } from './Logo' import { Logo } from './Logo'
import { APP_NAME } from 'lib/constants' import { APP_NAME } from 'lib/constants'
import { useCommandsContext } from 'hooks/useCommandsContext'
const ProjectSidebarMenu = ({ const ProjectSidebarMenu = ({
project, project,
@ -21,8 +21,6 @@ const ProjectSidebarMenu = ({
project?: IndexLoaderData['project'] project?: IndexLoaderData['project']
file?: IndexLoaderData['file'] file?: IndexLoaderData['file']
}) => { }) => {
const { commandBarSend } = useCommandsContext()
return renderAsLink ? ( return renderAsLink ? (
<Link <Link
to={paths.HOME} to={paths.HOME}
@ -114,19 +112,13 @@ const ProjectSidebarMenu = ({
<div className="flex-1 overflow-hidden" /> <div className="flex-1 overflow-hidden" />
)} )}
<div className="flex flex-col gap-2 p-4 dark:bg-chalkboard-90"> <div className="flex flex-col gap-2 p-4 dark:bg-chalkboard-90">
<ActionButton <ExportButton
Element="button" className={{
icon={{ icon: 'exportFile', className: 'p-1' }} button: 'border-transparent dark:border-transparent',
className="border-transparent dark:border-transparent" }}
onClick={() =>
commandBarSend({
type: 'Find and select command',
data: { name: 'Export', ownerMachine: 'modeling' },
})
}
> >
Export Part Export Model
</ActionButton> </ExportButton>
{isTauri() && ( {isTauri() && (
<ActionButton <ActionButton
Element="link" Element="link"

View File

@ -28,7 +28,7 @@ interface UseStateMachineCommandsArgs<
machineId: T['id'] machineId: T['id']
state: StateFrom<T> state: StateFrom<T>
send: Function send: Function
actor: InterpreterFrom<T> actor?: InterpreterFrom<T>
commandBarConfig?: CommandSetConfig<T, S> commandBarConfig?: CommandSetConfig<T, S>
allCommandsRequireNetwork?: boolean allCommandsRequireNetwork?: boolean
onCancel?: () => void onCancel?: () => void

View File

@ -6,6 +6,7 @@ import {
PipeExpression, PipeExpression,
VariableDeclaration, VariableDeclaration,
VariableDeclarator, VariableDeclarator,
ExpressionStatement,
Value, Value,
Literal, Literal,
PipeSubstitution, PipeSubstitution,

View File

@ -28,8 +28,7 @@ export const homeCommandBarConfig: CommandSetConfig<
name: { name: {
inputType: 'options', inputType: 'options',
required: true, required: true,
options: [], options: (context) =>
optionsFromContext: (context) =>
context.projects.map((p) => ({ context.projects.map((p) => ({
name: p.name!, name: p.name!,
value: p.name!, value: p.name!,
@ -44,7 +43,7 @@ export const homeCommandBarConfig: CommandSetConfig<
name: { name: {
inputType: 'string', inputType: 'string',
required: true, required: true,
defaultValueFromContext: (context) => context.defaultProjectName, defaultValue: (context) => context.defaultProjectName,
}, },
}, },
}, },
@ -56,8 +55,7 @@ export const homeCommandBarConfig: CommandSetConfig<
name: { name: {
inputType: 'options', inputType: 'options',
required: true, required: true,
options: [], options: (context) =>
optionsFromContext: (context) =>
context.projects.map((p) => ({ context.projects.map((p) => ({
name: p.name!, name: p.name!,
value: p.name!, value: p.name!,
@ -73,8 +71,7 @@ export const homeCommandBarConfig: CommandSetConfig<
oldName: { oldName: {
inputType: 'options', inputType: 'options',
required: true, required: true,
options: [], options: (context) =>
optionsFromContext: (context) =>
context.projects.map((p) => ({ context.projects.map((p) => ({
name: p.name!, name: p.name!,
value: p.name!, value: p.name!,
@ -83,7 +80,7 @@ export const homeCommandBarConfig: CommandSetConfig<
newName: { newName: {
inputType: 'string', inputType: 'string',
required: true, required: true,
defaultValueFromContext: (context) => context.defaultProjectName, defaultValue: (context) => context.defaultProjectName,
}, },
}, },
}, },

View File

@ -1,13 +1,7 @@
import { Models } from '@kittycad/lib'
import { CommandSetConfig, KclCommandValue } from 'lib/commandTypes' import { CommandSetConfig, KclCommandValue } from 'lib/commandTypes'
import { Selections } from 'lib/selections' import { Selections } from 'lib/selections'
import { modelingMachine } from 'machines/modelingMachine' import { modelingMachine } from 'machines/modelingMachine'
type OutputFormat = Models['OutputFormat_type']
type OutputTypeKey = OutputFormat['type']
type ExtractStorageTypes<T> = T extends { storage: infer U } ? U : never
type StorageUnion = ExtractStorageTypes<OutputFormat>
export const EXTRUSION_RESULTS = [ export const EXTRUSION_RESULTS = [
'new', 'new',
'add', 'add',
@ -17,10 +11,6 @@ export const EXTRUSION_RESULTS = [
export type ModelingCommandSchema = { export type ModelingCommandSchema = {
'Enter sketch': {} 'Enter sketch': {}
Export: {
type: OutputTypeKey
storage?: StorageUnion
}
Extrude: { Extrude: {
selection: Selections // & { type: 'face' } would be cool to lock that down selection: Selections // & { type: 'face' } would be cool to lock that down
// result: (typeof EXTRUSION_RESULTS)[number] // result: (typeof EXTRUSION_RESULTS)[number]
@ -36,80 +26,6 @@ export const modelingMachineConfig: CommandSetConfig<
description: 'Enter sketch mode.', description: 'Enter sketch mode.',
icon: 'sketch', icon: 'sketch',
}, },
Export: {
description: 'Export the current model.',
icon: 'exportFile',
needsReview: true,
args: {
type: {
inputType: 'options',
defaultValue: 'gltf',
required: true,
options: [
{ name: 'gLTF', isCurrent: true, value: 'gltf' },
{ name: 'OBJ', isCurrent: false, value: 'obj' },
{ name: 'STL', isCurrent: false, value: 'stl' },
{ name: 'STEP', isCurrent: false, value: 'step' },
{ name: 'PLY', isCurrent: false, value: 'ply' },
],
},
storage: {
inputType: 'options',
defaultValue: (c) => {
switch (c.argumentsToSubmit.type) {
case 'gltf':
return 'embedded'
case 'stl':
return 'ascii'
case 'ply':
return 'ascii'
default:
return undefined
}
},
skip: true,
required: (commandContext) =>
['gltf', 'stl', 'ply'].includes(
commandContext.argumentsToSubmit.type as string
),
options: (commandContext) => {
const type = commandContext.argumentsToSubmit.type as
| OutputTypeKey
| undefined
switch (type) {
case 'gltf':
return [
{ name: 'embedded', isCurrent: true, value: 'embedded' },
{ name: 'binary', isCurrent: false, value: 'binary' },
{ name: 'standard', isCurrent: false, value: 'standard' },
]
case 'stl':
return [
{ name: 'binary', isCurrent: false, value: 'binary' },
{ name: 'ascii', isCurrent: true, value: 'ascii' },
]
case 'ply':
return [
{ name: 'ascii', isCurrent: true, value: 'ascii' },
{
name: 'binary_big_endian',
isCurrent: false,
value: 'binary_big_endian',
},
{
name: 'binary_little_endian',
isCurrent: false,
value: 'binary_little_endian',
},
]
default:
return []
}
},
},
},
},
Extrude: { Extrude: {
description: 'Pull a sketch into 3D along its normal or perpendicular.', description: 'Pull a sketch into 3D along its normal or perpendicular.',
icon: 'extrude', icon: 'extrude',

View File

@ -41,9 +41,8 @@ export const settingsCommandBarConfig: CommandSetConfig<
baseUnit: { baseUnit: {
inputType: 'options', inputType: 'options',
required: true, required: true,
defaultValueFromContext: (context) => context.baseUnit, defaultValue: (context) => context.baseUnit,
options: [], options: (context) =>
optionsFromContext: (context) =>
Object.values(baseUnitsUnion).map((v) => ({ Object.values(baseUnitsUnion).map((v) => ({
name: v, name: v,
value: v, value: v,
@ -58,9 +57,8 @@ export const settingsCommandBarConfig: CommandSetConfig<
cameraControls: { cameraControls: {
inputType: 'options', inputType: 'options',
required: true, required: true,
defaultValueFromContext: (context) => context.cameraControls, defaultValue: (context) => context.cameraControls,
options: [], options: (context) =>
optionsFromContext: (context) =>
Object.values(cameraSystems).map((v) => ({ Object.values(cameraSystems).map((v) => ({
name: v, name: v,
value: v, value: v,
@ -76,7 +74,7 @@ export const settingsCommandBarConfig: CommandSetConfig<
defaultProjectName: { defaultProjectName: {
inputType: 'string', inputType: 'string',
required: true, required: true,
defaultValueFromContext: (context) => context.defaultProjectName, defaultValue: (context) => context.defaultProjectName,
}, },
}, },
}, },
@ -86,9 +84,8 @@ export const settingsCommandBarConfig: CommandSetConfig<
textWrapping: { textWrapping: {
inputType: 'options', inputType: 'options',
required: true, required: true,
defaultValueFromContext: (context) => context.textWrapping, defaultValue: (context) => context.textWrapping,
options: [], options: (context) => [
optionsFromContext: (context) => [
{ {
name: 'On', name: 'On',
value: 'On' as Toggle, value: 'On' as Toggle,
@ -109,9 +106,8 @@ export const settingsCommandBarConfig: CommandSetConfig<
theme: { theme: {
inputType: 'options', inputType: 'options',
required: true, required: true,
defaultValueFromContext: (context) => context.theme, defaultValue: (context) => context.theme,
options: [], options: (context) =>
optionsFromContext: (context) =>
Object.values(Themes).map((v) => ({ Object.values(Themes).map((v) => ({
name: v, name: v,
value: v, value: v,
@ -126,9 +122,8 @@ export const settingsCommandBarConfig: CommandSetConfig<
unitSystem: { unitSystem: {
inputType: 'options', inputType: 'options',
required: true, required: true,
defaultValueFromContext: (context) => context.unitSystem, defaultValue: (context) => context.unitSystem,
options: [], options: (context) => [
optionsFromContext: (context) => [
{ {
name: 'Imperial', name: 'Imperial',
value: 'imperial' as UnitSystem, value: 'imperial' as UnitSystem,

View File

@ -8,7 +8,6 @@ import {
} from 'xstate' } from 'xstate'
import { Selection } from './selections' import { Selection } from './selections'
import { Identifier, Value, VariableDeclaration } from 'lang/wasm' import { Identifier, Value, VariableDeclaration } from 'lang/wasm'
import { commandBarMachine } from 'machines/commandBarMachine'
type Icon = CustomIconName type Icon = CustomIconName
const PLATFORMS = ['both', 'web', 'desktop'] as const const PLATFORMS = ['both', 'web', 'desktop'] as const
@ -94,31 +93,15 @@ export type CommandArgumentConfig<
> = > =
| { | {
description?: string description?: string
required: required: boolean
| boolean skip?: true
| ((
commandBarContext: { argumentsToSubmit: Record<string, unknown> } // Should be the commandbarMachine's context, but it creates a circular dependency
) => boolean)
skip?: boolean
} & ( } & (
| { | {
inputType: Extract<CommandInputType, 'options'> inputType: Extract<CommandInputType, 'options'>
options: options:
| CommandArgumentOption<OutputType>[] | CommandArgumentOption<OutputType>[]
| (( | ((context: ContextFrom<T>) => CommandArgumentOption<OutputType>[])
commandBarContext: { defaultValue?: OutputType | ((context: ContextFrom<T>) => OutputType)
argumentsToSubmit: Record<string, unknown>
} // Should be the commandbarMachine's context, but it creates a circular dependency
) => CommandArgumentOption<OutputType>[])
optionsFromContext?: (
context: ContextFrom<T>
) => CommandArgumentOption<OutputType>[]
defaultValue?:
| OutputType
| ((
commandBarContext: ContextFrom<typeof commandBarMachine>
) => OutputType)
defaultValueFromContext?: (context: ContextFrom<T>) => OutputType
} }
| { | {
inputType: Extract<CommandInputType, 'selection'> inputType: Extract<CommandInputType, 'selection'>
@ -128,12 +111,7 @@ export type CommandArgumentConfig<
| { inputType: Extract<CommandInputType, 'kcl'>; defaultValue?: string } // KCL expression inputs have simple strings as default values | { inputType: Extract<CommandInputType, 'kcl'>; defaultValue?: string } // KCL expression inputs have simple strings as default values
| { | {
inputType: Extract<CommandInputType, 'string'> inputType: Extract<CommandInputType, 'string'>
defaultValue?: defaultValue?: OutputType | ((context: ContextFrom<T>) => OutputType)
| OutputType
| ((
commandBarContext: ContextFrom<typeof commandBarMachine>
) => OutputType)
defaultValueFromContext?: (context: ContextFrom<T>) => OutputType
} }
) )
@ -143,42 +121,24 @@ export type CommandArgument<
> = > =
| { | {
description?: string description?: string
required: required: boolean
| boolean skip?: true
| ((
commandBarContext: { argumentsToSubmit: Record<string, unknown> } // Should be the commandbarMachine's context, but it creates a circular dependency
) => boolean)
skip?: boolean
machineActor: InterpreterFrom<T>
} & ( } & (
| { | {
inputType: Extract<CommandInputType, 'options'> inputType: Extract<CommandInputType, 'options'>
options: options: CommandArgumentOption<OutputType>[]
| CommandArgumentOption<OutputType>[] defaultValue?: OutputType
| ((
commandBarContext: {
argumentsToSubmit: Record<string, unknown>
} // Should be the commandbarMachine's context, but it creates a circular dependency
) => CommandArgumentOption<OutputType>[])
defaultValue?:
| OutputType
| ((
commandBarContext: ContextFrom<typeof commandBarMachine>
) => OutputType)
} }
| { | {
inputType: Extract<CommandInputType, 'selection'> inputType: Extract<CommandInputType, 'selection'>
selectionTypes: Selection['type'][] selectionTypes: Selection['type'][]
actor: InterpreterFrom<T>
multiple: boolean multiple: boolean
} }
| { inputType: Extract<CommandInputType, 'kcl'>; defaultValue?: string } // KCL expression inputs have simple strings as default values | { inputType: Extract<CommandInputType, 'kcl'>; defaultValue?: string } // KCL expression inputs have simple strings as default values
| { | {
inputType: Extract<CommandInputType, 'string'> inputType: Extract<CommandInputType, 'string'>
defaultValue?: defaultValue?: OutputType
| OutputType
| ((
commandBarContext: ContextFrom<typeof commandBarMachine>
) => OutputType)
} }
) )

View File

@ -17,7 +17,7 @@ interface CreateMachineCommandProps<
ownerMachine: T['id'] ownerMachine: T['id']
state: StateFrom<T> state: StateFrom<T>
send: Function send: Function
actor: InterpreterFrom<T> actor?: InterpreterFrom<T>
commandBarConfig?: CommandSetConfig<T, S> commandBarConfig?: CommandSetConfig<T, S>
onCancel?: () => void onCancel?: () => void
} }
@ -91,13 +91,13 @@ function buildCommandArguments<
>( >(
state: StateFrom<T>, state: StateFrom<T>,
args: CommandConfig<T, CommandName, S>['args'], args: CommandConfig<T, CommandName, S>['args'],
machineActor: InterpreterFrom<T> actor?: InterpreterFrom<T>
): NonNullable<Command<T, CommandName, S>['args']> { ): NonNullable<Command<T, CommandName, S>['args']> {
const newArgs = {} as NonNullable<Command<T, CommandName, S>['args']> const newArgs = {} as NonNullable<Command<T, CommandName, S>['args']>
for (const arg in args) { for (const arg in args) {
const argConfig = args[arg] as CommandArgumentConfig<S[typeof arg], T> const argConfig = args[arg] as CommandArgumentConfig<S[typeof arg], T>
const newArg = buildCommandArgument(argConfig, arg, state, machineActor) const newArg = buildCommandArgument(argConfig, arg, state, actor)
newArgs[arg] = newArg newArgs[arg] = newArg
} }
@ -111,36 +111,44 @@ function buildCommandArgument<
arg: CommandArgumentConfig<O, T>, arg: CommandArgumentConfig<O, T>,
argName: string, argName: string,
state: StateFrom<T>, state: StateFrom<T>,
machineActor: InterpreterFrom<T> actor?: InterpreterFrom<T>
): CommandArgument<O, T> & { inputType: typeof arg.inputType } { ): CommandArgument<O, T> & { inputType: typeof arg.inputType } {
const baseCommandArgument = { const baseCommandArgument = {
description: arg.description, description: arg.description,
required: arg.required, required: arg.required,
skip: arg.skip, skip: arg.skip,
machineActor,
} satisfies Omit<CommandArgument<O, T>, 'inputType'> } satisfies Omit<CommandArgument<O, T>, 'inputType'>
if (arg.inputType === 'options') { if (arg.inputType === 'options') {
if (!arg.options) { const options = arg.options
? arg.options instanceof Function
? arg.options(state.context)
: arg.options
: undefined
if (!options) {
throw new Error('Options must be provided for options input type') throw new Error('Options must be provided for options input type')
} }
return { return {
inputType: arg.inputType, inputType: arg.inputType,
...baseCommandArgument, ...baseCommandArgument,
defaultValue: arg.defaultValueFromContext defaultValue:
? arg.defaultValueFromContext(state.context) arg.defaultValue instanceof Function
: arg.defaultValue, ? arg.defaultValue(state.context)
options: arg.optionsFromContext : arg.defaultValue,
? arg.optionsFromContext(state.context) options,
: arg.options,
} satisfies CommandArgument<O, T> & { inputType: 'options' } } satisfies CommandArgument<O, T> & { inputType: 'options' }
} else if (arg.inputType === 'selection') { } else if (arg.inputType === 'selection') {
if (!actor)
throw new Error('Actor must be provided for selection input type')
return { return {
inputType: arg.inputType, inputType: arg.inputType,
...baseCommandArgument, ...baseCommandArgument,
multiple: arg.multiple, multiple: arg.multiple,
selectionTypes: arg.selectionTypes, selectionTypes: arg.selectionTypes,
actor,
} satisfies CommandArgument<O, T> & { inputType: 'selection' } } satisfies CommandArgument<O, T> & { inputType: 'selection' }
} else if (arg.inputType === 'kcl') { } else if (arg.inputType === 'kcl') {
return { return {
@ -151,7 +159,10 @@ function buildCommandArgument<
} else { } else {
return { return {
inputType: arg.inputType, inputType: arg.inputType,
defaultValue: arg.defaultValue, defaultValue:
arg.defaultValue instanceof Function
? arg.defaultValue(state.context)
: arg.defaultValue,
...baseCommandArgument, ...baseCommandArgument,
} }
} }

View File

@ -1,27 +0,0 @@
import { engineCommandManager } from 'lang/std/engineConnection'
import { type Models } from '@kittycad/lib'
import { v4 as uuidv4 } from 'uuid'
// Isolating a function to call the engine to export the current scene.
// Because it has given us trouble in automated testing environments.
export function exportFromEngine({
source_unit,
format,
}: {
source_unit: Models['UnitLength_type']
format: Models['OutputFormat_type']
}) {
return engineCommandManager.sendSceneCommand({
type: 'modeling_cmd_req',
cmd: {
type: 'export',
// By default let's leave this blank to export the whole scene.
// In the future we might want to let the user choose which entities
// in the scene to export. In that case, you'd pass the IDs thru here.
entity_ids: [],
format,
source_unit,
},
cmd_id: uuidv4(),
})
}

View File

@ -20,7 +20,6 @@ import {
TANGENTIAL_ARC_TO_SEGMENT, TANGENTIAL_ARC_TO_SEGMENT,
sceneEntitiesManager, sceneEntitiesManager,
getParentGroup, getParentGroup,
PROFILE_START,
} from 'clientSideScene/sceneEntities' } from 'clientSideScene/sceneEntities'
import { Mesh } from 'three' import { Mesh } from 'three'
import { AXIS_GROUP, X_AXIS } from 'clientSideScene/sceneInfra' import { AXIS_GROUP, X_AXIS } from 'clientSideScene/sceneInfra'
@ -189,11 +188,7 @@ export async function getEventForSelectWithPoint(
export function getEventForSegmentSelection( export function getEventForSegmentSelection(
obj: any obj: any
): ModelingMachineEvent | null { ): ModelingMachineEvent | null {
const group = getParentGroup(obj, [ const group = getParentGroup(obj)
STRAIGHT_SEGMENT,
TANGENTIAL_ARC_TO_SEGMENT,
PROFILE_START,
])
const axisGroup = getParentGroup(obj, [AXIS_GROUP]) const axisGroup = getParentGroup(obj, [AXIS_GROUP])
if (!group && !axisGroup) return null if (!group && !axisGroup) return null
if (axisGroup?.userData.type === AXIS_GROUP) { if (axisGroup?.userData.type === AXIS_GROUP) {
@ -412,8 +407,8 @@ function updateSceneObjectColors(codeBasedSelections: Selection[]) {
} }
Object.values(sceneEntitiesManager.activeSegments).forEach((segmentGroup) => { Object.values(sceneEntitiesManager.activeSegments).forEach((segmentGroup) => {
if ( if (
![STRAIGHT_SEGMENT, TANGENTIAL_ARC_TO_SEGMENT, PROFILE_START].includes( ![STRAIGHT_SEGMENT, TANGENTIAL_ARC_TO_SEGMENT].includes(
segmentGroup?.name segmentGroup?.userData?.type
) )
) )
return return
@ -425,9 +420,7 @@ function updateSceneObjectColors(codeBasedSelections: Selection[]) {
const groupHasCursor = codeBasedSelections.some((selection) => { const groupHasCursor = codeBasedSelections.some((selection) => {
return isOverlap(selection.range, [node.start, node.end]) return isOverlap(selection.range, [node.start, node.end])
}) })
const color = groupHasCursor const color = groupHasCursor ? 0x0000ff : 0xffffff
? 0x0000ff
: segmentGroup?.userData?.baseColor || 0xffffff
segmentGroup.traverse( segmentGroup.traverse(
(child) => child instanceof Mesh && child.material.color.set(color) (child) => child instanceof Mesh && child.material.color.set(color)
) )

View File

@ -8,29 +8,21 @@ import {
import { Selections } from 'lib/selections' import { Selections } from 'lib/selections'
import { getCommandArgumentKclValuesOnly } from 'lib/commandUtils' import { getCommandArgumentKclValuesOnly } from 'lib/commandUtils'
export type CommandBarContext = {
commands: Command[]
selectedCommand?: Command
currentArgument?: CommandArgument<unknown> & { name: string }
selectionRanges: Selections
argumentsToSubmit: { [x: string]: unknown }
}
export const commandBarMachine = createMachine( export const commandBarMachine = createMachine(
{ {
/** @xstate-layout N4IgpgJg5mDOIC5QGED2BbdBDAdhABAEJYBOAxMgDaqxgDaADALqKgAONAlgC6eo6sQAD0QBaAJwA6AGwAmAKwBmBoukAWafIAcDcSoA0IAJ6JZDaZIDs8hgzV6AjA61a1DWQF8PhtJlwFiciowUkYWJBAOWB4+AQiRBFF5CwdpcVkHS1lpVyU5QxNEh1lFGTUsrUUtOQd5SwZLLx8MbDwiUkkqGkgyAHk2MBwwwSiY-kEEswZJbPltM0U3eXLZAsRFeQcrerUHRbTFvfkmkF9WgI6u2ggyADFONv98WkowAGNufDeW-2GI0d443iiHESkktUUilkskqdiUWjWCAcDC0kjUqnElkc6lkoK0JzOT0CnWo1zIAEEIARvn48LA-uwuIC4qAEqIHJjJPJcYtxFoYdJFFjVsZEG5pqCquJxJoGvJxOUCT82sSrj0AEpgdCoABuYC+yog9OYIyZsQmYmhWzMmTqLnU0kyikRGVRbj5lg2SmUam5StpFxIkgAymBXh8HlADQGyKHw58aecGZEzUDWYgchY7CisWoSlpQQ5EVDLJJxKkdIpyypUop-ed2kHCW0Xu9uD1kwDzcCEJCtlUNgWhZZ1OlnaKEBpZJItHVzDZ5xlpPWiZdDc8w22Ow5wozosyLUiVNMSg5ytVKmfrIipzO564z2otPVpI1vKd18SAOJYbgACzAEhI3wUgoAAV3QQZuFgCg-1wGAvjAkgSCgkCSHAyCcG4TtUxZYRED2TQZGSOw80qaQ7ARCc9mmZYSksextGkNJBRXFUOh-f9AOA0CIKgmCABE4E3D5oyTE1-lww8clKBjZF2Bh5SFZwXUUyRVDzJwNE2LQzzYwNJE4gCgJwKNeMw6DJHJAB3LAYlM-AHjYMDuFjMCACN0B4NCMKgnD927dMkWSUs8yY8RISfWQshvcsrDlapshULJPHfZsDKM7iHPM-jJAANSwShOAgX9IzICB+DASQHh1VAAGsqp1Qrit-MByXQvisP8sY8ISZwbCsDllBLSwz1qRFBQsRZ5WRIVkui-TG0M39jJ4jqLNgfLmpK3hTLIQCSFQIM2EoX8ADMjvQSQmqKna2vWvyJL3HrD1SEoyzSdJUkUm9dnUtQn10aK6hkxbiU1HVODAay3M87zE1+J6UwCtN8IQUauR0OZ0ksFwUUqRFPWmUdouPXR3TBjoIahmHKQIHKuqRrtUYSaVShhLF+TkeTzBFQosVKDRM2RVInEdSmg2p6GyE1bU9R8zrsKZqSexFqQHWlHNXHzCbFhnX1+UydFkVxiXJClmGAFEIG8hmld3ZGXp7TR5C5RSCxdjlQV1tR9bqaVdhsSFxDN5AALeOrgPa3ysJgiqcCqmr6sa7bWujxXjQd5nesQZYthsblIQYJx6hUic9AsdEFT2HQzByVLmgDJaw-eSOHPTjbysq6qcFqhrrtT9sO-4ugd1NFGc4QXEPo5eVLGsFxlnKREXGnZI9HcT1mOikO0s-S5w7bqNh9j-bkKOyQTvOy6B9utOHtj7qD1V8pSyrHJZ3STY4QmjQ0R2Ww0oSjuF3u+HAqAIBwEEOlRs48nZBVENkKQNprC42qBoJ0LothaXMJ9aElRXDLj3k3VUpJIBwOfgg4oMx8y41SPUOY8p5DFk2GiKoxsoRVmhGoM2cY2zAQRngChgU0a7HZtFH0ilRp1D5usVh6JXCKD2CUdI8lQ7rlbB8chkkJ6HkxFsBeulbQZAUCwrYCiqzIhyE+fqZtMomTMg-aCwiWYEV2NOBUCghQ6EFGzYsvpwQUT5MxawFECx2JWllRxMdLI2TsrtKMTkXIuMnmeCiZYwrePMFWCKv1XZKVGroWwmxNARK4g4hWG0tp3wSSk16lQpC41ULjV8uxUjSBvFCNEDTdCOkWCY44xCGzgzAJDaGdTnaZHBADYcqg5DpCovzeRno5izA0BeUOh8o5OPgDo+BaNvqV0xNFaE7gdYTnnvkmUChdKEMyF4LwQA */ /** @xstate-layout N4IgpgJg5mDOIC5QGED2BbdBDAdhABAEJYBOAxMgDaqxgDaADALqKgAONAlgC6eo6sQAD0QBaAJwA6AGwAmAKwBmBoukAWafIAcDcSoA0IAJ6JZDaZIDs8hgzV6AjA61a1DWQF8PhtJlwFiciowUkYWJBAOWB4+AQiRBFF5CwdpcVkHS1lpVyU5QxNEh1lFGTUsrUUtOQd5SwZLLx8MbDwiUkkqGkgyAHk2MBwwwSiY-kEEswdJNWcNbPFLNMr5AsRFWUtJcVSdRR2VVMUmkF9WgI6u2ggyADFONv98WkowAGNufDeW-2GI0d443iiHESkktUUG1klTsSi0awQDgYWhmqkWjnUslBWhOZyegU61GuZAAghACN8-HhYH92FxAXFQAlRA5FpJ5FjFPYtNDpIpLOkEW4GNs4eJxJoGvJxOVcT82gSrj0AEpgdCoABuYC+8ogNOYI3psQmYlkGUkU2slhc6mkmUUCIyKLc4i0lkU8iUyjUHLlVIuJEkAGUwK8Pg8oDr-WQQ2HPpTzrTIkagUzEDkLHZkQK1CUtKCHAiNlsdjkVAdFEc-ed2oG8W0Xu9uD0kwDjcCEJDplUPfn+Ut7CUhXJJFo6uYbBOMtJq-jLrrnqGmy2HOE6dEGSbESoRSUHOVqpV99Zh7JR+PXPu1G7zI1vKcFwSAOJYbgACzAJAj+FIUAAruggzcLAFBvrgMBfH+JAkEBP4kP+gE4NwrYpoywiIA4qjyDIyR2LmlTSHY8LGBhyjsrm-L2No0hpHys4Kh0L7vp+36-gBQEgQAInAS4fFGiYGv8qFbjkpSWLmswMNK-LOI6UmSKouZOBo8jOPu9EBpITEfl+OCRmxiHAZIJIAO5YDEen4A8bB-twMZ-gARugPBwQhQEoRu7ZpoiyRbLm1HiJC16bLIQo7FYUrVNkKhZJ4971pp2ksZZBkcZIABqWCUJwECvhGZAQPwYCSA8GqoAA1sVGpZTlr5gCS8HsUhHljGhCTODYVissoxaWPutQInyFhctKSL8jFmwabWWmvjprGNYZsAZTVuW8HpZCfiQqCBmwlCvgAZtt6CSNV2WrfVC3uYJ66tVuqQlNsaTpKkUlCrMClqNeuibHUolTQSqoapwYAmfZTkuQmvzXcmnmpuhCB9eyOieuk1o6C4DokQjZHqKjO66C6-0dIDwOg2SBCpc10NtnDCTiqU0ICjyciyG4+RYwKpQaBmSKpE4dpE4GJMg2QqrqlqrlNch1PCR2vNSLa4rZq4eaDVyo4+jymRqJWDQ4vFj7E2AQMiwAohALmU9La4w7dHaaNhNjaBKnqsqCatqBrdTirMNiQuIgudB+bzld+DVuUhIGFTgxWlRVVUrXV4dS-qNs021iDyO9TslMoTj1LJWN6BYOsyphOhmDkcXNP603IMHoeWcni0FUVJU4GVlUnYnzbNxxdCroasMZwgWKPay0qWNYLhZ+UCIuGeyR6O47o0ZsAcG7XioN2Hl2Rxt0HbZIu0HUd3dnUne-AS1m5y+UWz7DkY7pKpsKDRoMz1MK4olO4G-3jgVAEA4CCASrWIedtvKiAWBaBgmQ6g2g0PaR00xWYSjHFPHQNFCIzk3jWRURJIAQNvlA4oFo8zWlSPUT00pVhYw9NMHWrhKwbH2GaNQgdYxNm-JDPAxCvLw1mAzTY3opJ9TqKFehqlUTMMwiUdIrNA5gMbB8IhQlh5bkWFsVwyJshYmkNYQs9DNhI2vJhFQZp+SgkDklXS+kr7wHUZA+G-UzwygUPyLB+xApFh9BaCU6hpSLGqNKDheC5yBlsfNCORlTLmTWpGaytl+G0wwhoEU7ilDWnMN4zGhRPqO0Cn1XQthVKaBsbNZK9iYlLUyhfBJKSR7OH2FYAi1oDGzFSNIIUGwZiVD0BKTCSkFCB2FiZRpIlMjgk+v2VQch0jEUKIYz+HoOSaA0IeJRO8m4OImXLTQjDYRpAFIsfckillZAUjYGipceQ6zvF4IAA */
predictableActionArguments: true,
tsTypes: {} as import('./commandBarMachine.typegen').Typegen0,
context: { context: {
commands: [], commands: [] as Command[],
selectedCommand: undefined, selectedCommand: undefined as Command | undefined,
currentArgument: undefined, currentArgument: undefined as
| (CommandArgument<unknown> & { name: string })
| undefined,
selectionRanges: { selectionRanges: {
otherSelections: [], otherSelections: [],
codeBasedSelections: [], codeBasedSelections: [],
}, } as Selections,
argumentsToSubmit: {}, argumentsToSubmit: {} as { [x: string]: unknown },
} as CommandBarContext, },
id: 'Command Bar', id: 'Command Bar',
initial: 'Closed', initial: 'Closed',
states: { states: {
@ -275,6 +267,7 @@ export const commandBarMachine = createMachine(
data: { [x: string]: CommandArgumentWithName<unknown> } data: { [x: string]: CommandArgumentWithName<unknown> }
}, },
}, },
predictableActionArguments: true,
preserveActionOrder: true, preserveActionOrder: true,
}, },
{ {
@ -286,45 +279,28 @@ export const commandBarMachine = createMachine(
(selectedCommand?.args && event.type === 'Submit command') || (selectedCommand?.args && event.type === 'Submit command') ||
event.type === 'done.invoke.validateArguments' event.type === 'done.invoke.validateArguments'
) { ) {
const resolvedArgs = {} as { [x: string]: unknown } selectedCommand?.onSubmit(getCommandArgumentKclValuesOnly(event.data))
for (const [argName, argValue] of Object.entries(
getCommandArgumentKclValuesOnly(event.data)
)) {
resolvedArgs[argName] =
typeof argValue === 'function' ? argValue(context) : argValue
}
selectedCommand?.onSubmit(resolvedArgs)
} else { } else {
selectedCommand?.onSubmit() selectedCommand?.onSubmit()
} }
}, },
'Set current argument to first non-skippable': assign({ 'Set current argument to first non-skippable': assign({
currentArgument: (context, event) => { currentArgument: (context) => {
const { selectedCommand } = context const { selectedCommand } = context
if (!(selectedCommand && selectedCommand.args)) return undefined if (!(selectedCommand && selectedCommand.args)) return undefined
const rejectedArg = 'data' in event && event.data.arg
// Find the first argument that is not to be skipped: // Find the first argument that is not to be skipped:
// that is, the first argument that is not already in the argumentsToSubmit // that is, the first argument that is not already in the argumentsToSubmit
// or that is not undefined, or that is not marked as "skippable". // or that is not undefined, or that is not marked as "skippable".
// TODO validate the type of the existing arguments // TODO validate the type of the existing arguments
let argIndex = 0 let argIndex = 0
while (argIndex < Object.keys(selectedCommand.args).length) { while (argIndex < Object.keys(selectedCommand.args).length) {
const [argName, argConfig] = Object.entries(selectedCommand.args)[ const argName = Object.keys(selectedCommand.args)[argIndex]
argIndex
]
const argIsRequired =
typeof argConfig.required === 'function'
? argConfig.required(context)
: argConfig.required
const mustNotSkipArg = const mustNotSkipArg =
argIsRequired && !context.argumentsToSubmit.hasOwnProperty(argName) ||
(!context.argumentsToSubmit.hasOwnProperty(argName) || context.argumentsToSubmit[argName] === undefined ||
context.argumentsToSubmit[argName] === undefined || !selectedCommand.args[argName].skip
(rejectedArg && rejectedArg.name === argName)) if (mustNotSkipArg) {
if (mustNotSkipArg === true) {
return { return {
...selectedCommand.args[argName], ...selectedCommand.args[argName],
name: argName, name: argName,
@ -332,10 +308,14 @@ export const commandBarMachine = createMachine(
} }
argIndex++ argIndex++
} }
// Just show the last argument if all are skippable
// TODO: use an XState service to continue onto review step // TODO: use an XState service to continue onto review step
// if all arguments are skippable and contain values. // if all arguments are skippable and contain values.
return undefined const argName = Object.keys(selectedCommand.args)[argIndex - 1]
return {
...selectedCommand.args[argName],
name: argName,
}
}, },
}), }),
'Clear current argument': assign({ 'Clear current argument': assign({
@ -353,6 +333,8 @@ export const commandBarMachine = createMachine(
'Set current argument': assign({ 'Set current argument': assign({
currentArgument: (context, event) => { currentArgument: (context, event) => {
switch (event.type) { switch (event.type) {
case 'error.platform.validateArguments':
return event.data.arg
case 'Edit argument': case 'Edit argument':
return event.data.arg return event.data.arg
default: default:
@ -361,22 +343,27 @@ export const commandBarMachine = createMachine(
}, },
}), }),
'Remove current argument and set a new one': assign({ 'Remove current argument and set a new one': assign({
currentArgument: (context, event) => {
if (event.type !== 'Change current argument')
return context.currentArgument
return Object.values(event.data)[0]
},
argumentsToSubmit: (context, event) => { argumentsToSubmit: (context, event) => {
if ( if (
event.type !== 'Change current argument' || event.type !== 'Change current argument' ||
!context.currentArgument !context.currentArgument
) )
return context.argumentsToSubmit return context.argumentsToSubmit
const { name } = context.currentArgument const { name, required } = context.currentArgument
if (required)
return {
[name]: undefined,
...context.argumentsToSubmit,
}
const { [name]: _, ...rest } = context.argumentsToSubmit const { [name]: _, ...rest } = context.argumentsToSubmit
return rest return rest
}, },
currentArgument: (context, event) => {
if (event.type !== 'Change current argument')
return context.currentArgument
return Object.values(event.data)[0]
},
}), }),
'Clear argument data': assign({ 'Clear argument data': assign({
selectedCommand: undefined, selectedCommand: undefined,
@ -401,6 +388,11 @@ export const commandBarMachine = createMachine(
}), }),
'Initialize arguments to submit': assign({ 'Initialize arguments to submit': assign({
argumentsToSubmit: (c, e) => { argumentsToSubmit: (c, e) => {
if (
e.type !== 'Select command' &&
e.type !== 'Find and select command'
)
return c.argumentsToSubmit
const command = const command =
'command' in e.data ? e.data.command : c.selectedCommand! 'command' in e.data ? e.data.command : c.selectedCommand!
if (!command.args) return {} if (!command.args) return {}
@ -429,67 +421,38 @@ export const commandBarMachine = createMachine(
}, },
'Validate all arguments': (context, _) => { 'Validate all arguments': (context, _) => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
for (const [argName, argConfig] of Object.entries( for (const [argName, arg] of Object.entries(
context.selectedCommand!.args! context.argumentsToSubmit
)) { )) {
let arg = context.argumentsToSubmit[argName] let argConfig = context.selectedCommand!.args![argName]
let argValue = typeof arg === 'function' ? arg(context) : arg
try { if (
const isRequired = ('defaultValue' in argConfig &&
typeof argConfig.required === 'function' argConfig.defaultValue &&
? argConfig.required(context) typeof arg !== typeof argConfig.defaultValue &&
: argConfig.required argConfig.inputType !== 'kcl') ||
(argConfig.inputType === 'kcl' &&
!(arg as Partial<KclCommandValue>).valueAst) ||
('options' in argConfig &&
typeof arg !== typeof argConfig.options[0].value)
) {
return reject({
message: 'Argument payload is of the wrong type',
arg: {
...argConfig,
name: argName,
},
})
}
const resolvedDefaultValue = if (!arg && argConfig.required) {
'defaultValue' in argConfig return reject({
? typeof argConfig.defaultValue === 'function' message: 'Argument payload is falsy but is required',
? argConfig.defaultValue(context) arg: {
: argConfig.defaultValue ...argConfig,
: undefined name: argName,
},
const hasMismatchedDefaultValueType = })
isRequired &&
typeof argValue !== typeof resolvedDefaultValue &&
!(argConfig.inputType === 'kcl' || argConfig.skip)
const hasInvalidKclValue =
argConfig.inputType === 'kcl' &&
!(argValue as Partial<KclCommandValue> | undefined)?.valueAst
const hasInvalidOptionsValue =
isRequired &&
'options' in argConfig &&
!(
typeof argConfig.options === 'function'
? argConfig.options(context)
: argConfig.options
).some((o) => o.value === argValue)
if (
hasMismatchedDefaultValueType ||
hasInvalidKclValue ||
hasInvalidOptionsValue
) {
return reject({
message: 'Argument payload is of the wrong type',
arg: {
...argConfig,
name: argName,
},
})
}
if (!argValue && isRequired) {
return reject({
message: 'Argument payload is falsy but is required',
arg: {
...argConfig,
name: argName,
},
})
}
} catch (e) {
console.error('Error validating argument', context, e)
throw e
} }
} }

View File

@ -104,7 +104,6 @@ export type ModelingMachineEvent =
| { type: 'Constrain parallel' } | { type: 'Constrain parallel' }
| { type: 'Constrain remove constraints' } | { type: 'Constrain remove constraints' }
| { type: 'Re-execute' } | { type: 'Re-execute' }
| { type: 'Export'; data: ModelingCommandSchema['Export'] }
| { type: 'Extrude'; data?: ModelingCommandSchema['Extrude'] } | { type: 'Extrude'; data?: ModelingCommandSchema['Extrude'] }
| { type: 'Equip Line tool' } | { type: 'Equip Line tool' }
| { type: 'Equip tangential arc to' } | { type: 'Equip tangential arc to' }
@ -120,7 +119,7 @@ export type MoveDesc = { line: number; snippet: string }
export const modelingMachine = createMachine( export const modelingMachine = createMachine(
{ {
/** @xstate-layout N4IgpgJg5mDOIC5QFkD2EwBsCWA7KAxAMICGuAxlgNoAMAuoqAA6qzYAu2qujIAHogC0AdgCsAZgB04gEyjhADnEA2GgoUAWJQBoQAT0QBGGuICckmoZkbTM42YWKAvk91oMOfAQDKYdgAJYLDByTm5aBiQQFjYwniiBBFtpGhlxDRphGg1ZURlhXQMEcRLDSVF5UwV84XEK8Rc3dCw8KElsCEwwHz9A4NCuXAjeGI5B3kTBLWFJDWV5hVSZZTrlBUKjS1FZiUrDeWVDasaQdxb8ds7ugFFcdjAAJ0CAaz9yAAthqNG4iaF07bCNLiRYlGiZUyZDbFYQzfIaLSQ5Sw0TGZQnM6eNodLoEW73J6wV7sD5UQyRZisMbcP4IQRrGiSdRmfIqUxrCrQlbKcoQ4RHDTWUx5DHNLGXXHXPjsB4AVwwX0psXGCSEFThkNEymFGlECkM0MM+0ZMgUa1MwuypgRhlFHlaEpufBYD3YiuiVN+qrpdg0klMwkFaU0ermwuhwlUkiBhxUyKUazt5za3mJH2IZEomFTb0+9BGnpVoESRrNkmMga0ahMplkhqOZVL+sM4ksCj1SfFOZJ70k3Y+AEkrj0AkEugNwvnvoWad7DIGyuqOe2rPNDRCLIKaKJa6JBXrRJ2Hf3eyeh7jkCRXn0oABbMB3fwAN0enHIJEw7p+Rf4RhkpkbapNEjTRUgKfQjA0FsUnBOQJHyJQNCPC4Tz7NN3nPbpL2vII7wfAJ3lQB5sAAL24dgPy-Gd4mLP9A0kVQzW3I18mRGRDXEI1yl1LJDBWYwKhkZCU3QtDc0w4huFgGUSDwfxCOIsi7g-fwIGwaTMzAKjlVnWiECsPdNzSGgqnSAD1GhNJ2QsCpRHBXUAJbYSxJ7FzB2HIgpJkuSX1dbB30wVT1IoigtKnJVqRo399OWBR-TyFdlCgtRBUs1lymsAVYRjPdnNQs8PK8h5ZNwfwAEEACFvH8AANbTItpKx21mcQxEMGxOP-NJLJ1eL41gndagaVxTjFY9RIK3FPNwaTirkyrqoATXqr09Ka7ZrDsZQ5H2ZQtXYiDijUOKgVsvi1ErPKJvQiTptmkr-DIKAuhWn8S3SKQQRBU15H1YRTEsuy4QPbKtX+gMrtzNyMMKmbvNKrp8HYPMKQ9HSove1rpD21QANkTjREsw4Tu1KD-oRBNhEh1zJu6O74f8JhHiZ3A1PIWVMBIJ41I00LXt06KrFNWYLU6jIjlUZRLP1P1+SqAMgQRDQxGpj5oduoqHoU0jyI-TA9EenAoCGcK0YaudlhkJlQ3bAEMhWQ1UR5f9Q1hfItRKVXTxu2H7p819-L1g2P2wY3+YxujzByU1-qdsxUUNbbtj1AMqjWf9HGGpp7RQ67xN9hnYFwEgmH8dhUFq8PGs4qRlhBNQA2qMxLOyK2ZDkeZ5m3Rx2699WC7m0qi5LsuK+W03vwF97oMyfjYVa4wNEs9qZj3Hud3nOYcj72nJLhwf-DAABHWUVMRqBkari3a3KFf-qyPIJHAop51bFqTHBbccdMHefamzW5JMC5nragE9qLV3yBYdu-EdzpE3oaWsMwTCcUOBaTIahDwjUxONKGu96YHweGAW8qAXz+HIAAu4sAr5rWsHFQ41h9qaH2DoQ6RxOIWD4rYbIYIzRCSwWNXOuC-7dAAEpgEEGAPgIRZT3GoYLOoUgWyGFrKkdIYgE6HXUFbdIqRkSdwTLafhOcRJCPzpKE+2BS4ABk8BgFHqgT8YD0aNQUNZCQKgbDCnSBZTRdReSK3bnuPaRpf5mJuBY0uIUYB3GwCpLm5BR5yMSFtOKIIPZ8V+go+slhoxrFUJkhESU+5lQAO6yQIkRHWylAo8xCpQfweAABmqACAQG4GAdouAnyoFeJIGA7BBDayUhRTAggmmoCSYgYyfpCkZADGYBW4hLJQXMO2U0aROEdypkY5M0NJClPKfJSpwyVK1M0g03AzSCCPAeERSQTBObsGaQ8W8fS-CDOObrUZ4zJkIGMtsI0NoWy6lNAGSy31Nw0FUCYPIkZDHZ12ahA5HBnwBwCkFXm9TxmtPaZ07pvT+mCF8m+D8YzLkTKcebPSGyrYtkUPYZEyj8hpSUEyMQcg7ClCBMUspKLiWBxqcFc52Kbl3IeSQJ5RFXmEv5QFMlzTfk0qZNuMQAExCaDSoKcsONVFaOZDyw5C1aoXKuW03AHS8D4o6YSkgAAjWAgg+DyopajSeEc-koNyTIWCcg6jrEOv+ba5YMhQSSrIbatQDUoqNTVE1LTRUPHuY8550r3l2odU6n5lLVrRQ2TMHIIIlA7m3FoHqfFpB6j2pxbIgYf47K7KJZFAQjWLTjTi81eKenWrTfawQehnWKs9XUeQFRbCIN1NCVsyIRauLsnqVsuV604Nck28qVV-CtpFQ8W5ibxWSpeW8gZ6a+0DuzW9KZnEkGsVassFYobJ18T9FC9k20do3qjQEJ6XQ21motV0rth7BBfvEVm114DvR5pSEaOocgLQaKKHBHkKi7JGgqGaWEH7Hr4G-VundSaJUpsA8B09YHnEQc4idbITUrAN3nIDec5YWzyFSHO5Y2yEUNqhqu8+yMf24stQBwlPH3gkYLGR6lBMWpWTJluZlAaOQWBWJelQ84rCYeE22hN+H92poGcJ0T05xO5s9cYGF+o1jKwOghpKVt5i0OtGIFE6Il2CJXbygITMHgszZhzLmGK6l2Oxb+ztBL3mee8-5XzDxBBnNCgZiKObkmW3ikoawr706EwDdUbYC85h7Tg76zD4WHw+c5tzIVoVNPbrFcmqVgHius0i2VmLFXKDxbNolqZyXhSpbmNA7LllsrSH5HlncwpCsuZMa5Gx5r7GYBHH0cccRFXeqjjRo4mQjh5CWQGgM5YesmVoYxPhHHl1qxm3Y8uDi8QRLLk9fCsTArxMSWeqeUzYR+n2GYcE6h5ZS0OgrFqrVHBZHZH3C7c3JADlwBwAgK2VBMjSRt60sGuT6kkMGWskJWr-TSOD2xkPoew7JKRqlub8hWzsqaR9NHtQRn+rMFYEhbBakhNufHs2ruYEkLgKVH4FtjhCMt177rrC1GkKafUvCMhan+0UJKMwFzKz1O1KCnEOeXdQA4yQAA5CuAAFVAeB2CwAIGVCAEBAgUVdIzI3dxFXzD9JqfLgpFhJS5NqFqqcsiKCUAoDXkO9f+EN8b03pBQqONJ51-SmhzAmGDKtrQep-VFF4iLVBpRTQrD7kT9gcOReNUsDyJPWQVDbkT4aYEjPIzLChboqCOeYd55J2JsnJZmJMkhJCOYshwTtUrwBaMts5irg6s507rm1YABV7sxLiQ8BJ5cBf9GF1H89fztrmG1EoLxh3-yV6gkyR93rLC6g7JNvZM-8APfn4vlp1xbsQ65w7hE79FA7jsttaE1oeRuzSMxqFOYdjUaYxPZWUVmCuDSV0E8AAeVwHbT-StX2W8Cn0EHALaUECgPYFgJNjXze30nxkZxMF1BMkyEWGfkQB3DihXDQzpU8UwQnymw+H8F538EaRIEoB6CW1UjAHYI5g805nNV+UEHanMDSRhSNEDAkGUS5DkH9BsEODmEsDgmAOwQuDIGwFvAlVaFHkZkEO6GCwE16Q0K0PuEEHLkEHYMoF+QqEAjIOyB9ShEOm2lSSsDBFBUDHUGchMO0PwF0PFXNWXyW0GEVSBEYw5R3EYkDGhDXnLFWDsHBBWBqG8Jh1MJ0PLkPj4GCh0KJFzAQJCw6R8LMIsNyJ7BsL2gYisFqAtHbjSFhGhCghywSkAN9BcKQhOF5wwHgCiDUKgFb2j0EEWDKFyEUDL3UC0B2yKEGJNC1EsFqCFGMDNGchxDAH6PX3pC0HKGRHmRDGAmhHUEXFbH1GkOMmUS9jWPwPZDihLxMHyQr0OiQQWGtEGlsHmAwwv3ymEQuPdRbAtAxySn1GB19UyxfiZTiNalSGqAAmhMwyGS+X82FXJW+JcTWFmDmUUH5BqFSHBWSABFbChW1HyHhRAMRUbXc1RT8nRViyxSRMMzbyMC0W1T7wUU3wNADTkEZFSCghlnmFsMwxjTjWRLnFcSkDUE6lrG2lMwBl23YV1HjFUHOj1BOxJM4zc0NXXU3VpIS3XyOGSAShyA21SkOinQ2jghHSqChMw2A0FLpOj11O2BWHshKCqDyFrEBjUCZGDAqFUBrzqHUwfAvneBtO1PwN1MZAyFWBVwlLXHkwR3+mUUcG2LlKzhVLO17FXQa1Kz82pMCy1I6x1PbF-3ZDslAgUJKEG3RzlnbAAnVDBw+NEify10wCFOpXsAsCGnbBsHUDWBiKjG5MEmQVtnH1TMn17EbO11zxbNzX2DKEyBKE7ItJ7OcOSD4jvR72721ADy5x5z52bNtPXyFHzXnn-GrFkCs0QDTwUOUUzzNFkC3KbN1wNztxNynOSV1FriUHan1HjxUEmIvKDWp2gyOEjC0GJN6L2UnP3NDO3C311EFAjJ3371YQbEYwOJbDbHP0YMv1n04Fv1HlfKmVDGjBjBUB+nBnXDyH+LNA5AfgMj7nQMgOt2wPQjgIIv0nNHKDqCTyZ1S2lKKCCUZyZTP3jC1C9hYIrisNWKgvdSmDkIkBWVUDqH+i-2cLkKgnsDnQmNNBSM0N8KgH8P0LYr4ikADG1CtHkFp1kJ5BMHUEjDXiONUIETaCKPSIrkkWyL8NKI+CMscAx1hCYlanZE5UnUjF5HsHIMswAnaKwpcr8IyICLsQAApyEmA9AABKNikQCQCXTJRiTQFhIoHhRTF9QJOlZU8C2K-S+K-Q-wZK1AVKtK2qlK9KzKsQT6OQUYvKiYydBESnHcawA0muYUFwFwIAA */ /** @xstate-layout N4IgpgJg5mDOIC5QFkD2EwBsCWA7KAxAMICGuAxlgNoAMAuoqAA6qzYAu2qujIAHogC0AdgCsAZgB04gEyjhADnEA2GgoUAWJQBoQAT0QBGGuICckmoZkbTM42YWKAvk91oMOfAQDKYdgAJYLDByTm5aBiQQFjYwniiBBFtpGhlxDRphGg1ZURlhXQMEcRLDSVF5UwV84XEK8Rc3dCw8KElsCEwwHz9A4NCuXAjeGI5B3kTBLWFJDWV5hVSZZTrlBUKjS1FZiUrDeWVDasaQdxb8ds7ugFFcdjAAJ0CAaz9yAAthqNG4iaF07bCNLiRYlGiZUyZDbFYQzfIaLSQ5Sw0TGZQnM6eNodLoEW73J6wV7sD5UQyRZisMbcP4IQRrGiSdRmfIqUxrCrQlbKcoQ4RHDTWUx5DHNLGXXHXPjsB4AVwwX0psXGCSEohWO3Uh2UOSsmmhhn2jPyNGUolMhjWwvZoo8rUk3mJH2IZEomEdb0+9BGVN+qoQhoUPOMwgRi1BplkBqOZUDCkN4ksClEClt5zaHpJ7wdTveAEkrj0AkEugNwt7vr6VaBEoZQ2UKsIOcmrPMDRCLIKaOa6oKU6I0+LMx8c56C7jkCRXn0oABbMB3fwAN0enHIJEwiuiVZp-qsFskRxkmibmlSBX0Rg0hikifBcgk+SUGkH9uH2ff4+6k+nQTnC4Cd5UAebAAC9uHYDctx+at+CMeFJFUINu0NfJkRkA1xENcoNHkSwVmMCoZFfC531HLMv2IbhYBlEg8H8ICQPAu4N38CBsBo10wGgnd4hreDRA0Ts0hoKp0gtdRoTSdkLAqURwVwi0bxIjNc3Ij5KKIajaPolcHjXVj2M4ihuIrJVqT4uCA2WBRJGFY9UR1YxNAwy8EBkVlymsAVYSBM0X1cU4xTfNTP0LLTcBoh46NwfwAEEACFvH8AANHjlV3fjrOTWZxDEQwbCwmRbHEKThSE4VkUWVJzVqBpAsxELPXU-Nwu06L6MS5KAE10os2k9W2aw7GUOR9jNUboUTdRJCBOTLTUUMAqaO1SNC3NNPamL-DIKAuj6v0spvHJpCUapk0UOtTCk+S4X7Xz1WEUxTGEFSWpazbIp02KunwdgvQpbcMss2sShmFR1VEm80n2KTDlsptTGvJ6wxUV6GuCtbmrC3EIqi7amEeQncHY8hZUwEgniMyCTIO2Daw82ybEjKwMiOVRlCk+MhP5KoXqBBENDEN6yJx7o8e+hjgLAiCN0wPQdpwKAhjMoH+r3ZYZCZFMtAkXCMhWA1HMkYqddhfJ1RKEX1rHNqvo62K9IMzB5cV7BlbpzKrKsJ7cuPJ7HLMVEDVG7YUxeqo1mKxx6pW9N3rFqj7e22BcBIJh-HYVBUs9kGjCwqRlhBNQXuqMwpOyLWPPVeZVBTIFiIx1bVOxja7fx+jU-TzPs961WYK90GbwsJssNhPLjA0KSCpmQTHDkC1Qx1WOgubhO29xrb6LAABHWVWN+qB-tzgbiqkVEhaerI8gkC8ijrRNcpMcFuwm0xrdb23N+T+imEpuXqD914gNWQMwlgER7MjKeblDCRjAaUQ4z1MhqAHE3eOosN7iy3rFB4YBZyoBXP4cg2D2CwBPhrLQiFWbqmPNeFMpUYFKDKPhWw2QwRBkbnHIcNsKKFgAEpgEEGAPgIRZT3HIUdOoUgbywMTGkIWFRDDQnUFrdIqRkS1yUIcD+WYPqFmuHvbAGcAAyeAwA91QJuIBwMBoKBkhIFQzM9aSTckoMOmRhpyDmKiQwOiRyJwMbKIxmddoAWwKxSm5Ae4SKsiNWyIJLaWnkG4hh99jBlFPHXRQ6Q5jLVXugtScUADudFALS2YpBTAbEOI00oP4PAAAzVABAIDcDAO0XAS5UCvEkDAdgghGIyxYpgQQjTUAxMSCJISCIdSiVqM9ceUlrzmGTMeGGyhbCPT8dmYppSpZMVllU6mXF6m4CaQQR4DxgKSCYBTdgTSHizl6X4AZ5TDmjLOeM6x6ssoiW2IaBECZcLHhelJEEWtsimnBHUNCvi0HcOarsjgy5VzYHXEcmpJyxktLaR0rpPS+mCCdmijcHymkTMQDDLWN5LomGRLA-IUljxSEcDfOwpQgTbMkEigIxL0XVOMnU7Flzrm3JIPc4CTzCV8tJWMil7kipMm7GIReOsmWCkPBNVIIJzpKC5Ty+KSVUqnPOa03A7S8D4vaYSkgAAjWAgg+Bkq+YDAeecFWWlmqNe8cg6jrDcsVUah4MjXmXprWo+qSnIq6sa4VDwrkPBuXch5UqXl2odU6uV3zDqxLHrlHVdRIRzzKp6v1ZosLZFDO-eFTVdEGpjd1E1zSzUWs6d061ab7WCD0M6+V6zpCNgqLYOBuEpqmhmDYOx8l6HdjyY1LGdao0BAbU2i58bRXJslc8-p6bu29uzfTSlebzwrD8isUNU1LRCVNOyUaY08qcPyQixdezdpdFXS2vF7bt2CDfYIrNrrgH+hhuDSwN4b7PWDgGioPJIzdjAxUIMsJI2vvwO+uNCak3ipTT+v9+7AM2OA1hBG2Q9RWBLnWG6dZDzgayHIbsyx0ZcNrSOA1h9-oftxZa79hL2PvHwz6QjvysLbByNJKBMhzxwxyqaDlKg6xWBQ8ivjq6RWJrFRKx5P6+MCcrEJ3NnrjAmBTJaE8rkijLA1fMawdiFEonRDWhdrGl3+EJg8YmpNyaUwFbU8x2LP3cYJS8tzHm0VeYeIIY5JldPmRzZMzWdl6HWDvVHUQXM5DSH5HMM0kHfVKYCCFhcnmKZU0xSZVT671Obq04SwrJMwslci2VygMW1ZxcpQl4UShkseVS1JXymWCrzHNPZCQXLTHmosZgIsfRSxxD7ZJ8w15JNHEyEcPIqTKUvUPF10SNmkKPvnS3XRE3zFZ0sXiQxGcaYwDuOEqpkTokHsHpS2EQl9hmHBOoPmnM3L83zYoWEokHPMac9mU7U3JB5lwBwAgC2VBMgSatpGC8uTxhNiCSMkI8pPTSONsxkPoew7JARn5sT8ha3kseK95GNnQlhEtlYEhNkbPBKg0Hx2RwQ-O5gSQuBJUbhmyWEI83nvuusLUaQx54wcIyDXLkQtZrXgUdza8WF8eTZ55IAActnAACqgPApCCBxQgBAQIkF9KucN3cPt8wKqv3NIKRYOouQbNyhHLIigzoa7O6gSxOv9c2+N6QEyVjSftYDJocwJg0jVFErrFxRQshlBsIg0ox4VhcqJ+wOHYuBr4VmBdOlDGE8GmBLMDRywoWWbhRz96Oe4fkkE2T2sKEmSQkhHMWQ4ICrl4PIoPWhxLORhB0+lj2YAAqoS7sRIeFErOQv+ii4j4e9yo1zAbKUMKRMthirl+vEyK9knLC4RTFy6f+Awlz4X80wJwTuf+-Dy3yPlnpmJkH8KU05nEBIx5ObNIPCU0OYJjcfMHSQWUEmbOTifSd8AAeVwBxXNS-R6Tim8En0EEgNaUEBgPYHgJVlXxewDFkCkDmBMFwlEkyEWDvkQHNFshbEQxpScW2X8H538AaRIEoB6DmzYjAA4PJgKwpnNXlUEAKnMASWM0NFDAkFgS5AyyRnZCG0sAfFAKO0kDIGwFnHFVaB7lcyEO6ACzbR6Q0K0PuEECzkEA4MoHlUUUPGoMWAyCWChDclGniSsDBBBVDHUDehMO0PwF0LFXNSXzm0GD7SBBozkAXiQlDGhDnkPFWDsHBBWBqB8Jh1MJ0Kzn8GERqR0KJE9CQNbStXULSPFUEQsLyKzBsLNCoVZGeg8jSFhGhDoQHWPGALsDmFoRcECn5wwHgCiCOxfzX0EEWDKFyGySQk0B0DcmGMZAcMk3kmfnrDHzUJxDAEGKIPpEoUehehBFwhPGUXRwkDUA+3cMsGrXr3fHWPdXZCZmLxUFLyRi5EQi62sETHSGqC2Uc05w-EwSuJAWehNh1HjDyg+LqGjGSBvDylSGqAtFhPy32SGUqR8yxU+T+L3CDCZgyFLn5BqFSDBWSABDvHmFsH5HhJlQxUFT81RL01byMBUU1V7ykQ3yUQDXowsG8m5mG1RHhJjRSibTRKOjsSkDUCKlH0SNEjKiwh2CqlUAWhTEO0xm+O5RcxXTGQFO9jsUp1oWI0rjHWqOrkHSqBhPhL-X5JpMjyOGFEQjvByAcDyEjBujUCZDjxg0yFGjqHhJUzVPNLX0tMZAyFWBM1HzbADW5FmlhMcGRCRktnhLq2K28yiyFWpNi19OTH-3ZHkjPDTxKH63R15mTAtEbBtC+Pekf0sXVMmXsBHhKGTEnQxNiNUBNmVxhJsHozrzAKVLLN5xzwrMpX2GYTqlrKNLWC5AhPmByRWC7w2V90h350eQ3F7PchsAnXHmKjUFjx-wQBT1mEUMjCKiDFkBnK11138ANyNz6JTKIOsAkBNiYS0HsDSENjcibCrnjATCOCbHvOzxh3YEXMIk331msBMG3z7xgRjBo3UATCTHPxLLIkv1u04Bvx7kXLkE0Fmj8hUAciemuhgQY0BKDA5GvisHZw7PeiwOgMtzwNzAQL-KtHKF7DTL9VkBwqKEEh5DmAZTPyqnVBYLYKsLWJ9I2L7HouWVUDqCekmhcIy2vHsGnS0FkFTBLN8M4H8MyMCIEsvPdUtCkBelZxsHkFpzkJ5GAsjLnmmlUMVOKM0L8KgF0OyJolyNzD-McBNlhGQjynZHZSmibF5HsGoKFmKgKi6KcCAA */
id: 'Modeling', id: 'Modeling',
tsTypes: {} as import('./modelingMachine.typegen').Typegen0, tsTypes: {} as import('./modelingMachine.typegen').Typegen0,
@ -171,13 +170,6 @@ export const modelingMachine = createMachine(
actions: ['AST extrude'], actions: ['AST extrude'],
internal: true, internal: true,
}, },
Export: {
target: 'idle',
internal: true,
cond: 'Has exportable geometry',
actions: 'Engine export',
},
}, },
entry: 'reset client scene mouse handlers', entry: 'reset client scene mouse handlers',
@ -538,9 +530,6 @@ export const modelingMachine = createMachine(
entry: 'clientToEngine cam sync direction', entry: 'clientToEngine cam sync direction',
}, },
'animating to plane (copy)': {},
'animating to plane (copy) (copy)': {},
}, },
initial: 'idle', initial: 'idle',
@ -840,13 +829,13 @@ export const modelingMachine = createMachine(
sceneInfra.setCallbacks({ sceneInfra.setCallbacks({
onClick: async (args) => { onClick: async (args) => {
if (!args) return if (!args) return
if (args.mouseEvent.which !== 1) return if (args.event.which !== 1) return
const { intersectionPoint } = args const { intersection2d } = args
if (!intersectionPoint?.twoD || !sketchPathToNode) return if (!intersection2d || !sketchPathToNode) return
const { modifiedAst } = addStartProfileAt( const { modifiedAst } = addStartProfileAt(
kclManager.ast, kclManager.ast,
sketchPathToNode, sketchPathToNode,
[intersectionPoint.twoD.x, intersectionPoint.twoD.y] [intersection2d.x, intersection2d.y]
) )
await kclManager.updateAst(modifiedAst, false) await kclManager.updateAst(modifiedAst, false)
sceneEntitiesManager.removeIntersectionPlane() sceneEntitiesManager.removeIntersectionPlane()

View File

@ -72,7 +72,7 @@ const Home = () => {
} }
) )
const [state, send, actor] = useMachine(homeMachine, { const [state, send] = useMachine(homeMachine, {
context: { context: {
projects: loadedProjects, projects: loadedProjects,
defaultProjectName, defaultProjectName,
@ -176,7 +176,6 @@ const Home = () => {
send, send,
state, state,
commandBarConfig: homeCommandBarConfig, commandBarConfig: homeCommandBarConfig,
actor,
}) })
useEffect(() => { useEffect(() => {

View File

@ -21,7 +21,7 @@ export default function Export() {
<section className="flex-1"> <section className="flex-1">
<h2 className="text-2xl font-bold">Export</h2> <h2 className="text-2xl font-bold">Export</h2>
<p className="my-4"> <p className="my-4">
Try opening the project menu and clicking "Export Part". Try opening the project menu and clicking "Export Model".
</p> </p>
<p className="my-4"> <p className="my-4">
{APP_NAME} uses{' '} {APP_NAME} uses{' '}

View File

@ -1990,12 +1990,11 @@ dependencies = [
[[package]] [[package]]
name = "kittycad-execution-plan" name = "kittycad-execution-plan"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/KittyCAD/modeling-api?branch=main#03eb9c3763de56d7284c09dba678ddd6120bb523" source = "git+https://github.com/KittyCAD/modeling-api?branch=main#ada70b2e6c89438385963eda90d04baa2f9a9cca"
dependencies = [ dependencies = [
"bytes", "bytes",
"insta", "insta",
"kittycad", "kittycad",
"kittycad-execution-plan-macros",
"kittycad-execution-plan-traits", "kittycad-execution-plan-traits",
"kittycad-modeling-cmds", "kittycad-modeling-cmds",
"kittycad-modeling-session", "kittycad-modeling-session",
@ -2010,7 +2009,7 @@ dependencies = [
[[package]] [[package]]
name = "kittycad-execution-plan-macros" name = "kittycad-execution-plan-macros"
version = "0.1.8" version = "0.1.8"
source = "git+https://github.com/KittyCAD/modeling-api?branch=main#03eb9c3763de56d7284c09dba678ddd6120bb523" source = "git+https://github.com/KittyCAD/modeling-api?branch=main#ada70b2e6c89438385963eda90d04baa2f9a9cca"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@ -2020,7 +2019,7 @@ dependencies = [
[[package]] [[package]]
name = "kittycad-execution-plan-traits" name = "kittycad-execution-plan-traits"
version = "0.1.12" version = "0.1.12"
source = "git+https://github.com/KittyCAD/modeling-api?branch=main#03eb9c3763de56d7284c09dba678ddd6120bb523" source = "git+https://github.com/KittyCAD/modeling-api?branch=main#ada70b2e6c89438385963eda90d04baa2f9a9cca"
dependencies = [ dependencies = [
"serde", "serde",
"thiserror", "thiserror",
@ -2030,7 +2029,7 @@ dependencies = [
[[package]] [[package]]
name = "kittycad-modeling-cmds" name = "kittycad-modeling-cmds"
version = "0.1.28" version = "0.1.28"
source = "git+https://github.com/KittyCAD/modeling-api?branch=main#03eb9c3763de56d7284c09dba678ddd6120bb523" source = "git+https://github.com/KittyCAD/modeling-api?branch=main#ada70b2e6c89438385963eda90d04baa2f9a9cca"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"chrono", "chrono",
@ -2058,7 +2057,7 @@ dependencies = [
[[package]] [[package]]
name = "kittycad-modeling-cmds-macros" name = "kittycad-modeling-cmds-macros"
version = "0.1.2" version = "0.1.2"
source = "git+https://github.com/KittyCAD/modeling-api?branch=main#03eb9c3763de56d7284c09dba678ddd6120bb523" source = "git+https://github.com/KittyCAD/modeling-api?branch=main#ada70b2e6c89438385963eda90d04baa2f9a9cca"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@ -2068,7 +2067,7 @@ dependencies = [
[[package]] [[package]]
name = "kittycad-modeling-session" name = "kittycad-modeling-session"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/KittyCAD/modeling-api?branch=main#03eb9c3763de56d7284c09dba678ddd6120bb523" source = "git+https://github.com/KittyCAD/modeling-api?branch=main#ada70b2e6c89438385963eda90d04baa2f9a9cca"
dependencies = [ dependencies = [
"futures", "futures",
"kittycad", "kittycad",

View File

@ -105,10 +105,6 @@ impl BindingScope {
"startSketchAt".into(), "startSketchAt".into(),
EpBinding::from(KclFunction::StartSketchAt(native_functions::sketch::StartSketchAt)), EpBinding::from(KclFunction::StartSketchAt(native_functions::sketch::StartSketchAt)),
), ),
(
"lineTo".into(),
EpBinding::from(KclFunction::LineTo(native_functions::sketch::LineTo)),
),
]), ]),
parent: None, parent: None,
} }

View File

@ -252,7 +252,6 @@ impl Planner {
} = match callee { } = match callee {
KclFunction::Id(f) => f.call(&mut self.next_addr, args)?, KclFunction::Id(f) => f.call(&mut self.next_addr, args)?,
KclFunction::StartSketchAt(f) => f.call(&mut self.next_addr, args)?, KclFunction::StartSketchAt(f) => f.call(&mut self.next_addr, args)?,
KclFunction::LineTo(f) => f.call(&mut self.next_addr, args)?,
KclFunction::Add(f) => f.call(&mut self.next_addr, args)?, KclFunction::Add(f) => f.call(&mut self.next_addr, args)?,
KclFunction::UserDefined(f) => { KclFunction::UserDefined(f) => {
let UserDefinedFunction { let UserDefinedFunction {
@ -620,7 +619,6 @@ impl Eq for UserDefinedFunction {}
enum KclFunction { enum KclFunction {
Id(native_functions::Id), Id(native_functions::Id),
StartSketchAt(native_functions::sketch::StartSketchAt), StartSketchAt(native_functions::sketch::StartSketchAt),
LineTo(native_functions::sketch::LineTo),
Add(native_functions::Add), Add(native_functions::Add),
UserDefined(UserDefinedFunction), UserDefined(UserDefinedFunction),
} }

View File

@ -2,5 +2,6 @@
pub mod helpers; pub mod helpers;
pub mod stdlib_functions; pub mod stdlib_functions;
pub mod types;
pub use stdlib_functions::{LineTo, StartSketchAt}; pub use stdlib_functions::StartSketchAt;

View File

@ -1,4 +1,4 @@
use kittycad_execution_plan::{api_request::ApiRequest, Destination, Instruction}; use kittycad_execution_plan::{api_request::ApiRequest, Instruction};
use kittycad_execution_plan_traits::{Address, InMemory}; use kittycad_execution_plan_traits::{Address, InMemory};
use kittycad_modeling_cmds::{id::ModelingCmdId, ModelingCmdEndpoint}; use kittycad_modeling_cmds::{id::ModelingCmdId, ModelingCmdEndpoint};
@ -120,13 +120,11 @@ pub fn arg_point2d(
instructions.extend([ instructions.extend([
Instruction::Copy { Instruction::Copy {
source: single_binding(elements[0].clone(), "startSketchAt", "number", arg_number)?, source: single_binding(elements[0].clone(), "startSketchAt", "number", arg_number)?,
destination: Destination::Address(start_x), destination: start_x,
length: 1,
}, },
Instruction::Copy { Instruction::Copy {
source: single_binding(elements[1].clone(), "startSketchAt", "number", arg_number)?, source: single_binding(elements[1].clone(), "startSketchAt", "number", arg_number)?,
destination: Destination::Address(start_y), destination: start_y,
length: 1,
}, },
Instruction::SetPrimitive { Instruction::SetPrimitive {
address: start_z, address: start_z,

View File

@ -1,8 +1,4 @@
use kittycad_execution_plan::{ use kittycad_execution_plan::{api_request::ApiRequest, Instruction};
api_request::ApiRequest,
sketch_types::{self, Axes, BasePath, Plane, SketchGroup},
Destination, Instruction,
};
use kittycad_execution_plan_traits::{Address, InMemory, Value}; use kittycad_execution_plan_traits::{Address, InMemory, Value};
use kittycad_modeling_cmds::{ use kittycad_modeling_cmds::{
shared::{Point3d, Point4d}, shared::{Point3d, Point4d},
@ -10,85 +6,12 @@ use kittycad_modeling_cmds::{
}; };
use uuid::Uuid; use uuid::Uuid;
use super::helpers::{arg_point2d, no_arg_api_call, single_binding, stack_api_call}; use super::{
helpers::{arg_point2d, no_arg_api_call, single_binding, stack_api_call},
types::{Axes, BasePath, Plane, SketchGroup},
};
use crate::{binding_scope::EpBinding, error::CompileError, native_functions::Callable, EvalPlan}; use crate::{binding_scope::EpBinding, error::CompileError, native_functions::Callable, EvalPlan};
#[derive(Debug, Clone)]
#[cfg_attr(test, derive(Eq, PartialEq))]
pub struct LineTo;
impl Callable for LineTo {
fn call(&self, next_addr: &mut Address, args: Vec<EpBinding>) -> Result<EvalPlan, CompileError> {
let mut instructions = Vec::new();
let fn_name = "lineTo";
// Get both required params.
let mut args_iter = args.into_iter();
let Some(to) = args_iter.next() else {
return Err(CompileError::NotEnoughArgs {
fn_name: fn_name.into(),
required: 2,
actual: 0,
});
};
let Some(sketch_group) = args_iter.next() else {
return Err(CompileError::NotEnoughArgs {
fn_name: fn_name.into(),
required: 2,
actual: 1,
});
};
// Check the type of both required params.
let to = arg_point2d(to, fn_name, &mut instructions, next_addr, 0)?;
let sg = single_binding(sketch_group, fn_name, "sketch group", 1)?;
let id = Uuid::new_v4();
let start_of_line = next_addr.offset(1);
let length_of_3d_point = Point3d::<f64>::default().into_parts().len();
instructions.extend([
// Push the `to` 2D point onto the stack.
Instruction::Copy {
source: to,
length: 2,
destination: Destination::StackPush,
},
// Make it a 3D point.
Instruction::StackExtend { data: vec![0.0.into()] },
// Append the new path segment to memory.
// First comes its tag.
Instruction::SetPrimitive {
address: start_of_line,
value: "Line".to_owned().into(),
},
// Then its end
Instruction::StackPop {
destination: Some(start_of_line + 1),
},
// Then its `relative` field.
Instruction::SetPrimitive {
address: start_of_line + 1 + length_of_3d_point,
value: false.into(),
},
// Send the ExtendPath request
Instruction::ApiRequest(ApiRequest {
endpoint: ModelingCmdEndpoint::ExtendPath,
store_response: None,
arguments: vec![
// Path ID
InMemory::Address(sg + SketchGroup::path_id_offset()),
// Segment
InMemory::Address(start_of_line),
],
cmd_id: id.into(),
}),
]);
// TODO: Create a new SketchGroup from the old one + add the new path, then store it.
Ok(EvalPlan {
instructions,
binding: EpBinding::Single(Address::ZERO + 9999),
})
}
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
#[cfg_attr(test, derive(Eq, PartialEq))] #[cfg_attr(test, derive(Eq, PartialEq))]
pub struct StartSketchAt; pub struct StartSketchAt;
@ -185,9 +108,9 @@ impl Callable for StartSketchAt {
name: Default::default(), name: Default::default(),
}, },
path_rest: Vec::new(), path_rest: Vec::new(),
on: sketch_types::SketchSurface::Plane(Plane { on: super::types::SketchSurface::Plane(Plane {
id: plane_id, id: plane_id,
value: sketch_types::PlaneType::XY, value: super::types::PlaneType::XY,
origin, origin,
axes, axes,
}), }),

View File

@ -0,0 +1,133 @@
use kittycad_execution_plan::Instruction;
use kittycad_execution_plan_macros::ExecutionPlanValue;
use kittycad_execution_plan_traits::{Address, Value};
use kittycad_modeling_cmds::shared::{Point2d, Point3d, Point4d};
use uuid::Uuid;
/// A sketch group is a collection of paths.
#[derive(Clone, ExecutionPlanValue)]
pub struct SketchGroup {
/// The id of the sketch group.
pub id: Uuid,
/// What the sketch is on (can be a plane or a face).
pub on: SketchSurface,
/// The position of the sketch group.
pub position: Point3d,
/// The rotation of the sketch group base plane.
pub rotation: Point4d,
/// The X, Y and Z axes of this sketch's base plane, in 3D space.
pub axes: Axes,
/// The plane id or face id of the sketch group.
pub entity_id: Option<Uuid>,
/// The base path.
pub path_first: BasePath,
/// Paths after the first path, if any.
pub path_rest: Vec<Path>,
}
impl SketchGroup {
pub fn set_base_path(&self, sketch_group: Address, start_point: Address, tag: Option<Address>) -> Vec<Instruction> {
let base_path_addr = sketch_group
+ self.id.into_parts().len()
+ self.on.into_parts().len()
+ self.position.into_parts().len()
+ self.rotation.into_parts().len()
+ self.axes.into_parts().len()
+ self.entity_id.into_parts().len()
+ self.entity_id.into_parts().len();
let mut out = vec![
// Copy over the `from` field.
Instruction::Copy {
source: start_point,
destination: base_path_addr,
},
// Copy over the `to` field.
Instruction::Copy {
source: start_point,
destination: base_path_addr + self.path_first.from.into_parts().len(),
},
];
if let Some(tag) = tag {
// Copy over the `name` field.
out.push(Instruction::Copy {
source: tag,
destination: base_path_addr
+ self.path_first.from.into_parts().len()
+ self.path_first.to.into_parts().len(),
});
}
out
}
}
/// The X, Y and Z axes.
#[derive(Clone, Copy, ExecutionPlanValue)]
pub struct Axes {
pub x: Point3d,
pub y: Point3d,
pub z: Point3d,
}
#[derive(Clone, ExecutionPlanValue)]
pub struct BasePath {
pub from: Point2d<f64>,
pub to: Point2d<f64>,
pub name: String,
}
/// A path.
#[derive(Clone, ExecutionPlanValue)]
pub enum Path {
/// A path that goes to a point.
ToPoint { base: BasePath },
/// A arc that is tangential to the last path segment that goes to a point
TangentialArcTo {
base: BasePath,
/// the arc's center
center: Point2d,
/// arc's direction
ccw: bool,
},
/// A path that is horizontal.
Horizontal {
base: BasePath,
/// The x coordinate.
x: f64,
},
/// An angled line to.
AngledLineTo {
base: BasePath,
/// The x coordinate.
x: Option<f64>,
/// The y coordinate.
y: Option<f64>,
},
/// A base path.
Base { base: BasePath },
}
#[derive(Clone, Copy, ExecutionPlanValue)]
pub enum SketchSurface {
Plane(Plane),
}
/// A plane.
#[derive(Clone, Copy, ExecutionPlanValue)]
pub struct Plane {
/// The id of the plane.
pub id: Uuid,
// The code for the plane either a string or custom.
pub value: PlaneType,
/// Origin of the plane.
pub origin: Point3d,
pub axes: Axes,
}
/// Type for a plane.
#[derive(Clone, Copy, ExecutionPlanValue)]
pub enum PlaneType {
XY,
XZ,
YZ,
Custom,
}

View File

@ -1048,30 +1048,15 @@ fn store_object_with_array_property() {
#[tokio::test] #[tokio::test]
async fn stdlib_cube_partial() { async fn stdlib_cube_partial() {
let program = r#" let program = r#"
let cube = startSketchAt([0.0, 0.0]) let cube = startSketchAt([22.0, 33.0])
|> lineTo([4.0, 0.0], %)
"#; "#;
let (_plan, _scope) = must_plan(program); let (plan, _scope) = must_plan(program);
std::fs::write("stdlib_cube_partial.json", serde_json::to_string_pretty(&plan).unwrap()).unwrap();
let ast = kcl_lib::parser::Parser::new(kcl_lib::token::lexer(program)) let ast = kcl_lib::parser::Parser::new(kcl_lib::token::lexer(program))
.ast() .ast()
.unwrap(); .unwrap();
let client = test_client().await; let mem = crate::execute(ast, Some(test_client().await)).await.unwrap();
let _mem = crate::execute(ast, Some(client)).await.unwrap(); dbg!(mem);
// use kittycad_modeling_cmds::{each_cmd, ok_response::OkModelingCmdResponse, ImageFormat, ModelingCmd};
// let out = client
// .run_command(
// uuid::Uuid::new_v4().into(),
// each_cmd::TakeSnapshot {
// format: ImageFormat::Png,
// },
// )
// .await
// .unwrap();
// let out = match out {
// OkModelingCmdResponse::TakeSnapshot(b) => b,
// other => panic!("wrong output: {other:?}"),
// };
// let out: Vec<u8> = out.contents.into();
} }
async fn test_client() -> Session { async fn test_client() -> Session {