Compare commits
27 Commits
achalmers/
...
v0.24.3
Author | SHA1 | Date | |
---|---|---|---|
cba953c245 | |||
54ca6ea0b2 | |||
6a01608c3a | |||
530f15e04a | |||
725e59d987 | |||
54313c9b03 | |||
890d96496c | |||
35999366a7 | |||
2affc7271d | |||
d30fbf8b4b | |||
3f7e776464 | |||
79cff57f43 | |||
1cd2cd82b2 | |||
60e187bd3e | |||
c64175425b | |||
36464e6984 | |||
2f0002e53c | |||
482833c88f | |||
d9d0a72306 | |||
65cd9fab64 | |||
5e41e382ce | |||
1e3cb00092 | |||
d1a2bd01ca | |||
aca13d087b | |||
fcdde3e482 | |||
a1df3d0ffc | |||
1852e6167b |
37
.github/ISSUE_TEMPLATE/cryptic_error.yml
vendored
Normal file
@ -0,0 +1,37 @@
|
||||
name: Cryptic KCL Error
|
||||
description: File a bug report for source code that produces a confusing error
|
||||
title: "[CRYPTIC]: "
|
||||
labels: ["cryptic-error"]
|
||||
assignees: []
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: "Thank you for taking the time to report a confusing error. Please provide as much information as possible to help us resolve it."
|
||||
|
||||
- type: textarea
|
||||
id: kcl
|
||||
attributes:
|
||||
label: Paste minimal KCL source that produces a cryptic error
|
||||
description: Minimal KCL reproducer that produces a cryptic error
|
||||
placeholder: "const ..."
|
||||
render: javascript
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: expected-behavior
|
||||
attributes:
|
||||
label: Expected Behavior
|
||||
description: Description of what you expected to happen (if you know).
|
||||
placeholder: "I expected that..."
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: additional-context
|
||||
attributes:
|
||||
label: Additional Context
|
||||
description: Add any other context about the problem here.
|
||||
placeholder: "Anything else you want to add..."
|
||||
validations:
|
||||
required: false
|
38
README.md
@ -124,20 +124,40 @@ Before you submit a contribution PR to this repo, please ensure that:
|
||||
|
||||
## Release a new version
|
||||
|
||||
1. Bump the versions by running `./make-realease.sh` while on a fresh pull of main
|
||||
#### 1. Bump the versions by running `./make-release.sh` and create a Cut Release PR
|
||||
|
||||
That will create the branch with the updated json files for you.
|
||||
run `./make-release.sh` for a patch update
|
||||
run `./make-release.sh "minor"` for minor
|
||||
run `./make-release.sh "major"` for major
|
||||
That will create the branch with the updated json files for you:
|
||||
- run `./make-release.sh` or `./make-release.sh patch` for a patch update;
|
||||
- run `./make-release.sh minor` for minor; or
|
||||
- run `./make-release.sh major` for major.
|
||||
|
||||
After it runs you should just need to push the push the branch and open a PR (it will suggest a changelog for you too, delete any that are not user facing)
|
||||
After it runs you should just need the push the branch and open a PR.
|
||||
|
||||
The PR may serve as a place to discuss the human-readable changelog and extra QA.
|
||||
**Important:** It needs to be prefixed with `Cut release v` to build in release mode and a few other things to test in the best context possible, the intent would be for instance to have `Cut release v1.2.3` for the `v1.2.3` release candidate.
|
||||
|
||||
2. Merge the PR
|
||||
The PR may then serve as a place to discuss the human-readable changelog and extra QA. The `make-release.sh` tool suggests a changelog for you too to be used as PR description, just make sure to delete lines that are not user facing.
|
||||
|
||||
#### 2. Smoke test artifacts from the Cut Release PR
|
||||
|
||||
The release builds can be find under the `artifact` zip, at the very bottom of the `ci` action page for each commit on this branch.
|
||||
|
||||
We don't have a strict process, but click around and check for anything obvious, posting results as comments in the Cut Release PR.
|
||||
|
||||
The other `ci` output in Cut Release PRs is `updater-test`, because we don't have a way to test this fully automated, we have a semi-automated process. Download updater-test zip file, install the app, run it, expect an updater prompt to a dummy v0.99.99, install it and check that the app comes back at that version (on both macOS and Windows).
|
||||
|
||||
#### 3. Merge the Cut Release PR
|
||||
|
||||
This will kick the `create-release` action, that creates a _Draft_ release out of this Cut Release PR merge after less than a minute, with the new version as title and Cut Release PR as description.
|
||||
|
||||
|
||||
#### 4. Publish the release
|
||||
|
||||
Head over to https://github.com/KittyCAD/modeling-app/releases, the draft release corresponding to the merged Cut Release PR should show up at the top as _Draft_. Click on it, verify the content, and hit _Publish_.
|
||||
|
||||
#### 5. Profit
|
||||
|
||||
A new Action kicks in at https://github.com/KittyCAD/modeling-app/actions, which can be found under `release` event filter.
|
||||
|
||||
3. Profit (A new Action kicks in at https://github.com/KittyCAD/modeling-app/actions if the PR was correctly named)
|
||||
|
||||
## Fuzzing the parser
|
||||
|
||||
|
@ -3099,6 +3099,49 @@ const sketch002 = startSketchOn(extrude001, $seg01)
|
||||
).not.toBeDisabled()
|
||||
})
|
||||
|
||||
test('Fillet button states test', async ({ page }) => {
|
||||
const u = await getUtils(page)
|
||||
await page.addInitScript(async () => {
|
||||
localStorage.setItem(
|
||||
'persistCode',
|
||||
`const sketch001 = startSketchOn('XZ')
|
||||
|> startProfileAt([-5, -5], %)
|
||||
|> line([0, 10], %)
|
||||
|> line([10, 0], %)
|
||||
|> line([0, -10], %)
|
||||
|> lineTo([profileStartX(%), profileStartY(%)], %)
|
||||
|> close(%)`
|
||||
)
|
||||
})
|
||||
|
||||
await page.setViewportSize({ width: 1000, height: 500 })
|
||||
await u.waitForAuthSkipAppStart()
|
||||
await u.openDebugPanel()
|
||||
await u.expectCmdLog('[data-message-type="execution-done"]')
|
||||
await u.closeDebugPanel()
|
||||
|
||||
const selectSegment = () => page.getByText(`line([10, 0], %)`).click()
|
||||
const selectClose = () => page.getByText(`close(%)`).click()
|
||||
const clickEmpty = () => page.mouse.click(950, 100)
|
||||
|
||||
// expect fillet button without any bodies in the scene
|
||||
await selectSegment()
|
||||
await expect(page.getByRole('button', { name: 'Fillet' })).toBeDisabled()
|
||||
await clickEmpty()
|
||||
await expect(page.getByRole('button', { name: 'Fillet' })).toBeDisabled()
|
||||
|
||||
// test fillet button with the body in the scene
|
||||
const codeToAdd = `${await u.codeLocator.allInnerTexts()}
|
||||
const extrude001 = extrude(10, sketch001)`
|
||||
await u.codeLocator.fill(codeToAdd)
|
||||
await selectSegment()
|
||||
await expect(page.getByRole('button', { name: 'Fillet' })).toBeEnabled()
|
||||
await selectClose()
|
||||
await expect(page.getByRole('button', { name: 'Fillet' })).toBeDisabled()
|
||||
await clickEmpty()
|
||||
await expect(page.getByRole('button', { name: 'Fillet' })).toBeEnabled()
|
||||
})
|
||||
|
||||
const removeAfterFirstParenthesis = (inputString: string) => {
|
||||
const index = inputString.indexOf('(')
|
||||
if (index !== -1) {
|
||||
@ -3500,11 +3543,62 @@ test.describe('Command bar tests', () => {
|
||||
`const extrude001 = extrude(${KCL_DEFAULT_LENGTH}, sketch001)`
|
||||
)
|
||||
})
|
||||
test('Command bar works and can change a setting', async ({ page }) => {
|
||||
|
||||
test('Fillet from command bar', async ({ page }) => {
|
||||
await page.addInitScript(async () => {
|
||||
localStorage.setItem(
|
||||
'persistCode',
|
||||
`const sketch001 = startSketchOn('XY')
|
||||
|> startProfileAt([-5, -5], %)
|
||||
|> line([0, 10], %)
|
||||
|> line([10, 0], %)
|
||||
|> line([0, -10], %)
|
||||
|> lineTo([profileStartX(%), profileStartY(%)], %)
|
||||
|> close(%)
|
||||
const extrude001 = extrude(-10, sketch001)`
|
||||
)
|
||||
})
|
||||
|
||||
const u = await getUtils(page)
|
||||
await page.setViewportSize({ width: 1000, height: 500 })
|
||||
await u.waitForAuthSkipAppStart()
|
||||
await u.openDebugPanel()
|
||||
await u.expectCmdLog('[data-message-type="execution-done"]')
|
||||
await u.closeDebugPanel()
|
||||
|
||||
const selectSegment = () => page.getByText(`line([0, -10], %)`).click()
|
||||
|
||||
await selectSegment()
|
||||
await page.waitForTimeout(100)
|
||||
await page.getByRole('button', { name: 'Fillet' }).click()
|
||||
await page.waitForTimeout(100)
|
||||
await page.keyboard.press('Enter')
|
||||
await page.waitForTimeout(100)
|
||||
await page.keyboard.press('Enter')
|
||||
await page.waitForTimeout(100)
|
||||
await expect(page.locator('.cm-activeLine')).toContainText(
|
||||
`fillet({ radius: ${KCL_DEFAULT_LENGTH}, tags: [seg01] }, %)`
|
||||
)
|
||||
})
|
||||
|
||||
test('Command bar can change a setting, and switch back and forth between arguments', async ({
|
||||
page,
|
||||
}) => {
|
||||
const u = await getUtils(page)
|
||||
await page.setViewportSize({ width: 1200, height: 500 })
|
||||
await u.waitForAuthSkipAppStart()
|
||||
|
||||
const commandBarButton = page.getByRole('button', { name: 'Commands' })
|
||||
const cmdSearchBar = page.getByPlaceholder('Search commands')
|
||||
const themeOption = page.getByRole('option', {
|
||||
name: 'theme',
|
||||
exact: false,
|
||||
})
|
||||
const commandLevelArgButton = page.getByRole('button', { name: 'level' })
|
||||
const commandThemeArgButton = page.getByRole('button', { name: 'value' })
|
||||
// This selector changes after we set the setting
|
||||
let commandOptionInput = page.getByPlaceholder('Select an option')
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Start Sketch' })
|
||||
).not.toBeDisabled()
|
||||
@ -3515,23 +3609,17 @@ test.describe('Command bar tests', () => {
|
||||
.or(page.getByRole('button', { name: '⌘K' }))
|
||||
.click()
|
||||
|
||||
let cmdSearchBar = page.getByPlaceholder('Search commands')
|
||||
await expect(cmdSearchBar).toBeVisible()
|
||||
await page.keyboard.press('Escape')
|
||||
cmdSearchBar = page.getByPlaceholder('Search commands')
|
||||
await expect(cmdSearchBar).not.toBeVisible()
|
||||
|
||||
// Now try the same, but with the keyboard shortcut, check focus
|
||||
await page.keyboard.press('Meta+K')
|
||||
cmdSearchBar = page.getByPlaceholder('Search commands')
|
||||
await expect(cmdSearchBar).toBeVisible()
|
||||
await expect(cmdSearchBar).toBeFocused()
|
||||
|
||||
// Try typing in the command bar
|
||||
await page.keyboard.type('theme')
|
||||
const themeOption = page.getByRole('option', {
|
||||
name: 'Settings · app · theme',
|
||||
})
|
||||
await cmdSearchBar.fill('theme')
|
||||
await expect(themeOption).toBeVisible()
|
||||
await themeOption.click()
|
||||
const themeInput = page.getByPlaceholder('Select an option')
|
||||
@ -3553,6 +3641,24 @@ test.describe('Command bar tests', () => {
|
||||
).toBeVisible()
|
||||
// Check that the theme changed
|
||||
await expect(page.locator('body')).not.toHaveClass(`body-bg dark`)
|
||||
|
||||
commandOptionInput = page.getByPlaceholder('system')
|
||||
|
||||
// Test case for https://github.com/KittyCAD/modeling-app/issues/2882
|
||||
await commandBarButton.click()
|
||||
await cmdSearchBar.focus()
|
||||
await cmdSearchBar.fill('theme')
|
||||
await themeOption.click()
|
||||
await expect(commandThemeArgButton).toBeDisabled()
|
||||
await commandOptionInput.focus()
|
||||
await commandOptionInput.fill('lig')
|
||||
await commandLevelArgButton.click()
|
||||
await expect(commandLevelArgButton).toBeDisabled()
|
||||
|
||||
// Test case for https://github.com/KittyCAD/modeling-app/issues/2881
|
||||
await commandThemeArgButton.click()
|
||||
await expect(commandThemeArgButton).toBeDisabled()
|
||||
await expect(commandLevelArgButton).toHaveText('level: project')
|
||||
})
|
||||
|
||||
test('Command bar keybinding works from code editor and can change a setting', async ({
|
||||
@ -3577,7 +3683,7 @@ test.describe('Command bar tests', () => {
|
||||
await expect(cmdSearchBar).toBeFocused()
|
||||
|
||||
// Try typing in the command bar
|
||||
await page.keyboard.type('theme')
|
||||
await cmdSearchBar.fill('theme')
|
||||
const themeOption = page.getByRole('option', {
|
||||
name: 'Settings · app · theme',
|
||||
})
|
||||
@ -3648,7 +3754,9 @@ test.describe('Command bar tests', () => {
|
||||
await page.mouse.click(700, 200)
|
||||
|
||||
// Assert that we're on the distance step
|
||||
await expect(page.getByRole('button', { name: 'distance' })).toBeDisabled()
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'distance', exact: false })
|
||||
).toBeDisabled()
|
||||
|
||||
// Assert that the an alternative variable name is chosen,
|
||||
// since the default variable name is already in use (distance)
|
||||
@ -3663,11 +3771,12 @@ test.describe('Command bar tests', () => {
|
||||
|
||||
// Review step and argument hotkeys
|
||||
await expect(submitButton).toBeEnabled()
|
||||
await page.keyboard.press('Backspace')
|
||||
await expect(submitButton).toBeFocused()
|
||||
await submitButton.press('Backspace')
|
||||
|
||||
// Assert we're back on the distance step
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Distance 5', exact: false })
|
||||
page.getByRole('button', { name: 'distance', exact: false })
|
||||
).toBeDisabled()
|
||||
|
||||
await continueButton.click()
|
||||
@ -3724,6 +3833,7 @@ const extrude001 = extrude(distance001, sketch001)`.replace(
|
||||
// Click in the scene a couple times to draw a line
|
||||
// so tangential arc is valid
|
||||
await page.mouse.click(700, 200)
|
||||
await page.mouse.move(700, 300, { steps: 5 })
|
||||
await page.mouse.click(700, 300)
|
||||
|
||||
// switch to tangential arc via command bar
|
||||
@ -4682,10 +4792,10 @@ test.describe('Sketch tests', () => {
|
||||
// click extrude
|
||||
await page.getByRole('button', { name: 'Extrude' }).click()
|
||||
|
||||
// sketch selection should already have been made. "Selection 1 face" only show up when the selection has been made already
|
||||
// sketch selection should already have been made. "Selection: 1 face" only show up when the selection has been made already
|
||||
// otherwise the cmdbar would be waiting for a selection.
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Selection 1 face' })
|
||||
page.getByRole('button', { name: 'selection : 1 face', exact: false })
|
||||
).toBeVisible()
|
||||
})
|
||||
test("Existing sketch with bad code delete user's code", async ({ page }) => {
|
||||
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 44 KiB |
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 44 KiB |
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 51 KiB |
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 45 KiB |
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 35 KiB |
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 36 KiB |
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 39 KiB |
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 72 KiB |
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 44 KiB |
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 62 KiB |
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 30 KiB |
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 33 KiB |
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 31 KiB |
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 35 KiB |
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 29 KiB |
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "untitled-app",
|
||||
"version": "0.24.1",
|
||||
"version": "0.24.3",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.17.0",
|
||||
|
@ -30,7 +30,7 @@ import { URI } from 'vscode-uri'
|
||||
import { LanguageServerClient } from '../client'
|
||||
import { CompletionItemKindMap } from './autocomplete'
|
||||
import { addToken, SemanticToken } from './semantic-tokens'
|
||||
import { deferExecution, posToOffset, formatMarkdownContents } from './util'
|
||||
import { posToOffset, formatMarkdownContents } from './util'
|
||||
import lspAutocompleteExt from './autocomplete'
|
||||
import lspHoverExt from './hover'
|
||||
import lspFormatExt from './format'
|
||||
|
@ -80,5 +80,5 @@
|
||||
}
|
||||
},
|
||||
"productName": "Zoo Modeling App",
|
||||
"version": "0.24.1"
|
||||
"version": "0.24.3"
|
||||
}
|
||||
|
@ -44,7 +44,7 @@ export function App() {
|
||||
}, [projectName, projectPath])
|
||||
|
||||
useHotKeyListener()
|
||||
const { context } = useModelingContext()
|
||||
const { context, state } = useModelingContext()
|
||||
|
||||
const { auth, settings } = useSettingsAuthContext()
|
||||
const token = auth?.context?.token
|
||||
@ -57,7 +57,6 @@ export function App() {
|
||||
const {
|
||||
app: { onboardingStatus },
|
||||
} = settings.context
|
||||
const { state } = useModelingContext()
|
||||
|
||||
useHotkeys('backspace', (e) => {
|
||||
e.preventDefault()
|
||||
|
@ -16,6 +16,7 @@ import {
|
||||
canRectangleTool,
|
||||
isEditingExistingSketch,
|
||||
} from 'machines/modelingMachine'
|
||||
import { DEV } from 'env'
|
||||
|
||||
export function Toolbar({
|
||||
className = '',
|
||||
@ -118,6 +119,16 @@ export function Toolbar({
|
||||
}),
|
||||
{ enabled: !disableAllButtons, scopes: ['modeling'] }
|
||||
)
|
||||
const disableFillet = !state.can('Fillet') || disableAllButtons
|
||||
useHotkeys(
|
||||
'f',
|
||||
() =>
|
||||
commandBarSend({
|
||||
type: 'Find and select command',
|
||||
data: { name: 'Fillet', groupId: 'modeling' },
|
||||
}),
|
||||
{ enabled: !disableFillet, scopes: ['modeling'] }
|
||||
)
|
||||
|
||||
function handleToolbarButtonsWheelEvent(ev: WheelEvent<HTMLSpanElement>) {
|
||||
const span = toolbarButtonsRef.current
|
||||
@ -404,6 +415,36 @@ export function Toolbar({
|
||||
</ActionButton>
|
||||
</li>
|
||||
)}
|
||||
{state.matches('idle') && (DEV || (window as any)._enableFillet) && (
|
||||
<li className="contents">
|
||||
<ActionButton
|
||||
className={buttonClassName}
|
||||
Element="button"
|
||||
onClick={() =>
|
||||
commandBarSend({
|
||||
type: 'Find and select command',
|
||||
data: { name: 'Fillet', groupId: 'modeling' },
|
||||
})
|
||||
}
|
||||
disabled={disableFillet}
|
||||
title={disableFillet ? 'fillet' : "edge can't be filleted"}
|
||||
iconStart={{
|
||||
icon: 'fillet', // todo: add fillet icon
|
||||
iconClassName,
|
||||
bgClassName,
|
||||
}}
|
||||
>
|
||||
Fillet
|
||||
<Tooltip
|
||||
delay={1250}
|
||||
position="bottom"
|
||||
className="!px-2 !text-xs"
|
||||
>
|
||||
Shortcut: F
|
||||
</Tooltip>
|
||||
</ActionButton>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</menu>
|
||||
)
|
||||
|
@ -41,6 +41,7 @@ function CommandArgOptionInput({
|
||||
)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const formRef = useRef<HTMLFormElement>(null)
|
||||
const [shouldSubmitOnChange, setShouldSubmitOnChange] = useState(false)
|
||||
const [selectedOption, setSelectedOption] = useState<
|
||||
CommandArgumentOption<unknown>
|
||||
>(currentOption || resolvedOptions[0])
|
||||
@ -82,8 +83,10 @@ function CommandArgOptionInput({
|
||||
// We deal with the whole option object internally
|
||||
setSelectedOption(option)
|
||||
|
||||
// But we only submit the value
|
||||
onSubmit(option.value)
|
||||
// But we only submit the value itself
|
||||
if (shouldSubmitOnChange) {
|
||||
onSubmit(option.value)
|
||||
}
|
||||
}
|
||||
|
||||
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
@ -94,7 +97,18 @@ function CommandArgOptionInput({
|
||||
}
|
||||
|
||||
return (
|
||||
<form id="arg-form" onSubmit={handleSubmit} ref={formRef}>
|
||||
<form
|
||||
id="arg-form"
|
||||
onSubmit={handleSubmit}
|
||||
ref={formRef}
|
||||
onKeyDownCapture={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
setShouldSubmitOnChange(true)
|
||||
} else {
|
||||
setShouldSubmitOnChange(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Combobox
|
||||
value={selectedOption}
|
||||
onChange={handleSelectOption}
|
||||
@ -118,6 +132,12 @@ function CommandArgOptionInput({
|
||||
if (event.key === 'Backspace' && !event.currentTarget.value) {
|
||||
stepBack()
|
||||
}
|
||||
|
||||
if (event.key === 'Enter') {
|
||||
setShouldSubmitOnChange(true)
|
||||
} else {
|
||||
setShouldSubmitOnChange(false)
|
||||
}
|
||||
}}
|
||||
value={query}
|
||||
placeholder={
|
||||
@ -136,6 +156,9 @@ function CommandArgOptionInput({
|
||||
<Combobox.Options
|
||||
static
|
||||
className="overflow-y-auto max-h-96 cursor-pointer"
|
||||
onMouseDown={() => {
|
||||
setShouldSubmitOnChange(true)
|
||||
}}
|
||||
>
|
||||
{filteredOptions?.map((option) => (
|
||||
<Combobox.Option
|
||||
|
@ -114,6 +114,7 @@ function CommandBarHeader({ children }: React.PropsWithChildren<{}>) {
|
||||
>
|
||||
{argName}
|
||||
</span>
|
||||
<span className="sr-only">: </span>
|
||||
{argValue ? (
|
||||
arg.inputType === 'selection' ? (
|
||||
getSelectionTypeDisplayText(argValue as Selections)
|
||||
|
@ -3,6 +3,7 @@ import { useCommandsContext } from 'hooks/useCommandsContext'
|
||||
import { useKclContext } from 'lang/KclProvider'
|
||||
import { CommandArgument } from 'lib/commandTypes'
|
||||
import {
|
||||
Selection,
|
||||
canSubmitSelectionArg,
|
||||
getSelectionType,
|
||||
getSelectionTypeDisplayText,
|
||||
@ -11,6 +12,25 @@ import { modelingMachine } from 'machines/modelingMachine'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { StateFrom } from 'xstate'
|
||||
|
||||
const semanticEntityNames: { [key: string]: Array<Selection['type']> } = {
|
||||
face: ['extrude-wall', 'start-cap', 'end-cap'],
|
||||
edge: ['edge', 'line', 'arc'],
|
||||
point: ['point', 'line-end', 'line-mid'],
|
||||
}
|
||||
|
||||
function getSemanticSelectionType(selectionType: Array<Selection['type']>) {
|
||||
const semanticSelectionType = new Set()
|
||||
selectionType.forEach((type) => {
|
||||
Object.entries(semanticEntityNames).forEach(([entity, entityTypes]) => {
|
||||
if (entityTypes.includes(type)) {
|
||||
semanticSelectionType.add(entity)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
return Array.from(semanticSelectionType)
|
||||
}
|
||||
|
||||
const selectionSelector = (snapshot: StateFrom<typeof modelingMachine>) =>
|
||||
snapshot.context.selectionRanges
|
||||
|
||||
@ -85,7 +105,9 @@ function CommandBarSelectionInput({
|
||||
>
|
||||
{canSubmitSelection
|
||||
? getSelectionTypeDisplayText(selection) + ' selected'
|
||||
: `Please select ${arg.multiple ? 'one or more faces' : 'one face'}`}
|
||||
: `Please select ${
|
||||
arg.multiple ? 'one or more ' : 'one '
|
||||
}${getSemanticSelectionType(arg.selectionTypes).join(' or ')}`}
|
||||
<input
|
||||
id="selection"
|
||||
name="selection"
|
||||
|
@ -187,6 +187,22 @@ const CustomIconMap = {
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
fillet: (
|
||||
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M8 5H5V15H15V12C15 8.13401 11.866 5 8 5ZM5 4H4V5V15V16H5H15H16V15V12C16 7.58172 12.4183 4 8 4H5Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M4.5 3.5H5.5H8.5C12.9183 3.5 16.5 7.08172 16.5 11.5V14.5V15.5H16V12C16 7.58172 12.4182 4 7.99996 4H4.5V3.5Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
file: (
|
||||
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
|
@ -72,6 +72,7 @@ import { uuidv4 } from 'lib/utils'
|
||||
import { err, trap } from 'lib/trap'
|
||||
import { useCommandsContext } from 'hooks/useCommandsContext'
|
||||
import { modelingMachineEvent } from 'editor/manager'
|
||||
import { hasValidFilletSelection } from 'lang/modifyAst/addFillet'
|
||||
|
||||
type MachineContext<T extends AnyStateMachine> = {
|
||||
state: StateFrom<T>
|
||||
@ -130,6 +131,9 @@ export const ModelingMachineProvider = ({
|
||||
},
|
||||
'sketch exit execute': ({ store }) => {
|
||||
;(async () => {
|
||||
// blocks entering a sketch until after exit sketch code has run
|
||||
kclManager.isExecuting = true
|
||||
|
||||
await sceneInfra.camControls.snapToPerspectiveBeforeHandingBackControlToEngine()
|
||||
|
||||
sceneInfra.camControls.syncDirection = 'engineToClient'
|
||||
@ -164,7 +168,7 @@ export const ModelingMachineProvider = ({
|
||||
|
||||
store.videoElement?.pause()
|
||||
kclManager.executeCode(true).then(() => {
|
||||
if (engineCommandManager.engineConnection?.freezeFrame) return
|
||||
if (engineCommandManager.engineConnection?.idleMode) return
|
||||
|
||||
store.videoElement?.play()
|
||||
})
|
||||
@ -444,6 +448,12 @@ export const ModelingMachineProvider = ({
|
||||
if (selectionRanges.codeBasedSelections.length <= 0) return false
|
||||
return true
|
||||
},
|
||||
'has valid fillet selection': ({ selectionRanges }) =>
|
||||
hasValidFilletSelection({
|
||||
selectionRanges,
|
||||
ast: kclManager.ast,
|
||||
code: codeManager.code,
|
||||
}),
|
||||
'Selection is on face': ({ selectionRanges }, { data }) => {
|
||||
if (data?.forceNewSketch) return false
|
||||
if (!isSingleCursorInPipe(selectionRanges, kclManager.ast))
|
||||
@ -494,7 +504,6 @@ export const ModelingMachineProvider = ({
|
||||
kclManager.ast,
|
||||
data.sketchPathToNode,
|
||||
data.extrudePathToNode,
|
||||
kclManager.programMemory,
|
||||
data.cap
|
||||
)
|
||||
if (trap(sketched)) return Promise.reject(sketched)
|
||||
|
@ -1,6 +1,8 @@
|
||||
import styles from './ModelingPane.module.css'
|
||||
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
|
||||
import { useModelingContext } from 'hooks/useModelingContext'
|
||||
import { ActionButton } from 'components/ActionButton'
|
||||
import Tooltip from 'components/Tooltip'
|
||||
|
||||
export interface ModelingPaneProps
|
||||
extends React.PropsWithChildren,
|
||||
@ -8,16 +10,32 @@ export interface ModelingPaneProps
|
||||
title: string
|
||||
Menu?: React.ReactNode | React.FC
|
||||
detailsTestId?: string
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export const ModelingPaneHeader = ({
|
||||
title,
|
||||
Menu,
|
||||
}: Pick<ModelingPaneProps, 'title' | 'Menu'>) => {
|
||||
onClose,
|
||||
}: Pick<ModelingPaneProps, 'title' | 'Menu' | 'onClose'>) => {
|
||||
return (
|
||||
<div className={styles.header}>
|
||||
<div className="flex gap-2 items-center flex-1">{title}</div>
|
||||
{Menu instanceof Function ? <Menu /> : Menu}
|
||||
<ActionButton
|
||||
Element="button"
|
||||
iconStart={{
|
||||
icon: 'close',
|
||||
iconClassName: '!text-current',
|
||||
bgClassName: 'bg-transparent dark:bg-transparent',
|
||||
}}
|
||||
className="!p-0 !bg-transparent hover:text-primary border-transparent dark:!border-transparent hover:!border-primary dark:hover:!border-chalkboard-70 !outline-none"
|
||||
onClick={onClose}
|
||||
>
|
||||
<Tooltip position="bottom-right" delay={750}>
|
||||
Close
|
||||
</Tooltip>
|
||||
</ActionButton>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -29,6 +47,7 @@ export const ModelingPane = ({
|
||||
className,
|
||||
Menu,
|
||||
detailsTestId,
|
||||
onClose,
|
||||
...props
|
||||
}: ModelingPaneProps) => {
|
||||
const { settings } = useSettingsAuthContext()
|
||||
@ -51,7 +70,7 @@ export const ModelingPane = ({
|
||||
(className || '')
|
||||
}
|
||||
>
|
||||
<ModelingPaneHeader title={title} Menu={Menu} />
|
||||
<ModelingPaneHeader title={title} Menu={Menu} onClose={onClose} />
|
||||
<div className="relative w-full">{children}</div>
|
||||
</section>
|
||||
)
|
||||
|
@ -24,14 +24,12 @@ export const KclEditorMenu = ({ children }: PropsWithChildren) => {
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Menu.Button className="p-0 border-none relative">
|
||||
<Menu.Button className="!p-0 !bg-transparent hover:text-primary border-transparent dark:!border-transparent hover:!border-primary dark:hover:!border-chalkboard-70 ui-open:!border-primary dark:ui-open:!border-chalkboard-70 !outline-none">
|
||||
<ActionIcon
|
||||
icon="three-dots"
|
||||
className="p-1"
|
||||
size="sm"
|
||||
bgClassName={
|
||||
'!bg-transparent hover:!bg-primary/10 hover:dark:!bg-chalkboard-100 ui-open:!bg-primary/10 dark:ui-open:!bg-chalkboard-100 rounded-sm'
|
||||
}
|
||||
bgClassName="bg-transparent dark:bg-transparent"
|
||||
iconClassName={'!text-chalkboard-90 dark:!text-chalkboard-40'}
|
||||
/>
|
||||
</Menu.Button>
|
||||
|
@ -204,6 +204,7 @@ function ModelingSidebarSection({
|
||||
id={`${pane.id}-pane`}
|
||||
title={pane.title}
|
||||
Menu={pane.Menu}
|
||||
onClose={() => togglePane(pane.id)}
|
||||
>
|
||||
{pane.Content instanceof Function ? (
|
||||
<pane.Content />
|
||||
|
@ -1,4 +1,3 @@
|
||||
import { DEV } from 'env'
|
||||
import { MouseEventHandler, useEffect, useRef, useState } from 'react'
|
||||
import { getNormalisedCoordinates } from '../lib/utils'
|
||||
import Loading from './Loading'
|
||||
@ -11,6 +10,10 @@ import { btnName } from 'lib/cameraControls'
|
||||
import { sendSelectEventToEngine } from 'lib/selections'
|
||||
import { kclManager, engineCommandManager, sceneInfra } from 'lib/singletons'
|
||||
import { useAppStream } from 'AppState'
|
||||
import {
|
||||
EngineConnectionStateType,
|
||||
DisconnectingType,
|
||||
} from 'lang/std/engineConnection'
|
||||
|
||||
export const Stream = () => {
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
@ -20,15 +23,28 @@ export const Stream = () => {
|
||||
const { settings } = useSettingsAuthContext()
|
||||
const { state, send, context } = useModelingContext()
|
||||
const { mediaStream } = useAppStream()
|
||||
const { overallState } = useNetworkContext()
|
||||
const { overallState, immediateState } = useNetworkContext()
|
||||
const [isFreezeFrame, setIsFreezeFrame] = useState(false)
|
||||
const [isPaused, setIsPaused] = useState(false)
|
||||
|
||||
const IDLE = true
|
||||
const IDLE = settings.context.app.streamIdleMode.current
|
||||
|
||||
const isNetworkOkay =
|
||||
overallState === NetworkHealthState.Ok ||
|
||||
overallState === NetworkHealthState.Weak
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
immediateState.type === EngineConnectionStateType.Disconnecting &&
|
||||
immediateState.value.type === DisconnectingType.Pause
|
||||
) {
|
||||
setIsPaused(true)
|
||||
}
|
||||
if (immediateState.type === EngineConnectionStateType.Connecting) {
|
||||
setIsPaused(false)
|
||||
}
|
||||
}, [immediateState])
|
||||
|
||||
// Linux has a default behavior to paste text on middle mouse up
|
||||
// This adds a listener to block that pasting if the click target
|
||||
// is not a text input, so users can move in the 3D scene with
|
||||
@ -65,25 +81,28 @@ export const Stream = () => {
|
||||
sceneInfra.modelingSend({ type: 'Cancel' })
|
||||
// Give video time to pause
|
||||
window.requestAnimationFrame(() => {
|
||||
engineCommandManager.tearDown()
|
||||
engineCommandManager.tearDown({ idleMode: true })
|
||||
})
|
||||
}
|
||||
|
||||
// Teardown everything if we go hidden or reconnect
|
||||
if (IDLE && DEV) {
|
||||
if (globalThis?.window?.document) {
|
||||
globalThis.window.document.onvisibilitychange = () => {
|
||||
if (globalThis.window.document.visibilityState === 'hidden') {
|
||||
clearTimeout(timeoutIdIdleA)
|
||||
timeoutIdIdleA = setTimeout(teardown, IDLE_TIME_MS)
|
||||
} else if (!engineCommandManager.engineConnection?.isReady()) {
|
||||
clearTimeout(timeoutIdIdleA)
|
||||
engineCommandManager.engineConnection?.connect(true)
|
||||
}
|
||||
}
|
||||
const onVisibilityChange = () => {
|
||||
if (globalThis.window.document.visibilityState === 'hidden') {
|
||||
clearTimeout(timeoutIdIdleA)
|
||||
timeoutIdIdleA = setTimeout(teardown, IDLE_TIME_MS)
|
||||
} else if (!engineCommandManager.engineConnection?.isReady()) {
|
||||
clearTimeout(timeoutIdIdleA)
|
||||
engineCommandManager.engineConnection?.connect(true)
|
||||
}
|
||||
}
|
||||
|
||||
// Teardown everything if we go hidden or reconnect
|
||||
if (IDLE) {
|
||||
globalThis?.window?.document?.addEventListener(
|
||||
'visibilitychange',
|
||||
onVisibilityChange
|
||||
)
|
||||
}
|
||||
|
||||
let timeoutIdIdleB: ReturnType<typeof setTimeout> | undefined = undefined
|
||||
|
||||
const onAnyInput = () => {
|
||||
@ -93,7 +112,7 @@ export const Stream = () => {
|
||||
timeoutIdIdleB = setTimeout(teardown, IDLE_TIME_MS)
|
||||
}
|
||||
|
||||
if (IDLE && DEV) {
|
||||
if (IDLE) {
|
||||
globalThis?.window?.document?.addEventListener('keydown', onAnyInput)
|
||||
globalThis?.window?.document?.addEventListener('mousemove', onAnyInput)
|
||||
globalThis?.window?.document?.addEventListener('mousedown', onAnyInput)
|
||||
@ -101,7 +120,7 @@ export const Stream = () => {
|
||||
globalThis?.window?.document?.addEventListener('touchstart', onAnyInput)
|
||||
}
|
||||
|
||||
if (IDLE && DEV) {
|
||||
if (IDLE) {
|
||||
timeoutIdIdleB = setTimeout(teardown, IDLE_TIME_MS)
|
||||
}
|
||||
|
||||
@ -109,7 +128,14 @@ export const Stream = () => {
|
||||
globalThis?.window?.document?.removeEventListener('paste', handlePaste, {
|
||||
capture: true,
|
||||
})
|
||||
if (IDLE && DEV) {
|
||||
if (IDLE) {
|
||||
clearTimeout(timeoutIdIdleA)
|
||||
clearTimeout(timeoutIdIdleB)
|
||||
|
||||
globalThis?.window?.document?.removeEventListener(
|
||||
'visibilitychange',
|
||||
onVisibilityChange
|
||||
)
|
||||
globalThis?.window?.document?.removeEventListener('keydown', onAnyInput)
|
||||
globalThis?.window?.document?.removeEventListener(
|
||||
'mousemove',
|
||||
@ -126,7 +152,7 @@ export const Stream = () => {
|
||||
)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
}, [IDLE])
|
||||
|
||||
useEffect(() => {
|
||||
setIsFirstRender(kclManager.isFirstRender)
|
||||
@ -249,6 +275,32 @@ export const Stream = () => {
|
||||
<ClientSideScene
|
||||
cameraControls={settings.context.modeling.mouseControls.current}
|
||||
/>
|
||||
{isPaused && (
|
||||
<div className="text-center absolute inset-0">
|
||||
<div
|
||||
className="flex flex-col items-center justify-center h-screen"
|
||||
data-testid="paused"
|
||||
>
|
||||
<div className="border-primary border p-2 rounded-sm">
|
||||
<svg
|
||||
width="8"
|
||||
height="12"
|
||||
viewBox="0 0 8 12"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M2 12V0H0V12H2ZM8 12V0H6V12H8Z"
|
||||
fill="var(--primary)"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-base mt-2 text-primary bold">Paused</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{(!isNetworkOkay || isLoading || isFirstRender) && !isFreezeFrame && (
|
||||
<div className="text-center absolute inset-0">
|
||||
<Loading>
|
||||
|
@ -1,11 +1,16 @@
|
||||
import { createContext, useContext } from 'react'
|
||||
import {
|
||||
ConnectingTypeGroup,
|
||||
EngineConnectionStateType,
|
||||
EngineConnectionState,
|
||||
initialConnectingTypeGroupState,
|
||||
} from '../lang/std/engineConnection'
|
||||
import { NetworkStatus, NetworkHealthState } from './useNetworkStatus'
|
||||
|
||||
export const NetworkContext = createContext<NetworkStatus>({
|
||||
immediateState: {
|
||||
type: EngineConnectionStateType.Disconnected,
|
||||
} as EngineConnectionState,
|
||||
hasIssues: undefined,
|
||||
overallState: NetworkHealthState.Disconnected,
|
||||
internetConnected: true,
|
||||
|
@ -6,6 +6,7 @@ import {
|
||||
EngineCommandManagerEvents,
|
||||
EngineConnectionEvents,
|
||||
EngineConnectionStateType,
|
||||
EngineConnectionState,
|
||||
ErrorType,
|
||||
initialConnectingTypeGroupState,
|
||||
} from '../lang/std/engineConnection'
|
||||
@ -19,6 +20,7 @@ export enum NetworkHealthState {
|
||||
}
|
||||
|
||||
export interface NetworkStatus {
|
||||
immediateState: EngineConnectionState
|
||||
hasIssues: boolean | undefined
|
||||
overallState: NetworkHealthState
|
||||
internetConnected: boolean
|
||||
@ -33,6 +35,9 @@ export interface NetworkStatus {
|
||||
// Must be called from one place in the application.
|
||||
// We've chosen the <Router /> component for this.
|
||||
export function useNetworkStatus() {
|
||||
const [immediateState, setImmediateState] = useState<EngineConnectionState>({
|
||||
type: EngineConnectionStateType.Disconnected,
|
||||
})
|
||||
const [steps, setSteps] = useState(
|
||||
structuredClone(initialConnectingTypeGroupState)
|
||||
)
|
||||
@ -126,6 +131,7 @@ export function useNetworkStatus() {
|
||||
const onConnectionStateChange = ({
|
||||
detail: engineConnectionState,
|
||||
}: CustomEvent) => {
|
||||
setImmediateState(engineConnectionState)
|
||||
setSteps((steps) => {
|
||||
let nextSteps = structuredClone(steps)
|
||||
|
||||
@ -215,6 +221,7 @@ export function useNetworkStatus() {
|
||||
}, [])
|
||||
|
||||
return {
|
||||
immediateState,
|
||||
hasIssues,
|
||||
overallState,
|
||||
internetConnected,
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { useLayoutEffect, useEffect, useRef, useState } from 'react'
|
||||
import { useLayoutEffect, useEffect, useRef } from 'react'
|
||||
import { engineCommandManager, kclManager } from 'lib/singletons'
|
||||
import { deferExecution } from 'lib/utils'
|
||||
import { Themes } from 'lib/theme'
|
||||
|
@ -346,6 +346,7 @@ export class KclManager {
|
||||
return
|
||||
}
|
||||
this.ast = { ...ast }
|
||||
this.isExecuting = true // executeAst sets this to false again
|
||||
return this.executeAst(ast, zoomToFit)
|
||||
}
|
||||
format() {
|
||||
|
@ -304,7 +304,6 @@ describe('testing sketchOnExtrudedFace', () => {
|
||||
const ast = parse(code)
|
||||
if (err(ast)) throw ast
|
||||
|
||||
const programMemory = await enginelessExecutor(ast)
|
||||
const segmentSnippet = `line([9.7, 9.19], %)`
|
||||
const segmentRange: [number, number] = [
|
||||
code.indexOf(segmentSnippet),
|
||||
@ -321,8 +320,7 @@ describe('testing sketchOnExtrudedFace', () => {
|
||||
const extruded = sketchOnExtrudedFace(
|
||||
ast,
|
||||
segmentPathToNode,
|
||||
extrudePathToNode,
|
||||
programMemory
|
||||
extrudePathToNode
|
||||
)
|
||||
if (err(extruded)) throw extruded
|
||||
const { modifiedAst } = extruded
|
||||
@ -345,7 +343,6 @@ const sketch001 = startSketchOn(part001, seg01)`)
|
||||
|> extrude(5 + 7, %)`
|
||||
const ast = parse(code)
|
||||
if (err(ast)) throw ast
|
||||
const programMemory = await enginelessExecutor(ast)
|
||||
const segmentSnippet = `close(%)`
|
||||
const segmentRange: [number, number] = [
|
||||
code.indexOf(segmentSnippet),
|
||||
@ -362,8 +359,7 @@ const sketch001 = startSketchOn(part001, seg01)`)
|
||||
const extruded = sketchOnExtrudedFace(
|
||||
ast,
|
||||
segmentPathToNode,
|
||||
extrudePathToNode,
|
||||
programMemory
|
||||
extrudePathToNode
|
||||
)
|
||||
if (err(extruded)) throw extruded
|
||||
const { modifiedAst } = extruded
|
||||
@ -386,7 +382,6 @@ const sketch001 = startSketchOn(part001, seg01)`)
|
||||
|> extrude(5 + 7, %)`
|
||||
const ast = parse(code)
|
||||
if (err(ast)) throw ast
|
||||
const programMemory = await enginelessExecutor(ast)
|
||||
const sketchSnippet = `startProfileAt([3.58, 2.06], %)`
|
||||
const sketchRange: [number, number] = [
|
||||
code.indexOf(sketchSnippet),
|
||||
@ -404,7 +399,6 @@ const sketch001 = startSketchOn(part001, seg01)`)
|
||||
ast,
|
||||
sketchPathToNode,
|
||||
extrudePathToNode,
|
||||
programMemory,
|
||||
'end'
|
||||
)
|
||||
if (err(extruded)) throw extruded
|
||||
@ -436,7 +430,6 @@ const sketch001 = startSketchOn(part001, 'END')`)
|
||||
const part001 = extrude(5 + 7, sketch001)`
|
||||
const ast = parse(code)
|
||||
if (err(ast)) throw ast
|
||||
const programMemory = await enginelessExecutor(ast)
|
||||
const segmentSnippet = `line([4.99, -0.46], %)`
|
||||
const segmentRange: [number, number] = [
|
||||
code.indexOf(segmentSnippet),
|
||||
@ -453,8 +446,7 @@ const sketch001 = startSketchOn(part001, 'END')`)
|
||||
const updatedAst = sketchOnExtrudedFace(
|
||||
ast,
|
||||
segmentPathToNode,
|
||||
extrudePathToNode,
|
||||
programMemory
|
||||
extrudePathToNode
|
||||
)
|
||||
if (err(updatedAst)) throw updatedAst
|
||||
const newCode = recast(updatedAst.modifiedAst)
|
||||
|
@ -349,7 +349,6 @@ export function sketchOnExtrudedFace(
|
||||
node: Program,
|
||||
sketchPathToNode: PathToNode,
|
||||
extrudePathToNode: PathToNode,
|
||||
programMemory: ProgramMemory,
|
||||
cap: 'none' | 'start' | 'end' = 'none'
|
||||
): { modifiedAst: Program; pathToNode: PathToNode } | Error {
|
||||
let _node = { ...node }
|
||||
@ -388,7 +387,6 @@ export function sketchOnExtrudedFace(
|
||||
if (cap === 'none') {
|
||||
const __tag = addTagForSketchOnFace(
|
||||
{
|
||||
previousProgramMemory: programMemory,
|
||||
pathToNode: sketchPathToNode,
|
||||
node: _node,
|
||||
},
|
||||
|
399
src/lang/modifyAst/addFillet.test.ts
Normal file
@ -0,0 +1,399 @@
|
||||
import {
|
||||
parse,
|
||||
recast,
|
||||
initPromise,
|
||||
PathToNode,
|
||||
Value,
|
||||
Program,
|
||||
CallExpression,
|
||||
} from '../wasm'
|
||||
import {
|
||||
addFillet,
|
||||
hasValidFilletSelection,
|
||||
isTagUsedInFillet,
|
||||
} from './addFillet'
|
||||
import { getNodeFromPath, getNodePathFromSourceRange } from '../queryAst'
|
||||
import { createLiteral } from 'lang/modifyAst'
|
||||
import { err } from 'lib/trap'
|
||||
import { Selections } from 'lib/selections'
|
||||
|
||||
beforeAll(async () => {
|
||||
await initPromise // Initialize the WASM environment before running tests
|
||||
})
|
||||
|
||||
const runFilletTest = async (
|
||||
code: string,
|
||||
segmentSnippet: string,
|
||||
extrudeSnippet: string,
|
||||
radius = createLiteral(5) as Value,
|
||||
expectedCode: string
|
||||
) => {
|
||||
const astOrError = parse(code)
|
||||
if (err(astOrError)) {
|
||||
return new Error('AST not found')
|
||||
}
|
||||
|
||||
const ast = astOrError as Program
|
||||
|
||||
const segmentRange: [number, number] = [
|
||||
code.indexOf(segmentSnippet),
|
||||
code.indexOf(segmentSnippet) + segmentSnippet.length,
|
||||
]
|
||||
const pathToSegmentNode: PathToNode = getNodePathFromSourceRange(
|
||||
ast,
|
||||
segmentRange
|
||||
)
|
||||
|
||||
const extrudeRange: [number, number] = [
|
||||
code.indexOf(extrudeSnippet),
|
||||
code.indexOf(extrudeSnippet) + extrudeSnippet.length,
|
||||
]
|
||||
|
||||
const pathToExtrudeNode: PathToNode = getNodePathFromSourceRange(
|
||||
ast,
|
||||
extrudeRange
|
||||
)
|
||||
if (err(pathToExtrudeNode)) {
|
||||
return new Error('Path to extrude node not found')
|
||||
}
|
||||
|
||||
// const radius = createLiteral(5) as Value
|
||||
|
||||
const result = addFillet(ast, pathToSegmentNode, pathToExtrudeNode, radius)
|
||||
if (err(result)) {
|
||||
return result
|
||||
}
|
||||
const { modifiedAst } = result
|
||||
const newCode = recast(modifiedAst)
|
||||
|
||||
expect(newCode).toContain(expectedCode)
|
||||
}
|
||||
|
||||
describe('Testing addFillet', () => {
|
||||
/**
|
||||
* 1. Ideal Case
|
||||
*/
|
||||
|
||||
it('should add a fillet to a specific segment after extrusion, clean', async () => {
|
||||
const code = `
|
||||
const sketch001 = startSketchOn('XZ')
|
||||
|> startProfileAt([2.16, 49.67], %)
|
||||
|> line([101.49, 139.93], %)
|
||||
|> line([60.04, -55.72], %)
|
||||
|> line([1.29, -115.74], %)
|
||||
|> line([-87.24, -47.08], %)
|
||||
|> tangentialArcTo([56.15, -94.58], %)
|
||||
|> tangentialArcTo([14.68, -104.52], %)
|
||||
|> lineTo([profileStartX(%), profileStartY(%)], %)
|
||||
|> close(%)
|
||||
const extrude001 = extrude(50, sketch001)
|
||||
`
|
||||
const segmentSnippet = `line([60.04, -55.72], %)`
|
||||
const extrudeSnippet = `const extrude001 = extrude(50, sketch001)`
|
||||
const radius = createLiteral(5) as Value
|
||||
const expectedCode = `const sketch001 = startSketchOn('XZ')
|
||||
|> startProfileAt([2.16, 49.67], %)
|
||||
|> line([101.49, 139.93], %)
|
||||
|> line([60.04, -55.72], %, $seg01)
|
||||
|> line([1.29, -115.74], %)
|
||||
|> line([-87.24, -47.08], %)
|
||||
|> tangentialArcTo([56.15, -94.58], %)
|
||||
|> tangentialArcTo([14.68, -104.52], %)
|
||||
|> lineTo([profileStartX(%), profileStartY(%)], %)
|
||||
|> close(%)
|
||||
const extrude001 = extrude(50, sketch001)
|
||||
|> fillet({ radius: 5, tags: [seg01] }, %)`
|
||||
|
||||
await runFilletTest(
|
||||
code,
|
||||
segmentSnippet,
|
||||
extrudeSnippet,
|
||||
radius,
|
||||
expectedCode
|
||||
)
|
||||
})
|
||||
|
||||
/**
|
||||
* 2. Case of existing tag in the other line
|
||||
*/
|
||||
|
||||
it('should add a fillet to a specific segment after extrusion with existing tag in any other line', async () => {
|
||||
const code = `
|
||||
const sketch001 = startSketchOn('XZ')
|
||||
|> startProfileAt([2.16, 49.67], %)
|
||||
|> line([101.49, 139.93], %)
|
||||
|> line([60.04, -55.72], %)
|
||||
|> line([1.29, -115.74], %)
|
||||
|> line([-87.24, -47.08], %, $seg01)
|
||||
|> tangentialArcTo([56.15, -94.58], %)
|
||||
|> tangentialArcTo([14.68, -104.52], %)
|
||||
|> lineTo([profileStartX(%), profileStartY(%)], %)
|
||||
|> close(%)
|
||||
const extrude001 = extrude(50, sketch001)
|
||||
`
|
||||
const segmentSnippet = `line([60.04, -55.72], %)`
|
||||
const extrudeSnippet = `const extrude001 = extrude(50, sketch001)`
|
||||
const radius = createLiteral(5) as Value
|
||||
const expectedCode = `const sketch001 = startSketchOn('XZ')
|
||||
|> startProfileAt([2.16, 49.67], %)
|
||||
|> line([101.49, 139.93], %)
|
||||
|> line([60.04, -55.72], %, $seg02)
|
||||
|> line([1.29, -115.74], %)
|
||||
|> line([-87.24, -47.08], %, $seg01)
|
||||
|> tangentialArcTo([56.15, -94.58], %)
|
||||
|> tangentialArcTo([14.68, -104.52], %)
|
||||
|> lineTo([profileStartX(%), profileStartY(%)], %)
|
||||
|> close(%)
|
||||
const extrude001 = extrude(50, sketch001)
|
||||
|> fillet({ radius: 5, tags: [seg02] }, %)`
|
||||
|
||||
await runFilletTest(
|
||||
code,
|
||||
segmentSnippet,
|
||||
extrudeSnippet,
|
||||
radius,
|
||||
expectedCode
|
||||
)
|
||||
})
|
||||
|
||||
/**
|
||||
* 3. Case of existing tag in the fillet line
|
||||
*/
|
||||
|
||||
it('should add a fillet to a specific segment after extrusion with existing tag in that exact line', async () => {
|
||||
const code = `
|
||||
const sketch001 = startSketchOn('XZ')
|
||||
|> startProfileAt([2.16, 49.67], %)
|
||||
|> line([101.49, 139.93], %)
|
||||
|> line([60.04, -55.72], %)
|
||||
|> line([1.29, -115.74], %)
|
||||
|> line([-87.24, -47.08], %, $seg03)
|
||||
|> tangentialArcTo([56.15, -94.58], %)
|
||||
|> tangentialArcTo([14.68, -104.52], %)
|
||||
|> lineTo([profileStartX(%), profileStartY(%)], %)
|
||||
|> close(%)
|
||||
const extrude001 = extrude(50, sketch001)
|
||||
`
|
||||
const segmentSnippet = `line([-87.24, -47.08], %, $seg03)`
|
||||
const extrudeSnippet = `const extrude001 = extrude(50, sketch001)`
|
||||
const radius = createLiteral(5) as Value
|
||||
const expectedCode = `const sketch001 = startSketchOn('XZ')
|
||||
|> startProfileAt([2.16, 49.67], %)
|
||||
|> line([101.49, 139.93], %)
|
||||
|> line([60.04, -55.72], %)
|
||||
|> line([1.29, -115.74], %)
|
||||
|> line([-87.24, -47.08], %, $seg03)
|
||||
|> tangentialArcTo([56.15, -94.58], %)
|
||||
|> tangentialArcTo([14.68, -104.52], %)
|
||||
|> lineTo([profileStartX(%), profileStartY(%)], %)
|
||||
|> close(%)
|
||||
const extrude001 = extrude(50, sketch001)
|
||||
|> fillet({ radius: 5, tags: [seg03] }, %)`
|
||||
|
||||
await runFilletTest(
|
||||
code,
|
||||
segmentSnippet,
|
||||
extrudeSnippet,
|
||||
radius,
|
||||
expectedCode
|
||||
)
|
||||
})
|
||||
|
||||
/**
|
||||
* 4. Case of existing fillet on some other segment
|
||||
*/
|
||||
|
||||
it('should add another fillet after the existing fillet', async () => {
|
||||
const code = `const sketch001 = startSketchOn('XZ')
|
||||
|> startProfileAt([2.16, 49.67], %)
|
||||
|> line([101.49, 139.93], %)
|
||||
|> line([60.04, -55.72], %)
|
||||
|> line([1.29, -115.74], %)
|
||||
|> line([-87.24, -47.08], %, $seg03)
|
||||
|> tangentialArcTo([56.15, -94.58], %)
|
||||
|> tangentialArcTo([14.68, -104.52], %)
|
||||
|> lineTo([profileStartX(%), profileStartY(%)], %)
|
||||
|> close(%)
|
||||
const extrude001 = extrude(50, sketch001)
|
||||
|> fillet({ radius: 10, tags: [seg03] }, %)`
|
||||
const segmentSnippet = `line([60.04, -55.72], %)`
|
||||
const extrudeSnippet = `const extrude001 = extrude(50, sketch001)`
|
||||
const radius = createLiteral(5) as Value
|
||||
const expectedCode = `const sketch001 = startSketchOn('XZ')
|
||||
|> startProfileAt([2.16, 49.67], %)
|
||||
|> line([101.49, 139.93], %)
|
||||
|> line([60.04, -55.72], %, $seg01)
|
||||
|> line([1.29, -115.74], %)
|
||||
|> line([-87.24, -47.08], %, $seg03)
|
||||
|> tangentialArcTo([56.15, -94.58], %)
|
||||
|> tangentialArcTo([14.68, -104.52], %)
|
||||
|> lineTo([profileStartX(%), profileStartY(%)], %)
|
||||
|> close(%)
|
||||
const extrude001 = extrude(50, sketch001)
|
||||
|> fillet({ radius: 10, tags: [seg03] }, %)
|
||||
|> fillet({ radius: 5, tags: [seg01] }, %)`
|
||||
|
||||
await runFilletTest(
|
||||
code,
|
||||
segmentSnippet,
|
||||
extrudeSnippet,
|
||||
radius,
|
||||
expectedCode
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Testing isTagUsedInFillet', () => {
|
||||
const code = `const sketch001 = startSketchOn('XZ')
|
||||
|> startProfileAt([7.72, 4.13], %)
|
||||
|> line([7.11, 3.48], %, $seg01)
|
||||
|> line([-3.29, -13.85], %)
|
||||
|> line([-6.37, 3.88], %, $seg02)
|
||||
|> close(%)
|
||||
const extrude001 = extrude(-5, sketch001)
|
||||
|> fillet({
|
||||
radius: 1.11,
|
||||
tags: [
|
||||
getOppositeEdge(seg01, %),
|
||||
seg01,
|
||||
getPreviousAdjacentEdge(seg02, %)
|
||||
]
|
||||
}, %)
|
||||
`
|
||||
it('should correctly identify getOppositeEdge and baseEdge edges', () => {
|
||||
const ast = parse(code)
|
||||
if (err(ast)) return
|
||||
const lineOfInterest = `line([7.11, 3.48], %, $seg01)`
|
||||
const range: [number, number] = [
|
||||
code.indexOf(lineOfInterest),
|
||||
code.indexOf(lineOfInterest) + lineOfInterest.length,
|
||||
]
|
||||
const pathToNode = getNodePathFromSourceRange(ast, range)
|
||||
if (err(pathToNode)) return
|
||||
const callExp = getNodeFromPath<CallExpression>(
|
||||
ast,
|
||||
pathToNode,
|
||||
'CallExpression'
|
||||
)
|
||||
if (err(callExp)) return
|
||||
const edges = isTagUsedInFillet({ ast, callExp: callExp.node })
|
||||
expect(edges).toEqual(['getOppositeEdge', 'baseEdge'])
|
||||
})
|
||||
it('should correctly identify getPreviousAdjacentEdge edges', () => {
|
||||
const ast = parse(code)
|
||||
if (err(ast)) return
|
||||
const lineOfInterest = `line([-6.37, 3.88], %, $seg02)`
|
||||
const range: [number, number] = [
|
||||
code.indexOf(lineOfInterest),
|
||||
code.indexOf(lineOfInterest) + lineOfInterest.length,
|
||||
]
|
||||
const pathToNode = getNodePathFromSourceRange(ast, range)
|
||||
if (err(pathToNode)) return
|
||||
const callExp = getNodeFromPath<CallExpression>(
|
||||
ast,
|
||||
pathToNode,
|
||||
'CallExpression'
|
||||
)
|
||||
if (err(callExp)) return
|
||||
const edges = isTagUsedInFillet({ ast, callExp: callExp.node })
|
||||
expect(edges).toEqual(['getPreviousAdjacentEdge'])
|
||||
})
|
||||
it('should correctly identify no edges', () => {
|
||||
const ast = parse(code)
|
||||
if (err(ast)) return
|
||||
const lineOfInterest = `line([-3.29, -13.85], %)`
|
||||
const range: [number, number] = [
|
||||
code.indexOf(lineOfInterest),
|
||||
code.indexOf(lineOfInterest) + lineOfInterest.length,
|
||||
]
|
||||
const pathToNode = getNodePathFromSourceRange(ast, range)
|
||||
if (err(pathToNode)) return
|
||||
const callExp = getNodeFromPath<CallExpression>(
|
||||
ast,
|
||||
pathToNode,
|
||||
'CallExpression'
|
||||
)
|
||||
if (err(callExp)) return
|
||||
const edges = isTagUsedInFillet({ ast, callExp: callExp.node })
|
||||
expect(edges).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Testing button states', () => {
|
||||
const runButtonStateTest = async (
|
||||
code: string,
|
||||
segmentSnippet: string,
|
||||
expectedState: boolean
|
||||
) => {
|
||||
// ast
|
||||
const astOrError = parse(code)
|
||||
if (err(astOrError)) {
|
||||
return new Error('AST not found')
|
||||
}
|
||||
const ast = astOrError as Program
|
||||
|
||||
// selectionRanges
|
||||
const range: [number, number] = segmentSnippet
|
||||
? [
|
||||
code.indexOf(segmentSnippet),
|
||||
code.indexOf(segmentSnippet) + segmentSnippet.length,
|
||||
]
|
||||
: [ast.end, ast.end] // empty line in the end of the code
|
||||
|
||||
const selectionRanges: Selections = {
|
||||
codeBasedSelections: [
|
||||
{
|
||||
range,
|
||||
type: 'default',
|
||||
},
|
||||
],
|
||||
otherSelections: [],
|
||||
}
|
||||
|
||||
// state
|
||||
const buttonState = hasValidFilletSelection({
|
||||
ast,
|
||||
selectionRanges,
|
||||
code,
|
||||
})
|
||||
|
||||
expect(buttonState).toEqual(expectedState)
|
||||
}
|
||||
const codeWithBody: string = `
|
||||
const sketch001 = startSketchOn('XY')
|
||||
|> startProfileAt([-20, -5], %)
|
||||
|> line([0, 10], %)
|
||||
|> line([10, 0], %)
|
||||
|> line([0, -10], %)
|
||||
|> lineTo([profileStartX(%), profileStartY(%)], %)
|
||||
|> close(%)
|
||||
const extrude001 = extrude(-10, sketch001)
|
||||
`
|
||||
const codeWithoutBodies: string = `
|
||||
const sketch001 = startSketchOn('XY')
|
||||
|> startProfileAt([-20, -5], %)
|
||||
|> line([0, 10], %)
|
||||
|> line([10, 0], %)
|
||||
|> line([0, -10], %)
|
||||
|> lineTo([profileStartX(%), profileStartY(%)], %)
|
||||
|> close(%)
|
||||
`
|
||||
// body is missing
|
||||
it('should return false when body is missing and nothing is selected', async () => {
|
||||
await runButtonStateTest(codeWithoutBodies, '', false)
|
||||
})
|
||||
it('should return false when body is missing and segment is selected', async () => {
|
||||
await runButtonStateTest(codeWithoutBodies, `line([10, 0], %)`, false)
|
||||
})
|
||||
|
||||
// body exists
|
||||
it('should return true when body exists and nothing is selected', async () => {
|
||||
await runButtonStateTest(codeWithBody, '', true)
|
||||
})
|
||||
it('should return true when body exists and segment is selected', async () => {
|
||||
await runButtonStateTest(codeWithBody, `line([10, 0], %)`, true)
|
||||
})
|
||||
it('hould return false when body exists and not a segment is selected', async () => {
|
||||
await runButtonStateTest(codeWithBody, `close(%)`, false)
|
||||
})
|
||||
})
|
404
src/lang/modifyAst/addFillet.ts
Normal file
@ -0,0 +1,404 @@
|
||||
import {
|
||||
ArrayExpression,
|
||||
CallExpression,
|
||||
ObjectExpression,
|
||||
PathToNode,
|
||||
Program,
|
||||
Value,
|
||||
VariableDeclaration,
|
||||
VariableDeclarator,
|
||||
} from '../wasm'
|
||||
import {
|
||||
createCallExpressionStdLib,
|
||||
createLiteral,
|
||||
createPipeSubstitution,
|
||||
createObjectExpression,
|
||||
createArrayExpression,
|
||||
createIdentifier,
|
||||
createPipeExpression,
|
||||
} from '../modifyAst'
|
||||
import {
|
||||
getNodeFromPath,
|
||||
getNodePathFromSourceRange,
|
||||
hasSketchPipeBeenExtruded,
|
||||
traverse,
|
||||
} from '../queryAst'
|
||||
import {
|
||||
addTagForSketchOnFace,
|
||||
getTagFromCallExpression,
|
||||
sketchLineHelperMap,
|
||||
} from '../std/sketch'
|
||||
import { err } from 'lib/trap'
|
||||
import { Selections, canFilletSelection } from 'lib/selections'
|
||||
|
||||
export function addFillet(
|
||||
node: Program,
|
||||
pathToSegmentNode: PathToNode,
|
||||
pathToExtrudeNode: PathToNode,
|
||||
radius = createLiteral(5) as Value
|
||||
// shouldPipe = false, // TODO: Implement this feature
|
||||
): { modifiedAst: Program; pathToFilletNode: PathToNode } | Error {
|
||||
// close ast to make mutations safe
|
||||
let _node: Program = JSON.parse(JSON.stringify(node))
|
||||
|
||||
/**
|
||||
* Add Tag to the Segment Expression
|
||||
*/
|
||||
|
||||
// Find the specific sketch segment to tag with the new tag
|
||||
const sketchSegmentChunk = getNodeFromPath(
|
||||
_node,
|
||||
pathToSegmentNode,
|
||||
'CallExpression'
|
||||
)
|
||||
if (err(sketchSegmentChunk)) return sketchSegmentChunk
|
||||
const { node: sketchSegmentNode } = sketchSegmentChunk as {
|
||||
node: CallExpression
|
||||
}
|
||||
|
||||
// Check whether selection is a valid segment from sketchLineHelpersMap
|
||||
if (!(sketchSegmentNode.callee.name in sketchLineHelperMap)) {
|
||||
return new Error('Selection is not a sketch segment')
|
||||
}
|
||||
|
||||
// Add tag to the sketch segment or use existing tag
|
||||
const taggedSegment = addTagForSketchOnFace(
|
||||
{
|
||||
// previousProgramMemory: programMemory,
|
||||
pathToNode: pathToSegmentNode,
|
||||
node: _node,
|
||||
},
|
||||
sketchSegmentNode.callee.name
|
||||
)
|
||||
if (err(taggedSegment)) return taggedSegment
|
||||
const { tag } = taggedSegment
|
||||
|
||||
/**
|
||||
* Find Extrude Expression automatically
|
||||
*/
|
||||
|
||||
// 1. Get the sketch name
|
||||
|
||||
/**
|
||||
* Add Fillet to the Extrude expression
|
||||
*/
|
||||
|
||||
// Create the fillet call expression in one line
|
||||
const filletCall = createCallExpressionStdLib('fillet', [
|
||||
createObjectExpression({
|
||||
radius: radius,
|
||||
tags: createArrayExpression([createIdentifier(tag)]),
|
||||
}),
|
||||
createPipeSubstitution(),
|
||||
])
|
||||
|
||||
// Locate the extrude call
|
||||
const extrudeChunk = getNodeFromPath<VariableDeclaration>(
|
||||
_node,
|
||||
pathToExtrudeNode,
|
||||
'VariableDeclaration'
|
||||
)
|
||||
if (err(extrudeChunk)) return extrudeChunk
|
||||
const { node: extrudeVarDecl } = extrudeChunk
|
||||
|
||||
const extrudeDeclarator = extrudeVarDecl.declarations[0]
|
||||
const extrudeInit = extrudeDeclarator.init
|
||||
|
||||
if (
|
||||
!extrudeDeclarator ||
|
||||
(extrudeInit.type !== 'CallExpression' &&
|
||||
extrudeInit.type !== 'PipeExpression')
|
||||
) {
|
||||
return new Error('Extrude PipeExpression / CallExpression not found.')
|
||||
}
|
||||
|
||||
// determine if extrude is in a PipeExpression or CallExpression
|
||||
|
||||
// CallExpression - no fillet
|
||||
// PipeExpression - fillet exists
|
||||
|
||||
const getPathToNodeOfFilletLiteral = (
|
||||
pathToExtrudeNode: PathToNode,
|
||||
extrudeDeclarator: VariableDeclarator,
|
||||
tag: string
|
||||
): PathToNode => {
|
||||
let pathToFilletObj: any
|
||||
let inFillet = false
|
||||
traverse(extrudeDeclarator.init, {
|
||||
enter(node, path) {
|
||||
if (node.type === 'CallExpression' && node.callee.name === 'fillet') {
|
||||
inFillet = true
|
||||
}
|
||||
if (inFillet && node.type === 'ObjectExpression') {
|
||||
const hasTag = node.properties.some((prop) => {
|
||||
const isTagProp = prop.key.name === 'tags'
|
||||
if (isTagProp && prop.value.type === 'ArrayExpression') {
|
||||
return prop.value.elements.some(
|
||||
(element) =>
|
||||
element.type === 'Identifier' && element.name === tag
|
||||
)
|
||||
}
|
||||
return false
|
||||
})
|
||||
if (!hasTag) return false
|
||||
pathToFilletObj = path
|
||||
node.properties.forEach((prop, index) => {
|
||||
if (prop.key.name === 'radius') {
|
||||
pathToFilletObj.push(
|
||||
['properties', 'ObjectExpression'],
|
||||
[index, 'index'],
|
||||
['value', 'Property']
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
leave(node) {
|
||||
if (node.type === 'CallExpression' && node.callee.name === 'fillet') {
|
||||
inFillet = false
|
||||
}
|
||||
},
|
||||
})
|
||||
let indexOfPipeExpression = pathToExtrudeNode.findIndex(
|
||||
(path) => path[1] === 'PipeExpression'
|
||||
)
|
||||
indexOfPipeExpression =
|
||||
indexOfPipeExpression === -1
|
||||
? pathToExtrudeNode.length
|
||||
: indexOfPipeExpression
|
||||
|
||||
return [
|
||||
...pathToExtrudeNode.slice(0, indexOfPipeExpression),
|
||||
...pathToFilletObj,
|
||||
]
|
||||
}
|
||||
|
||||
if (extrudeInit.type === 'CallExpression') {
|
||||
// 1. no fillet case
|
||||
extrudeDeclarator.init = createPipeExpression([extrudeInit, filletCall])
|
||||
return {
|
||||
modifiedAst: _node,
|
||||
pathToFilletNode: getPathToNodeOfFilletLiteral(
|
||||
pathToExtrudeNode,
|
||||
extrudeDeclarator,
|
||||
tag
|
||||
),
|
||||
}
|
||||
} else if (extrudeInit.type === 'PipeExpression') {
|
||||
// 2. fillet case
|
||||
|
||||
// there are 2 options here:
|
||||
|
||||
const existingFilletCall = extrudeInit.body.find((node) => {
|
||||
return node.type === 'CallExpression' && node.callee.name === 'fillet'
|
||||
})
|
||||
|
||||
if (!existingFilletCall || existingFilletCall.type !== 'CallExpression') {
|
||||
return new Error('Fillet CallExpression not found.')
|
||||
}
|
||||
|
||||
// check if the existing fillet has the same tag as the new fillet
|
||||
let filletTag = null
|
||||
if (existingFilletCall.arguments[0].type === 'ObjectExpression') {
|
||||
const properties = (existingFilletCall.arguments[0] as ObjectExpression)
|
||||
.properties
|
||||
const tagsProperty = properties.find((prop) => prop.key.name === 'tags')
|
||||
if (tagsProperty && tagsProperty.value.type === 'ArrayExpression') {
|
||||
const elements = (tagsProperty.value as ArrayExpression).elements
|
||||
if (elements.length > 0 && elements[0].type === 'Identifier') {
|
||||
filletTag = elements[0].name
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return new Error('Expected an ObjectExpression node')
|
||||
}
|
||||
|
||||
if (filletTag !== tag) {
|
||||
extrudeInit.body.push(filletCall)
|
||||
return {
|
||||
modifiedAst: _node,
|
||||
pathToFilletNode: getPathToNodeOfFilletLiteral(
|
||||
pathToExtrudeNode,
|
||||
extrudeDeclarator,
|
||||
tag
|
||||
),
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return new Error('Unsupported extrude type.')
|
||||
}
|
||||
|
||||
return new Error('Unsupported extrude type.')
|
||||
}
|
||||
|
||||
export const hasValidFilletSelection = ({
|
||||
selectionRanges,
|
||||
ast,
|
||||
code,
|
||||
}: {
|
||||
selectionRanges: Selections
|
||||
ast: Program
|
||||
code: string
|
||||
}) => {
|
||||
// case 0: check if there is anything filletable in the scene
|
||||
let extrudeExists = false
|
||||
traverse(ast, {
|
||||
enter(node) {
|
||||
if (node.type === 'CallExpression' && node.callee.name === 'extrude') {
|
||||
extrudeExists = true
|
||||
}
|
||||
},
|
||||
})
|
||||
if (!extrudeExists) return false
|
||||
|
||||
// case 1: nothing selected, test whether the extrusion exists
|
||||
if (selectionRanges) {
|
||||
if (selectionRanges.codeBasedSelections.length === 0) {
|
||||
return true
|
||||
}
|
||||
const range0 = selectionRanges.codeBasedSelections[0].range[0]
|
||||
const codeLength = code.length
|
||||
if (range0 === codeLength) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// case 2: sketch segment selected, test whether it is extruded
|
||||
// TODO: add loft / sweep check
|
||||
if (selectionRanges.codeBasedSelections.length > 0) {
|
||||
const isExtruded = hasSketchPipeBeenExtruded(
|
||||
selectionRanges.codeBasedSelections[0],
|
||||
ast
|
||||
)
|
||||
if (isExtruded) {
|
||||
const pathToSelectedNode = getNodePathFromSourceRange(
|
||||
ast,
|
||||
selectionRanges.codeBasedSelections[0].range
|
||||
)
|
||||
const segmentNode = getNodeFromPath<CallExpression>(
|
||||
ast,
|
||||
pathToSelectedNode,
|
||||
'CallExpression'
|
||||
)
|
||||
if (err(segmentNode)) return false
|
||||
if (segmentNode.node.type === 'CallExpression') {
|
||||
const segmentName = segmentNode.node.callee.name
|
||||
if (segmentName in sketchLineHelperMap) {
|
||||
const edges = isTagUsedInFillet({
|
||||
ast,
|
||||
callExp: segmentNode.node,
|
||||
})
|
||||
// edge has already been filleted
|
||||
if (
|
||||
['edge', 'default'].includes(
|
||||
selectionRanges.codeBasedSelections[0].type
|
||||
) &&
|
||||
edges.includes('baseEdge')
|
||||
)
|
||||
return false
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return canFilletSelection(selectionRanges)
|
||||
}
|
||||
|
||||
type EdgeTypes =
|
||||
| 'baseEdge'
|
||||
| 'getNextAdjacentEdge'
|
||||
| 'getPreviousAdjacentEdge'
|
||||
| 'getOppositeEdge'
|
||||
|
||||
export const isTagUsedInFillet = ({
|
||||
ast,
|
||||
callExp,
|
||||
}: {
|
||||
ast: Program
|
||||
callExp: CallExpression
|
||||
}): Array<EdgeTypes> => {
|
||||
const tag = getTagFromCallExpression(callExp)
|
||||
if (err(tag)) return []
|
||||
|
||||
let inFillet = false
|
||||
let inObj = false
|
||||
let inTagHelper: EdgeTypes | '' = ''
|
||||
const edges: Array<EdgeTypes> = []
|
||||
traverse(ast, {
|
||||
enter: (node) => {
|
||||
if (node.type === 'CallExpression' && node.callee.name === 'fillet') {
|
||||
inFillet = true
|
||||
}
|
||||
if (inFillet && node.type === 'ObjectExpression') {
|
||||
node.properties.forEach((prop) => {
|
||||
if (
|
||||
prop.key.name === 'tags' &&
|
||||
prop.value.type === 'ArrayExpression'
|
||||
) {
|
||||
inObj = true
|
||||
}
|
||||
})
|
||||
}
|
||||
if (
|
||||
inObj &&
|
||||
inFillet &&
|
||||
node.type === 'CallExpression' &&
|
||||
(node.callee.name === 'getOppositeEdge' ||
|
||||
node.callee.name === 'getNextAdjacentEdge' ||
|
||||
node.callee.name === 'getPreviousAdjacentEdge')
|
||||
) {
|
||||
inTagHelper = node.callee.name
|
||||
}
|
||||
if (
|
||||
inObj &&
|
||||
inFillet &&
|
||||
!inTagHelper &&
|
||||
node.type === 'Identifier' &&
|
||||
node.name === tag
|
||||
) {
|
||||
edges.push('baseEdge')
|
||||
}
|
||||
if (
|
||||
inObj &&
|
||||
inFillet &&
|
||||
inTagHelper &&
|
||||
node.type === 'Identifier' &&
|
||||
node.name === tag
|
||||
) {
|
||||
edges.push(inTagHelper)
|
||||
}
|
||||
},
|
||||
leave: (node) => {
|
||||
if (node.type === 'CallExpression' && node.callee.name === 'fillet') {
|
||||
inFillet = false
|
||||
}
|
||||
if (inFillet && node.type === 'ObjectExpression') {
|
||||
node.properties.forEach((prop) => {
|
||||
if (
|
||||
prop.key.name === 'tags' &&
|
||||
prop.value.type === 'ArrayExpression'
|
||||
) {
|
||||
inObj = true
|
||||
}
|
||||
})
|
||||
}
|
||||
if (
|
||||
inObj &&
|
||||
inFillet &&
|
||||
node.type === 'CallExpression' &&
|
||||
(node.callee.name === 'getOppositeEdge' ||
|
||||
node.callee.name === 'getNextAdjacentEdge' ||
|
||||
node.callee.name === 'getPreviousAdjacentEdge')
|
||||
) {
|
||||
inTagHelper = ''
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
return edges
|
||||
}
|
@ -143,6 +143,7 @@ export enum DisconnectingType {
|
||||
Error = 'error',
|
||||
Timeout = 'timeout',
|
||||
Quit = 'quit',
|
||||
Pause = 'pause',
|
||||
}
|
||||
|
||||
// Sorted by severity
|
||||
@ -200,6 +201,7 @@ export type DisconnectingValue =
|
||||
| State<DisconnectingType.Error, ErrorType>
|
||||
| State<DisconnectingType.Timeout, void>
|
||||
| State<DisconnectingType.Quit, void>
|
||||
| State<DisconnectingType.Pause, void>
|
||||
|
||||
// These are ordered by the expected sequence.
|
||||
export enum ConnectingType {
|
||||
@ -300,7 +302,7 @@ class EngineConnection extends EventTarget {
|
||||
pc?: RTCPeerConnection
|
||||
unreliableDataChannel?: RTCDataChannel
|
||||
mediaStream?: MediaStream
|
||||
freezeFrame: boolean = false
|
||||
idleMode: boolean = false
|
||||
|
||||
onIceCandidate = function (
|
||||
this: RTCPeerConnection,
|
||||
@ -391,10 +393,10 @@ class EngineConnection extends EventTarget {
|
||||
this.pingPongSpan = { ping: undefined, pong: undefined }
|
||||
|
||||
// Without an interval ping, our connection will timeout.
|
||||
// If this.freezeFrame is true we skip this logic so only reconnect
|
||||
// If this.idleMode is true we skip this logic so only reconnect
|
||||
// happens on mouse move
|
||||
this.pingIntervalId = setInterval(() => {
|
||||
if (this.freezeFrame) return
|
||||
if (this.idleMode) return
|
||||
|
||||
switch (this.state.type as EngineConnectionStateType) {
|
||||
case EngineConnectionStateType.ConnectionEstablished:
|
||||
@ -456,8 +458,8 @@ class EngineConnection extends EventTarget {
|
||||
return this.state.type === EngineConnectionStateType.ConnectionEstablished
|
||||
}
|
||||
|
||||
tearDown(opts?: { freeze: boolean }) {
|
||||
this.freezeFrame = opts?.freeze ?? false
|
||||
tearDown(opts?: { idleMode: boolean }) {
|
||||
this.idleMode = opts?.idleMode ?? false
|
||||
this.disconnectAll()
|
||||
clearInterval(this.pingIntervalId)
|
||||
|
||||
@ -497,10 +499,19 @@ class EngineConnection extends EventTarget {
|
||||
this.onNetworkStatusReady
|
||||
)
|
||||
|
||||
this.state = {
|
||||
type: EngineConnectionStateType.Disconnecting,
|
||||
value: { type: DisconnectingType.Quit },
|
||||
}
|
||||
this.state = opts?.idleMode
|
||||
? {
|
||||
type: EngineConnectionStateType.Disconnecting,
|
||||
value: {
|
||||
type: DisconnectingType.Pause,
|
||||
},
|
||||
}
|
||||
: {
|
||||
type: EngineConnectionStateType.Disconnecting,
|
||||
value: {
|
||||
type: DisconnectingType.Quit,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -868,8 +879,7 @@ class EngineConnection extends EventTarget {
|
||||
.join('\n')
|
||||
if (message.request_id) {
|
||||
const artifactThatFailed =
|
||||
this.engineCommandManager.artifactMap[message.request_id] ||
|
||||
this.engineCommandManager.lastArtifactMap[message.request_id]
|
||||
this.engineCommandManager.artifactMap[message.request_id]
|
||||
console.error(
|
||||
`Error in response to request ${message.request_id}:\n${errorsString}
|
||||
failed cmd type was ${artifactThatFailed?.commandType}`
|
||||
@ -1099,8 +1109,6 @@ class EngineConnection extends EventTarget {
|
||||
this.unreliableDataChannel?.readyState === 'closed'
|
||||
if (allClosed) {
|
||||
// Do not notify the rest of the program that we have cut off anything.
|
||||
if (this.freezeFrame) return
|
||||
|
||||
this.state = { type: EngineConnectionStateType.Disconnected }
|
||||
}
|
||||
}
|
||||
@ -1174,13 +1182,6 @@ export class EngineCommandManager extends EventTarget {
|
||||
* of the KCL code that generated it.
|
||||
*/
|
||||
artifactMap: ArtifactMap = {}
|
||||
/**
|
||||
* The {@link ArtifactMap} from the previous engine connection. This is used as a fallback
|
||||
* when the engine connection is reset without a full client-side refresh.
|
||||
*
|
||||
* @deprecated This was used during a short time when we were choosing to not execute the engine in certain cases.
|
||||
*/
|
||||
lastArtifactMap: ArtifactMap = {}
|
||||
/**
|
||||
* The client-side representation of the scene command artifacts that have been sent to the server;
|
||||
* that is, the *non-modeling* commands and corresponding artifacts.
|
||||
@ -1584,10 +1585,7 @@ export class EngineCommandManager extends EventTarget {
|
||||
type: 'receive-reliable',
|
||||
data: message,
|
||||
id,
|
||||
cmd_type:
|
||||
command?.commandType ||
|
||||
this.lastArtifactMap[id]?.commandType ||
|
||||
sceneCommand?.commandType,
|
||||
cmd_type: command?.commandType || sceneCommand?.commandType,
|
||||
})
|
||||
Object.values(this.subscriptions[modelingResponse.type] || {}).forEach(
|
||||
(callback) => callback(modelingResponse)
|
||||
@ -1738,7 +1736,7 @@ export class EngineCommandManager extends EventTarget {
|
||||
}
|
||||
}
|
||||
}
|
||||
tearDown() {
|
||||
tearDown(opts?: { idleMode: boolean }) {
|
||||
if (this.engineConnection) {
|
||||
this.engineConnection.removeEventListener(
|
||||
EngineConnectionEvents.Opened,
|
||||
@ -1757,7 +1755,7 @@ export class EngineCommandManager extends EventTarget {
|
||||
this.onEngineConnectionNewTrack as EventListener
|
||||
)
|
||||
|
||||
this.engineConnection?.tearDown()
|
||||
this.engineConnection?.tearDown(opts)
|
||||
this.engineConnection = undefined
|
||||
|
||||
// Our window.tearDown assignment causes this case to happen which is
|
||||
@ -1765,11 +1763,10 @@ export class EngineCommandManager extends EventTarget {
|
||||
// @ts-ignore
|
||||
} else if (this.engineCommandManager?.engineConnection) {
|
||||
// @ts-ignore
|
||||
this.engineCommandManager?.engineConnection?.tearDown()
|
||||
this.engineCommandManager?.engineConnection?.tearDown(opts)
|
||||
}
|
||||
}
|
||||
async startNewSession() {
|
||||
this.lastArtifactMap = this.artifactMap
|
||||
this.artifactMap = {}
|
||||
await this.initPlanes()
|
||||
}
|
||||
|