Compare commits
31 Commits
derive-doc
...
v0.17.2
Author | SHA1 | Date | |
---|---|---|---|
b32295e1d9 | |||
e0838c1198 | |||
f03f34d8be | |||
108bb4ee90 | |||
092d459026 | |||
c4f7296e32 | |||
1cbd422d7f | |||
849685a986 | |||
359b3c1f35 | |||
f4ff5e43f2 | |||
daf7350c9e | |||
a829cdb006 | |||
1a7a19ee85 | |||
b045a89854 | |||
221f037eaa | |||
a93b72f7e1 | |||
63f36cbcbf | |||
79b50ef7d4 | |||
3d16dcd30d | |||
d605d4a029 | |||
77f51530f9 | |||
76480f1a43 | |||
f850f80de1 | |||
15ebbe6947 | |||
01beba42da | |||
509e372ed2 | |||
b0417114af | |||
0360a4021b | |||
6f36371e6d | |||
ebcc19e757 | |||
84cbcddff1 |
@ -1,3 +1,3 @@
|
||||
[codespell]
|
||||
ignore-words-list: crate,everytime,inout,co-ordinate,ot,nwo,absolutey
|
||||
ignore-words-list: crate,everytime,inout,co-ordinate,ot,nwo,absolutey,atleast
|
||||
skip: **/target,node_modules,build,**/Cargo.lock,./docs/kcl/*.md
|
||||
|
Before Width: | Height: | Size: 221 KiB After Width: | Height: | Size: 224 KiB |
Before Width: | Height: | Size: 221 KiB After Width: | Height: | Size: 224 KiB |
Before Width: | Height: | Size: 221 KiB After Width: | Height: | Size: 224 KiB |
Before Width: | Height: | Size: 163 KiB After Width: | Height: | Size: 165 KiB |
Before Width: | Height: | Size: 163 KiB After Width: | Height: | Size: 165 KiB |
Before Width: | Height: | Size: 163 KiB After Width: | Height: | Size: 165 KiB |
Before Width: | Height: | Size: 163 KiB After Width: | Height: | Size: 165 KiB |
Before Width: | Height: | Size: 221 KiB After Width: | Height: | Size: 224 KiB |
Before Width: | Height: | Size: 163 KiB After Width: | Height: | Size: 165 KiB |
Before Width: | Height: | Size: 163 KiB After Width: | Height: | Size: 165 KiB |
@ -1,10 +1,11 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { secrets } from './secrets'
|
||||
import { getUtils } from './test-utils'
|
||||
import waitOn from 'wait-on'
|
||||
import { Themes } from '../../src/lib/theme'
|
||||
import { initialSettings } from '../../src/lib/settings/initialSettings'
|
||||
import { roundOff } from 'lib/utils'
|
||||
import { basicStorageState } from './storageStates'
|
||||
import * as TOML from '@iarna/toml'
|
||||
import { SaveSettingsPayload } from 'lib/settings/settingsTypes'
|
||||
import { Themes } from 'lib/theme'
|
||||
|
||||
/*
|
||||
debug helper: unfortunately we do rely on exact coord mouse clicks in a few places
|
||||
@ -30,31 +31,14 @@ test.beforeEach(async ({ context, page }) => {
|
||||
resources: ['tcp:3000'],
|
||||
timeout: 5000,
|
||||
})
|
||||
await context.addInitScript(async (token) => {
|
||||
localStorage.setItem('TOKEN_PERSIST_KEY', token)
|
||||
localStorage.setItem('persistCode', ``)
|
||||
localStorage.setItem(
|
||||
'SETTINGS_PERSIST_KEY',
|
||||
JSON.stringify({
|
||||
baseUnit: 'in',
|
||||
cameraControls: 'KittyCAD',
|
||||
defaultDirectory: '',
|
||||
defaultProjectName: 'project-$nnn',
|
||||
onboardingStatus: 'dismissed',
|
||||
showDebugPanel: true,
|
||||
textWrapping: 'On',
|
||||
theme: 'system',
|
||||
unitSystem: 'imperial',
|
||||
})
|
||||
)
|
||||
}, secrets.token)
|
||||
|
||||
// kill animations, speeds up tests and reduced flakiness
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' })
|
||||
})
|
||||
|
||||
test.setTimeout(60000)
|
||||
|
||||
test('Basic sketch', async ({ page }) => {
|
||||
test('Basic sketch', async ({ page, context }) => {
|
||||
const u = getUtils(page)
|
||||
await page.setViewportSize({ width: 1200, height: 500 })
|
||||
const PUR = 400 / 37.5 //pixeltoUnitRatio
|
||||
@ -529,96 +513,133 @@ test('Auto complete works', async ({ page }) => {
|
||||
})
|
||||
|
||||
// Stored settings validation test
|
||||
test('Stored settings are validated and fall back to defaults', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
// Override beforeEach test setup
|
||||
test.describe('Settings persistence and validation tests', () => {
|
||||
// Override test setup
|
||||
// with corrupted settings
|
||||
await context.addInitScript(async () => {
|
||||
const storedSettings = JSON.parse(
|
||||
localStorage.getItem('SETTINGS_PERSIST_KEY') || '{}'
|
||||
)
|
||||
const storageState = structuredClone(basicStorageState)
|
||||
const s = TOML.parse(storageState.origins[0].localStorage[2].value) as {
|
||||
settings: SaveSettingsPayload
|
||||
}
|
||||
s.settings.app.theme = Themes.Dark
|
||||
s.settings.app.projectDirectory = 123 as any
|
||||
s.settings.modeling.defaultUnit = 'invalid' as any
|
||||
s.settings.modeling.mouseControls = `() => alert('hack the planet')` as any
|
||||
s.settings.projects.defaultProjectName = false as any
|
||||
storageState.origins[0].localStorage[2].value = TOML.stringify(s)
|
||||
|
||||
// Corrupt the settings
|
||||
storedSettings.baseUnit = 'invalid'
|
||||
storedSettings.cameraControls = `() => alert('hack the planet')`
|
||||
storedSettings.defaultDirectory = 123
|
||||
storedSettings.defaultProjectName = false
|
||||
test.use({ storageState })
|
||||
|
||||
localStorage.setItem('SETTINGS_PERSIST_KEY', JSON.stringify(storedSettings))
|
||||
test('Stored settings are validated and fall back to defaults', async ({
|
||||
page,
|
||||
}) => {
|
||||
const u = getUtils(page)
|
||||
await page.setViewportSize({ width: 1200, height: 500 })
|
||||
await page.goto('/')
|
||||
await u.waitForAuthSkipAppStart()
|
||||
|
||||
// Check the settings were reset
|
||||
const storedSettings = TOML.parse(
|
||||
await page.evaluate(() => localStorage.getItem('/user.toml') || '{}')
|
||||
) as { settings: SaveSettingsPayload }
|
||||
|
||||
expect(storedSettings.settings.app?.theme).toBe('dark')
|
||||
|
||||
// Check that the invalid settings were removed
|
||||
expect(storedSettings.settings.modeling?.defaultUnit).toBe(undefined)
|
||||
expect(storedSettings.settings.modeling?.mouseControls).toBe(undefined)
|
||||
expect(storedSettings.settings.app?.projectDirectory).toBe(undefined)
|
||||
expect(storedSettings.settings.projects?.defaultProjectName).toBe(undefined)
|
||||
})
|
||||
|
||||
await page.setViewportSize({ width: 1200, height: 500 })
|
||||
await page.goto('/', { waitUntil: 'domcontentloaded' })
|
||||
test('Project settings can be set and override user settings', async ({
|
||||
page,
|
||||
}) => {
|
||||
const u = getUtils(page)
|
||||
await page.setViewportSize({ width: 1200, height: 500 })
|
||||
await page.goto('/')
|
||||
await u.waitForAuthSkipAppStart()
|
||||
|
||||
// Check the toast appeared
|
||||
await expect(
|
||||
page.getByText(`Error validating persisted settings:`, {
|
||||
exact: false,
|
||||
})
|
||||
).toBeVisible()
|
||||
// Open the settings modal with the browser keyboard shortcut
|
||||
await page.keyboard.press('Meta+Shift+,')
|
||||
|
||||
// Check the settings were reset
|
||||
const storedSettings = JSON.parse(
|
||||
await page.evaluate(
|
||||
() => localStorage.getItem('SETTINGS_PERSIST_KEY') || '{}'
|
||||
)
|
||||
)
|
||||
await expect(storedSettings.baseUnit).toBe(initialSettings.baseUnit)
|
||||
await expect(storedSettings.cameraControls).toBe(
|
||||
initialSettings.cameraControls
|
||||
)
|
||||
await expect(storedSettings.defaultDirectory).toBe(
|
||||
initialSettings.defaultDirectory
|
||||
)
|
||||
await expect(storedSettings.defaultProjectName).toBe(
|
||||
initialSettings.defaultProjectName
|
||||
)
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Settings', exact: true })
|
||||
).toBeVisible()
|
||||
await page
|
||||
.locator('select[name="app-theme"]')
|
||||
.selectOption({ value: 'light' })
|
||||
|
||||
// Verify the toast appeared
|
||||
await expect(
|
||||
page.getByText(`Set theme to "light" for this project`)
|
||||
).toBeVisible()
|
||||
// Check that the theme changed
|
||||
await expect(page.locator('body')).not.toHaveClass(`body-bg dark`)
|
||||
|
||||
// Check that the user setting was not changed
|
||||
await page.getByRole('radio', { name: 'User' }).click()
|
||||
await expect(page.locator('select[name="app-theme"]')).toHaveValue('dark')
|
||||
|
||||
// Roll back to default "system" theme
|
||||
await page
|
||||
.getByText(
|
||||
'themeRoll back themeRoll back to match defaultThe overall appearance of the appl'
|
||||
)
|
||||
.hover()
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: 'Roll back theme ; Has tooltip: Roll back to match default',
|
||||
})
|
||||
.click()
|
||||
await expect(page.locator('select[name="app-theme"]')).toHaveValue('system')
|
||||
|
||||
// Check that the project setting did not change
|
||||
await page.getByRole('radio', { name: 'Project' }).click()
|
||||
await expect(page.locator('select[name="app-theme"]')).toHaveValue('light')
|
||||
})
|
||||
})
|
||||
|
||||
// Onboarding tests
|
||||
test('Onboarding redirects and code updating', async ({ page, context }) => {
|
||||
const u = getUtils(page)
|
||||
test.describe('Onboarding tests', () => {
|
||||
// Override test setup
|
||||
const storageState = structuredClone(basicStorageState)
|
||||
const s = TOML.parse(storageState.origins[0].localStorage[2].value) as {
|
||||
settings: SaveSettingsPayload
|
||||
}
|
||||
s.settings.app.onboardingStatus = '/export'
|
||||
storageState.origins[0].localStorage[2].value = TOML.stringify(s)
|
||||
test.use({ storageState })
|
||||
|
||||
// Override beforeEach test setup
|
||||
await context.addInitScript(async () => {
|
||||
// Give some initial code, so we can test that it's cleared
|
||||
localStorage.setItem('persistCode', 'const sigmaAllow = 15000')
|
||||
test('Onboarding redirects and code updating', async ({ page, context }) => {
|
||||
const u = getUtils(page)
|
||||
|
||||
const storedSettings = JSON.parse(
|
||||
localStorage.getItem('SETTINGS_PERSIST_KEY') || '{}'
|
||||
await page.setViewportSize({ width: 1200, height: 500 })
|
||||
await page.goto('/')
|
||||
await u.waitForAuthSkipAppStart()
|
||||
|
||||
// Test that the redirect happened
|
||||
await expect(page.url().split(':3000').slice(-1)[0]).toBe(
|
||||
`/file/%2Fbrowser%2Fmain.kcl/onboarding/export`
|
||||
)
|
||||
storedSettings.onboardingStatus = '/export'
|
||||
localStorage.setItem('SETTINGS_PERSIST_KEY', JSON.stringify(storedSettings))
|
||||
|
||||
// Test that you come back to this page when you refresh
|
||||
await page.reload()
|
||||
await expect(page.url().split(':3000').slice(-1)[0]).toBe(
|
||||
`/file/%2Fbrowser%2Fmain.kcl/onboarding/export`
|
||||
)
|
||||
|
||||
// Test that the onboarding pane loaded
|
||||
const title = page.locator('[data-testid="onboarding-content"]')
|
||||
await expect(title).toBeAttached()
|
||||
|
||||
// Test that the code changes when you advance to the next step
|
||||
await page.locator('[data-testid="onboarding-next"]').click()
|
||||
await expect(page.locator('.cm-content')).toHaveText('')
|
||||
|
||||
// Test that the code is not empty when you click on the next step
|
||||
await page.locator('[data-testid="onboarding-next"]').click()
|
||||
await expect(page.locator('.cm-content')).toHaveText(/.+/)
|
||||
})
|
||||
|
||||
await page.setViewportSize({ width: 1200, height: 500 })
|
||||
await page.goto('/')
|
||||
await u.waitForAuthSkipAppStart()
|
||||
|
||||
// Test that the redirect happened
|
||||
await expect(page.url().split(':3000').slice(-1)[0]).toBe(
|
||||
`/file/new/onboarding/export`
|
||||
)
|
||||
|
||||
// Test that you come back to this page when you refresh
|
||||
await page.reload()
|
||||
await expect(page.url().split(':3000').slice(-1)[0]).toBe(
|
||||
`/file/new/onboarding/export`
|
||||
)
|
||||
|
||||
// Test that the onboarding pane loaded
|
||||
const title = page.locator('[data-testid="onboarding-content"]')
|
||||
await expect(title).toBeAttached()
|
||||
|
||||
// Test that the code changes when you advance to the next step
|
||||
await page.locator('[data-testid="onboarding-next"]').click()
|
||||
await expect(page.locator('.cm-content')).toHaveText('')
|
||||
|
||||
// Test that the code is not empty when you click on the next step
|
||||
await page.locator('[data-testid="onboarding-next"]').click()
|
||||
await expect(page.locator('.cm-content')).toHaveText(/.+/)
|
||||
})
|
||||
|
||||
test('Selections work on fresh and edited sketch', async ({ page }) => {
|
||||
@ -779,129 +800,134 @@ test('Selections work on fresh and edited sketch', async ({ page }) => {
|
||||
await selectionSequence()
|
||||
})
|
||||
|
||||
test('Command bar works and can change a setting', async ({ page }) => {
|
||||
// Brief boilerplate
|
||||
const u = getUtils(page)
|
||||
await page.setViewportSize({ width: 1200, height: 500 })
|
||||
await page.goto('/')
|
||||
await u.waitForAuthSkipAppStart()
|
||||
test.describe('Command bar tests', () => {
|
||||
test('Command bar works and can change a setting', async ({ page }) => {
|
||||
// Brief boilerplate
|
||||
await page.setViewportSize({ width: 1200, height: 500 })
|
||||
await page.goto('/', { waitUntil: 'domcontentloaded' })
|
||||
|
||||
let cmdSearchBar = page.getByPlaceholder('Search commands')
|
||||
let cmdSearchBar = page.getByPlaceholder('Search commands')
|
||||
|
||||
// First try opening the command bar and closing it
|
||||
// It has a different label on mac and windows/linux, "Meta+K" and "Ctrl+/" respectively
|
||||
await page
|
||||
.getByRole('button', { name: 'Ctrl+/' })
|
||||
.or(page.getByRole('button', { name: '⌘K' }))
|
||||
.click()
|
||||
await expect(cmdSearchBar).toBeVisible()
|
||||
await page.keyboard.press('Escape')
|
||||
await expect(cmdSearchBar).not.toBeVisible()
|
||||
// First try opening the command bar and closing it
|
||||
// It has a different label on mac and windows/linux, "Meta+K" and "Ctrl+/" respectively
|
||||
await page
|
||||
.getByRole('button', { name: 'Ctrl+/' })
|
||||
.or(page.getByRole('button', { name: '⌘K' }))
|
||||
.click()
|
||||
await expect(cmdSearchBar).toBeVisible()
|
||||
await page.keyboard.press('Escape')
|
||||
await expect(cmdSearchBar).not.toBeVisible()
|
||||
|
||||
// Now try the same, but with the keyboard shortcut, check focus
|
||||
await page.keyboard.press('Meta+K')
|
||||
await expect(cmdSearchBar).toBeVisible()
|
||||
await expect(cmdSearchBar).toBeFocused()
|
||||
// Now try the same, but with the keyboard shortcut, check focus
|
||||
await page.keyboard.press('Meta+K')
|
||||
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: 'Set Theme' })
|
||||
await expect(themeOption).toBeVisible()
|
||||
await themeOption.click()
|
||||
const themeInput = page.getByPlaceholder('system')
|
||||
await expect(themeInput).toBeVisible()
|
||||
await expect(themeInput).toBeFocused()
|
||||
// Select dark theme
|
||||
await page.keyboard.press('ArrowDown')
|
||||
await page.keyboard.press('ArrowUp')
|
||||
await expect(page.getByRole('option', { name: Themes.Dark })).toHaveAttribute(
|
||||
'data-headlessui-state',
|
||||
'active'
|
||||
)
|
||||
await page.keyboard.press('Enter')
|
||||
|
||||
// Check the toast appeared
|
||||
await expect(page.getByText(`Set Theme to "${Themes.Dark}"`)).toBeVisible()
|
||||
// Check that the theme changed
|
||||
await expect(page.locator('body')).toHaveClass(`body-bg ${Themes.Dark}`)
|
||||
})
|
||||
|
||||
test('Can extrude from the command bar', async ({ page, context }) => {
|
||||
await context.addInitScript(async (token) => {
|
||||
localStorage.setItem(
|
||||
'persistCode',
|
||||
`
|
||||
const distance = sqrt(20)
|
||||
const part001 = startSketchOn('-XZ')
|
||||
|> startProfileAt([-6.95, 4.98], %)
|
||||
|> line([25.1, 0.41], %)
|
||||
|> line([0.73, -14.93], %)
|
||||
|> line([-23.44, 0.52], %)
|
||||
|> close(%)
|
||||
`
|
||||
// Try typing in the command bar
|
||||
await page.keyboard.type('theme')
|
||||
const themeOption = page.getByRole('option', {
|
||||
name: 'Settings · app · theme',
|
||||
})
|
||||
await expect(themeOption).toBeVisible()
|
||||
await themeOption.click()
|
||||
const themeInput = page.getByPlaceholder('Select an option')
|
||||
await expect(themeInput).toBeVisible()
|
||||
await expect(themeInput).toBeFocused()
|
||||
// Select dark theme
|
||||
await page.keyboard.press('ArrowDown')
|
||||
await page.keyboard.press('ArrowDown')
|
||||
await page.keyboard.press('ArrowDown')
|
||||
await expect(page.getByRole('option', { name: 'system' })).toHaveAttribute(
|
||||
'data-headlessui-state',
|
||||
'active'
|
||||
)
|
||||
await page.keyboard.press('Enter')
|
||||
|
||||
// Check the toast appeared
|
||||
await expect(
|
||||
page.getByText(`Set theme to "system" for this project`)
|
||||
).toBeVisible()
|
||||
// Check that the theme changed
|
||||
await expect(page.locator('body')).not.toHaveClass(`body-bg dark`)
|
||||
})
|
||||
|
||||
const u = getUtils(page)
|
||||
await page.setViewportSize({ width: 1200, height: 500 })
|
||||
await page.goto('/')
|
||||
await u.waitForAuthSkipAppStart()
|
||||
await u.openDebugPanel()
|
||||
await u.expectCmdLog('[data-message-type="execution-done"]')
|
||||
// Override test setup code
|
||||
const storageState = structuredClone(basicStorageState)
|
||||
storageState.origins[0].localStorage[1].value = `const distance = sqrt(20)
|
||||
const part001 = startSketchOn('-XZ')
|
||||
|> startProfileAt([-6.95, 4.98], %)
|
||||
|> line([25.1, 0.41], %)
|
||||
|> line([0.73, -14.93], %)
|
||||
|> line([-23.44, 0.52], %)
|
||||
|> close(%)
|
||||
`
|
||||
test.use({ storageState })
|
||||
|
||||
let cmdSearchBar = page.getByPlaceholder('Search commands')
|
||||
await page.keyboard.press('Meta+K')
|
||||
await expect(cmdSearchBar).toBeVisible()
|
||||
test('Can extrude from the command bar', async ({ page, context }) => {
|
||||
const u = getUtils(page)
|
||||
await page.setViewportSize({ width: 1200, height: 500 })
|
||||
await page.goto('/')
|
||||
await u.waitForAuthSkipAppStart()
|
||||
|
||||
// Search for extrude command and choose it
|
||||
await page.getByRole('option', { name: 'Extrude' }).click()
|
||||
await expect(page.locator('#arg-form > label')).toContainText(
|
||||
'Please select one face'
|
||||
)
|
||||
await expect(page.getByRole('button', { name: 'selection' })).toBeDisabled()
|
||||
// Make sure the stream is up
|
||||
await u.openDebugPanel()
|
||||
await u.expectCmdLog('[data-message-type="execution-done"]')
|
||||
await u.closeDebugPanel()
|
||||
|
||||
// Click to select face and set distance
|
||||
await page.getByText('|> startProfileAt([-6.95, 4.98], %)').click()
|
||||
await page.getByRole('button', { name: 'Continue' }).click()
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Start Sketch' })
|
||||
).not.toBeDisabled()
|
||||
await page.getByText('|> startProfileAt([-6.95, 4.98], %)').click()
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Extrude' })
|
||||
).not.toBeDisabled()
|
||||
|
||||
// Assert that we're on the distance step
|
||||
await expect(page.getByRole('button', { name: 'distance' })).toBeDisabled()
|
||||
let cmdSearchBar = page.getByPlaceholder('Search commands')
|
||||
await page.keyboard.press('Meta+K')
|
||||
await expect(cmdSearchBar).toBeVisible()
|
||||
|
||||
// Assert that the an alternative variable name is chosen,
|
||||
// since the default variable name is already in use (distance)
|
||||
await page.getByRole('button', { name: 'Create new variable' }).click()
|
||||
await expect(page.getByPlaceholder('Variable name')).toHaveValue(
|
||||
'distance001'
|
||||
)
|
||||
await expect(page.getByRole('button', { name: 'Continue' })).toBeEnabled()
|
||||
await page.getByRole('button', { name: 'Continue' }).click()
|
||||
// Search for extrude command and choose it
|
||||
await page.getByRole('option', { name: 'Extrude' }).click()
|
||||
|
||||
// Review step and argument hotkeys
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Submit command' })
|
||||
).toBeEnabled()
|
||||
await page.keyboard.press('Backspace')
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Distance 12', exact: false })
|
||||
).toBeDisabled()
|
||||
await page.keyboard.press('Enter')
|
||||
// Assert that we're on the distance step
|
||||
await expect(page.getByRole('button', { name: 'distance' })).toBeDisabled()
|
||||
|
||||
await expect(page.getByText('Confirm Extrude')).toBeVisible()
|
||||
// Assert that the an alternative variable name is chosen,
|
||||
// since the default variable name is already in use (distance)
|
||||
await page.getByRole('button', { name: 'Create new variable' }).click()
|
||||
await expect(page.getByPlaceholder('Variable name')).toHaveValue(
|
||||
'distance001'
|
||||
)
|
||||
await expect(page.getByRole('button', { name: 'Continue' })).toBeEnabled()
|
||||
await page.getByRole('button', { name: 'Continue' }).click()
|
||||
|
||||
// Check that the code was updated
|
||||
await page.keyboard.press('Enter')
|
||||
// Unfortunately this indentation seems to matter for the test
|
||||
await expect(page.locator('.cm-content')).toHaveText(
|
||||
`const distance = sqrt(20)
|
||||
// Review step and argument hotkeys
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Submit command' })
|
||||
).toBeEnabled()
|
||||
await page.keyboard.press('Backspace')
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Distance 12', exact: false })
|
||||
).toBeDisabled()
|
||||
await page.keyboard.press('Enter')
|
||||
|
||||
await expect(page.getByText('Confirm Extrude')).toBeVisible()
|
||||
|
||||
// Check that the code was updated
|
||||
await page.keyboard.press('Enter')
|
||||
// Unfortunately this indentation seems to matter for the test
|
||||
await expect(page.locator('.cm-content')).toHaveText(
|
||||
`const distance = sqrt(20)
|
||||
const distance001 = 5 + 7
|
||||
const part001 = startSketchOn('-XZ')
|
||||
|> startProfileAt([-6.95, 4.98], %)
|
||||
|> line([25.1, 0.41], %)
|
||||
|> line([0.73, -14.93], %)
|
||||
|> line([-23.44, 0.52], %)
|
||||
|> close(%)
|
||||
|> extrude(distance001, %)`.replace(/(\r\n|\n|\r)/gm, '') // remove newlines
|
||||
)
|
||||
|> startProfileAt([-6.95, 4.98], %)
|
||||
|> line([25.1, 0.41], %)
|
||||
|> line([0.73, -14.93], %)
|
||||
|> line([-23.44, 0.52], %)
|
||||
|> close(%)
|
||||
|> extrude(distance001, %)`.replace(/(\r\n|\n|\r)/gm, '') // remove newlines
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
test('Can add multiple sketches', async ({ page }) => {
|
||||
@ -1470,9 +1496,13 @@ test('Sketch on face', async ({ page, context }) => {
|
||||
await page.getByText('startProfileAt([1.03, 1.03], %)').click()
|
||||
await expect(page.getByRole('button', { name: 'Edit Sketch' })).toBeVisible()
|
||||
await page.getByRole('button', { name: 'Edit Sketch' }).click()
|
||||
await page.setViewportSize({ width: 1200, height: 1200 })
|
||||
await u.openAndClearDebugPanel()
|
||||
await u.updateCamPosition([452, -152, 1166])
|
||||
await u.closeDebugPanel()
|
||||
await page.waitForTimeout(200)
|
||||
|
||||
const pointToDragFirst = [691, 237]
|
||||
const pointToDragFirst = [787, 565]
|
||||
await page.mouse.move(pointToDragFirst[0], pointToDragFirst[1])
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(pointToDragFirst[0] - 20, pointToDragFirst[1], {
|
||||
@ -1486,7 +1516,9 @@ test('Sketch on face', async ({ page, context }) => {
|
||||
await expect(page.locator('.cm-content'))
|
||||
.toContainText(`const part002 = startSketchOn(part001, 'seg01')
|
||||
|> startProfileAt([1.03, 1.03], %)
|
||||
|> line([2.81, -0.33], %)
|
||||
|> line([${process?.env?.CI ? 2.74 : 2.93}, -${
|
||||
process?.env?.CI ? 0.24 : 0.2
|
||||
}], %)
|
||||
|> line([-4.44, -2.13], %)
|
||||
|> close(%)`)
|
||||
|
||||
@ -1501,6 +1533,7 @@ test('Sketch on face', async ({ page, context }) => {
|
||||
await page.getByRole('button', { name: 'Extrude' }).click()
|
||||
|
||||
await expect(page.getByTestId('command-bar')).toBeVisible()
|
||||
await page.waitForTimeout(100)
|
||||
|
||||
await page.keyboard.press('Enter')
|
||||
await expect(page.getByText('Confirm Extrude')).toBeVisible()
|
||||
@ -1509,7 +1542,9 @@ test('Sketch on face', async ({ page, context }) => {
|
||||
await expect(page.locator('.cm-content'))
|
||||
.toContainText(`const part002 = startSketchOn(part001, 'seg01')
|
||||
|> startProfileAt([1.03, 1.03], %)
|
||||
|> line([2.81, -0.33], %)
|
||||
|> line([${process?.env?.CI ? 2.74 : 2.93}, -${
|
||||
process?.env?.CI ? 0.24 : 0.2
|
||||
}], %)
|
||||
|> line([-4.44, -2.13], %)
|
||||
|> close(%)
|
||||
|> extrude(5 + 7, %)`)
|
||||
|
@ -7,30 +7,18 @@ import { spawn } from 'child_process'
|
||||
import { APP_NAME } from 'lib/constants'
|
||||
import JSZip from 'jszip'
|
||||
import path from 'path'
|
||||
import { basicSettings, basicStorageState } from './storageStates'
|
||||
import * as TOML from '@iarna/toml'
|
||||
|
||||
test.beforeEach(async ({ context, page }) => {
|
||||
await context.addInitScript(async (token) => {
|
||||
localStorage.setItem('TOKEN_PERSIST_KEY', token)
|
||||
localStorage.setItem('persistCode', ``)
|
||||
localStorage.setItem(
|
||||
'SETTINGS_PERSIST_KEY',
|
||||
JSON.stringify({
|
||||
baseUnit: 'in',
|
||||
cameraControls: 'KittyCAD',
|
||||
defaultDirectory: '',
|
||||
defaultProjectName: 'project-$nnn',
|
||||
onboardingStatus: 'dismissed',
|
||||
showDebugPanel: true,
|
||||
textWrapping: 'On',
|
||||
theme: 'dark',
|
||||
unitSystem: 'imperial',
|
||||
})
|
||||
)
|
||||
}, secrets.token)
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// reducedMotion kills animations, which speeds up tests and reduces flakiness
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' })
|
||||
})
|
||||
|
||||
test.use({
|
||||
storageState: structuredClone(basicStorageState),
|
||||
})
|
||||
|
||||
test.setTimeout(60_000)
|
||||
|
||||
test('exports of each format should work', async ({ page, context }) => {
|
||||
@ -332,6 +320,22 @@ test('extrude on each default plane should be stable', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
await context.addInitScript(async () => {
|
||||
localStorage.setItem(
|
||||
'SETTINGS_PERSIST_KEY',
|
||||
JSON.stringify({
|
||||
baseUnit: 'in',
|
||||
cameraControls: 'KittyCAD',
|
||||
defaultDirectory: '',
|
||||
defaultProjectName: 'project-$nnn',
|
||||
onboardingStatus: 'dismissed',
|
||||
showDebugPanel: true,
|
||||
textWrapping: 'On',
|
||||
theme: 'dark',
|
||||
unitSystem: 'imperial',
|
||||
})
|
||||
)
|
||||
})
|
||||
const u = getUtils(page)
|
||||
const makeCode = (plane = 'XY') => `const part001 = startSketchOn('${plane}')
|
||||
|> startProfileAt([7.00, 4.40], %)
|
||||
@ -353,29 +357,26 @@ test('extrude on each default plane should be stable', async ({
|
||||
await u.openDebugPanel()
|
||||
await u.expectCmdLog('[data-message-type="execution-done"]')
|
||||
await u.clearAndCloseDebugPanel()
|
||||
|
||||
await page.getByText('Code').click()
|
||||
await expect(page).toHaveScreenshot({
|
||||
maxDiffPixels: 100,
|
||||
})
|
||||
await page.getByText('Code').click()
|
||||
await page.waitForTimeout(200)
|
||||
|
||||
const runSnapshotsForOtherPlanes = async (plane = 'XY') => {
|
||||
// clear code
|
||||
await u.removeCurrentCode()
|
||||
// add makeCode('XZ')
|
||||
await u.openAndClearDebugPanel()
|
||||
await page.locator('.cm-content').fill(makeCode(plane))
|
||||
// wait for execution done
|
||||
await u.openDebugPanel()
|
||||
await u.expectCmdLog('[data-message-type="execution-done"]')
|
||||
await u.clearAndCloseDebugPanel()
|
||||
|
||||
await page.getByText('Code').click()
|
||||
await page.waitForTimeout(150)
|
||||
await expect(page).toHaveScreenshot({
|
||||
maxDiffPixels: 100,
|
||||
})
|
||||
await page.getByText('Code').click()
|
||||
}
|
||||
await runSnapshotsForOtherPlanes('XY')
|
||||
await runSnapshotsForOtherPlanes('-XY')
|
||||
|
||||
await runSnapshotsForOtherPlanes('XZ')
|
||||
@ -386,22 +387,6 @@ test('extrude on each default plane should be stable', async ({
|
||||
})
|
||||
|
||||
test('Draft segments should look right', async ({ page, context }) => {
|
||||
await context.addInitScript(async () => {
|
||||
localStorage.setItem(
|
||||
'SETTINGS_PERSIST_KEY',
|
||||
JSON.stringify({
|
||||
baseUnit: 'in',
|
||||
cameraControls: 'KittyCAD',
|
||||
defaultDirectory: '',
|
||||
defaultProjectName: 'project-$nnn',
|
||||
onboardingStatus: 'dismissed',
|
||||
showDebugPanel: true,
|
||||
textWrapping: 'On',
|
||||
theme: 'dark',
|
||||
unitSystem: 'imperial',
|
||||
})
|
||||
)
|
||||
})
|
||||
const u = getUtils(page)
|
||||
await page.setViewportSize({ width: 1200, height: 500 })
|
||||
const PUR = 400 / 37.5 //pixeltoUnitRatio
|
||||
@ -460,26 +445,9 @@ test('Draft segments should look right', async ({ page, context }) => {
|
||||
})
|
||||
})
|
||||
|
||||
test('Client side scene scale should match engine scale inch', async ({
|
||||
test('Client side scene scale should match engine scale - Inch', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
await context.addInitScript(async () => {
|
||||
localStorage.setItem(
|
||||
'SETTINGS_PERSIST_KEY',
|
||||
JSON.stringify({
|
||||
baseUnit: 'in',
|
||||
cameraControls: 'KittyCAD',
|
||||
defaultDirectory: '',
|
||||
defaultProjectName: 'project-$nnn',
|
||||
onboardingStatus: 'dismissed',
|
||||
showDebugPanel: true,
|
||||
textWrapping: 'On',
|
||||
theme: 'dark',
|
||||
unitSystem: 'imperial',
|
||||
})
|
||||
)
|
||||
})
|
||||
const u = getUtils(page)
|
||||
await page.setViewportSize({ width: 1200, height: 500 })
|
||||
const PUR = 400 / 37.5 //pixeltoUnitRatio
|
||||
@ -512,7 +480,7 @@ test('Client side scene scale should match engine scale inch', async ({
|
||||
await page.mouse.click(startXPx + PUR * 10, 500 - PUR * 10)
|
||||
await expect(page.locator('.cm-content'))
|
||||
.toHaveText(`const part001 = startSketchOn('-XZ')
|
||||
|> startProfileAt([9.06, -12.22], %)`)
|
||||
|> startProfileAt([9.06, -12.22], %)`)
|
||||
await page.waitForTimeout(100)
|
||||
|
||||
await u.closeDebugPanel()
|
||||
@ -522,8 +490,8 @@ test('Client side scene scale should match engine scale inch', async ({
|
||||
|
||||
await expect(page.locator('.cm-content'))
|
||||
.toHaveText(`const part001 = startSketchOn('-XZ')
|
||||
|> startProfileAt([9.06, -12.22], %)
|
||||
|> line([9.14, 0], %)`)
|
||||
|> startProfileAt([9.06, -12.22], %)
|
||||
|> line([9.14, 0], %)`)
|
||||
|
||||
await page.getByRole('button', { name: 'Tangential Arc' }).click()
|
||||
await page.waitForTimeout(100)
|
||||
@ -532,9 +500,9 @@ test('Client side scene scale should match engine scale inch', async ({
|
||||
|
||||
await expect(page.locator('.cm-content'))
|
||||
.toHaveText(`const part001 = startSketchOn('-XZ')
|
||||
|> startProfileAt([9.06, -12.22], %)
|
||||
|> line([9.14, 0], %)
|
||||
|> tangentialArcTo([27.34, -3.08], %)`)
|
||||
|> startProfileAt([9.06, -12.22], %)
|
||||
|> line([9.14, 0], %)
|
||||
|> tangentialArcTo([27.34, -3.08], %)`)
|
||||
|
||||
// click tangential arc tool again to unequip it
|
||||
await page.getByRole('button', { name: 'Tangential Arc' }).click()
|
||||
@ -560,102 +528,101 @@ test('Client side scene scale should match engine scale inch', async ({
|
||||
})
|
||||
})
|
||||
|
||||
test('Client side scene scale should match engine scale mm', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
await context.addInitScript(async () => {
|
||||
localStorage.setItem(
|
||||
'SETTINGS_PERSIST_KEY',
|
||||
JSON.stringify({
|
||||
baseUnit: 'mm',
|
||||
cameraControls: 'KittyCAD',
|
||||
defaultDirectory: '',
|
||||
defaultProjectName: 'project-$nnn',
|
||||
onboardingStatus: 'dismissed',
|
||||
showDebugPanel: true,
|
||||
textWrapping: 'On',
|
||||
theme: 'dark',
|
||||
unitSystem: 'metric',
|
||||
})
|
||||
test.describe('Client side scene scale should match engine scale - Millimeters', () => {
|
||||
const storageState = structuredClone(basicStorageState)
|
||||
storageState.origins[0].localStorage[2].value = TOML.stringify({
|
||||
settings: {
|
||||
...basicSettings,
|
||||
modeling: {
|
||||
...basicSettings.modeling,
|
||||
defaultUnit: 'mm',
|
||||
},
|
||||
},
|
||||
})
|
||||
test.use({
|
||||
storageState,
|
||||
})
|
||||
|
||||
test('Millimeters', async ({ page }) => {
|
||||
const u = getUtils(page)
|
||||
await page.setViewportSize({ width: 1200, height: 500 })
|
||||
const PUR = 400 / 37.5 //pixeltoUnitRatio
|
||||
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()
|
||||
|
||||
// click on "Start Sketch" button
|
||||
await u.clearCommandLogs()
|
||||
await u.doAndWaitForImageDiff(
|
||||
() => page.getByRole('button', { name: 'Start Sketch' }).click(),
|
||||
200
|
||||
)
|
||||
})
|
||||
const u = getUtils(page)
|
||||
await page.setViewportSize({ width: 1200, height: 500 })
|
||||
const PUR = 400 / 37.5 //pixeltoUnitRatio
|
||||
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()
|
||||
// select a plane
|
||||
await page.mouse.click(700, 200)
|
||||
|
||||
// click on "Start Sketch" button
|
||||
await u.clearCommandLogs()
|
||||
await u.doAndWaitForImageDiff(
|
||||
() => page.getByRole('button', { name: 'Start Sketch' }).click(),
|
||||
200
|
||||
)
|
||||
await expect(page.locator('.cm-content')).toHaveText(
|
||||
`const part001 = startSketchOn('-XZ')`
|
||||
)
|
||||
|
||||
// select a plane
|
||||
await page.mouse.click(700, 200)
|
||||
await page.waitForTimeout(300) // TODO detect animation ending, or disable animation
|
||||
|
||||
await expect(page.locator('.cm-content')).toHaveText(
|
||||
`const part001 = startSketchOn('-XZ')`
|
||||
)
|
||||
const startXPx = 600
|
||||
await page.mouse.click(startXPx + PUR * 10, 500 - PUR * 10)
|
||||
await expect(page.locator('.cm-content'))
|
||||
.toHaveText(`const part001 = startSketchOn('-XZ')
|
||||
|> startProfileAt([230.03, -310.32], %)`)
|
||||
await page.waitForTimeout(100)
|
||||
|
||||
await page.waitForTimeout(300) // TODO detect animation ending, or disable animation
|
||||
await u.closeDebugPanel()
|
||||
|
||||
const startXPx = 600
|
||||
await page.mouse.click(startXPx + PUR * 10, 500 - PUR * 10)
|
||||
await expect(page.locator('.cm-content'))
|
||||
.toHaveText(`const part001 = startSketchOn('-XZ')
|
||||
|> startProfileAt([230.03, -310.32], %)`)
|
||||
await page.waitForTimeout(100)
|
||||
await page.mouse.click(startXPx + PUR * 20, 500 - PUR * 10)
|
||||
await page.waitForTimeout(100)
|
||||
|
||||
await u.closeDebugPanel()
|
||||
await expect(page.locator('.cm-content'))
|
||||
.toHaveText(`const part001 = startSketchOn('-XZ')
|
||||
|> startProfileAt([230.03, -310.32], %)
|
||||
|> line([232.2, 0], %)`)
|
||||
|
||||
await page.mouse.click(startXPx + PUR * 20, 500 - PUR * 10)
|
||||
await page.waitForTimeout(100)
|
||||
await page.getByRole('button', { name: 'Tangential Arc' }).click()
|
||||
await page.waitForTimeout(100)
|
||||
|
||||
await expect(page.locator('.cm-content'))
|
||||
.toHaveText(`const part001 = startSketchOn('-XZ')
|
||||
|> startProfileAt([230.03, -310.32], %)
|
||||
|> line([232.2, 0], %)`)
|
||||
await page.mouse.click(startXPx + PUR * 30, 500 - PUR * 20)
|
||||
|
||||
await page.getByRole('button', { name: 'Tangential Arc' }).click()
|
||||
await page.waitForTimeout(100)
|
||||
await expect(page.locator('.cm-content'))
|
||||
.toHaveText(`const part001 = startSketchOn('-XZ')
|
||||
|> startProfileAt([230.03, -310.32], %)
|
||||
|> line([232.2, 0], %)
|
||||
|> tangentialArcTo([694.43, -78.12], %)`)
|
||||
|
||||
await page.mouse.click(startXPx + PUR * 30, 500 - PUR * 20)
|
||||
await page.getByRole('button', { name: 'Tangential Arc' }).click()
|
||||
await page.waitForTimeout(100)
|
||||
|
||||
await expect(page.locator('.cm-content'))
|
||||
.toHaveText(`const part001 = startSketchOn('-XZ')
|
||||
|> startProfileAt([230.03, -310.32], %)
|
||||
|> line([232.2, 0], %)
|
||||
|> tangentialArcTo([694.43, -78.12], %)`)
|
||||
// screen shot should show the sketch
|
||||
await expect(page).toHaveScreenshot({
|
||||
maxDiffPixels: 100,
|
||||
})
|
||||
|
||||
await page.getByRole('button', { name: 'Tangential Arc' }).click()
|
||||
await page.waitForTimeout(100)
|
||||
// exit sketch
|
||||
await u.openAndClearDebugPanel()
|
||||
await page.getByRole('button', { name: 'Exit Sketch' }).click()
|
||||
|
||||
// screen shot should show the sketch
|
||||
await expect(page).toHaveScreenshot({
|
||||
maxDiffPixels: 100,
|
||||
})
|
||||
// wait for execution done
|
||||
await u.expectCmdLog('[data-message-type="execution-done"]')
|
||||
await u.clearAndCloseDebugPanel()
|
||||
await page.waitForTimeout(200)
|
||||
|
||||
// exit sketch
|
||||
await u.openAndClearDebugPanel()
|
||||
await page.getByRole('button', { name: 'Exit Sketch' }).click()
|
||||
|
||||
// wait for execution done
|
||||
await u.expectCmdLog('[data-message-type="execution-done"]')
|
||||
await u.clearAndCloseDebugPanel()
|
||||
await page.waitForTimeout(200)
|
||||
|
||||
// second screen shot should look almost identical, i.e. scale should be the same.
|
||||
await expect(page).toHaveScreenshot({
|
||||
maxDiffPixels: 100,
|
||||
// second screen shot should look almost identical, i.e. scale should be the same.
|
||||
await expect(page).toHaveScreenshot({
|
||||
maxDiffPixels: 100,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -666,14 +633,14 @@ test('Sketch on face with none z-up', async ({ page, context }) => {
|
||||
'persistCode',
|
||||
`const part001 = startSketchOn('-XZ')
|
||||
|> startProfileAt([1.4, 2.47], %)
|
||||
|> line({ to: [9.31, 10.55], tag: 'seg01' }, %)
|
||||
|> line([9.31, 10.55], %, 'seg01')
|
||||
|> line([11.91, -10.42], %)
|
||||
|> close(%)
|
||||
|> extrude(5 + 7, %)
|
||||
const part002 = startSketchOn(part001, 'seg01')
|
||||
|> startProfileAt([-2.89, 1.82], %)
|
||||
|> line([4.68, 3.05], %)
|
||||
|> line({ to: [0, -7.79], tag: 'seg02' }, %)
|
||||
|> line([0, -7.79], %, 'seg02')
|
||||
|> close(%)
|
||||
|> extrude(5 + 7, %)
|
||||
`
|
||||
@ -700,6 +667,4 @@ const part002 = startSketchOn(part001, 'seg01')
|
||||
await expect(page).toHaveScreenshot({
|
||||
maxDiffPixels: 100,
|
||||
})
|
||||
|
||||
await page.waitForTimeout(200)
|
||||
})
|
||||
|
After Width: | Height: | Size: 44 KiB |
After Width: | Height: | Size: 48 KiB |
After Width: | Height: | Size: 48 KiB |
After Width: | Height: | Size: 49 KiB |
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 44 KiB |
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 48 KiB |
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 36 KiB |
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 39 KiB |
Before Width: | Height: | Size: 65 KiB After Width: | Height: | Size: 72 KiB |
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 50 KiB |
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 49 KiB |
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 50 KiB |
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 53 KiB |
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 48 KiB |
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 49 KiB |
40
e2e/playwright/storageStates.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import { SaveSettingsPayload } from 'lib/settings/settingsTypes'
|
||||
import { secrets } from './secrets'
|
||||
import * as TOML from '@iarna/toml'
|
||||
import { Themes } from 'lib/theme'
|
||||
|
||||
export const basicSettings = {
|
||||
app: {
|
||||
theme: Themes.Dark,
|
||||
onboardingStatus: 'dismissed',
|
||||
projectDirectory: '',
|
||||
},
|
||||
modeling: {
|
||||
defaultUnit: 'in',
|
||||
mouseControls: 'KittyCAD',
|
||||
showDebugPanel: true,
|
||||
},
|
||||
projects: {
|
||||
defaultProjectName: 'project-$nnn',
|
||||
},
|
||||
textEditor: {
|
||||
textWrapping: true,
|
||||
},
|
||||
} satisfies Partial<SaveSettingsPayload>
|
||||
|
||||
export const basicStorageState = {
|
||||
cookies: [],
|
||||
origins: [
|
||||
{
|
||||
origin: 'http://localhost:3000',
|
||||
localStorage: [
|
||||
{ name: 'TOKEN_PERSIST_KEY', value: secrets.token },
|
||||
{ name: 'persistCode', value: '' },
|
||||
{
|
||||
name: '/user.toml',
|
||||
value: TOML.stringify({ settings: basicSettings }),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
@ -33,7 +33,7 @@ async function clearCommandLogs(page: Page) {
|
||||
}
|
||||
|
||||
async function expectCmdLog(page: Page, locatorStr: string) {
|
||||
await expect(page.locator(locatorStr)).toBeVisible()
|
||||
await expect(page.locator(locatorStr).last()).toBeVisible()
|
||||
}
|
||||
|
||||
async function waitForDefaultPlanesToBeVisible(page: Page) {
|
||||
|
@ -68,10 +68,10 @@ describe('ZMA (Tauri, Linux)', () => {
|
||||
const defaultDirInput = await $('[data-testid="default-directory-input"]')
|
||||
expect(await defaultDirInput.getValue()).toEqual(defaultDir)
|
||||
|
||||
const nameInput = await $('[data-testid="name-input"]')
|
||||
const nameInput = await $('[data-testid="projects-defaultProjectName"]')
|
||||
expect(await nameInput.getValue()).toEqual('project-$nnn')
|
||||
|
||||
const closeButton = await $('[data-testid="close-button"]')
|
||||
const closeButton = await $('[data-testid="settings-close-button"]')
|
||||
await click(closeButton)
|
||||
})
|
||||
|
||||
|
12
package.json
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "untitled-app",
|
||||
"version": "0.17.0",
|
||||
"version": "0.17.2",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.15.0",
|
||||
@ -10,6 +10,7 @@
|
||||
"@fortawesome/react-fontawesome": "^0.2.0",
|
||||
"@headlessui/react": "^1.7.18",
|
||||
"@headlessui/tailwindcss": "^0.2.0",
|
||||
"@iarna/toml": "^2.2.5",
|
||||
"@kittycad/lib": "^0.0.56",
|
||||
"@lezer/javascript": "^1.4.9",
|
||||
"@open-rpc/client-js": "^1.8.1",
|
||||
@ -24,11 +25,12 @@
|
||||
"@types/node": "^18.19.26",
|
||||
"@types/react": "^18.2.73",
|
||||
"@types/react-dom": "^18.2.22",
|
||||
"@uiw/react-codemirror": "^4.21.24",
|
||||
"@uiw/react-codemirror": "^4.21.25",
|
||||
"@xstate/inspect": "^0.8.0",
|
||||
"@xstate/react": "^3.2.2",
|
||||
"crypto-js": "^4.2.0",
|
||||
"debounce-promise": "^3.1.2",
|
||||
"decamelize": "^6.0.0",
|
||||
"formik": "^2.4.3",
|
||||
"fuse.js": "^7.0.0",
|
||||
"http-server": "^14.1.1",
|
||||
@ -108,7 +110,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
|
||||
"@babel/preset-env": "^7.23.3",
|
||||
"@babel/preset-env": "^7.24.3",
|
||||
"@playwright/test": "^1.39.0",
|
||||
"@tauri-apps/cli": "^1.5.11",
|
||||
"@types/crypto-js": "^4.2.2",
|
||||
@ -132,7 +134,7 @@
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-react-app": "^7.0.1",
|
||||
"eslint-plugin-css-modules": "^2.12.0",
|
||||
"happy-dom": "^14.3.1",
|
||||
"happy-dom": "^14.3.10",
|
||||
"husky": "^9.0.11",
|
||||
"pixelmatch": "^5.3.0",
|
||||
"pngjs": "^7.0.0",
|
||||
@ -141,7 +143,7 @@
|
||||
"prettier": "^2.8.0",
|
||||
"setimmediate": "^1.0.5",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"vite": "^5.2.2",
|
||||
"vite": "^5.2.6",
|
||||
"vite-plugin-eslint": "^1.8.1",
|
||||
"vite-plugin-package-version": "^1.1.0",
|
||||
"vite-tsconfig-paths": "^4.3.2",
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { defineConfig, devices } from '@playwright/test'
|
||||
import { basicStorageState } from './e2e/playwright/storageStates'
|
||||
|
||||
/**
|
||||
* Read environment variables from file.
|
||||
@ -28,6 +29,9 @@ export default defineConfig({
|
||||
|
||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||
trace: 'on-first-retry',
|
||||
|
||||
/* Use a common shared localStorage */
|
||||
storageState: basicStorageState,
|
||||
},
|
||||
|
||||
/* Configure projects for major browsers */
|
||||
|
BIN
public/clientSideSceneAssets/extra-segment-texture.png
Normal file
After Width: | Height: | Size: 327 B |
6
src-tauri/Cargo.lock
generated
@ -3876,7 +3876,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "tauri-plugin-fs-extra"
|
||||
version = "0.0.0"
|
||||
source = "git+https://github.com/tauri-apps/plugins-workspace?branch=v1#c6a5b7ae479eaa598db2430a87fe32a3429198bf"
|
||||
source = "git+https://github.com/tauri-apps/plugins-workspace?branch=v1#73d8562849299342772e409cb4c9c2ef3073dc72"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
@ -4055,9 +4055,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.36.0"
|
||||
version = "1.37.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "61285f6515fa018fb2d1e46eb21223fff441ee8db5d0f1435e8ab4f5cdb80931"
|
||||
checksum = "1adbebffeca75fcfd058afa480fb6c0b81e165a0323f9c9d39c9697e37c46787"
|
||||
dependencies = [
|
||||
"backtrace",
|
||||
"bytes",
|
||||
|
@ -22,7 +22,7 @@ serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
tauri = { version = "1.6.1", features = [ "os-all", "dialog-all", "fs-all", "http-request", "path-all", "shell-open", "shell-open-api", "devtools"] }
|
||||
tauri-plugin-fs-extra = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v1" }
|
||||
tokio = { version = "1.36.0", features = ["time"] }
|
||||
tokio = { version = "1.37.0", features = ["time"] }
|
||||
toml = "0.8.2"
|
||||
|
||||
[features]
|
||||
|
@ -7,7 +7,7 @@
|
||||
},
|
||||
"package": {
|
||||
"productName": "zoo-modeling-app",
|
||||
"version": "0.17.0"
|
||||
"version": "0.17.2"
|
||||
},
|
||||
"tauri": {
|
||||
"allowlist": {
|
||||
|
20
src/App.tsx
@ -1,6 +1,6 @@
|
||||
import { useCallback, MouseEventHandler, useEffect } from 'react'
|
||||
import { DebugPanel } from './components/DebugPanel'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { uuidv4 } from 'lib/utils'
|
||||
import { PaneType, useStore } from './useStore'
|
||||
import { Logs, KCLErrors } from './components/Logs'
|
||||
import { CollapsiblePanel } from './components/CollapsiblePanel'
|
||||
@ -33,10 +33,10 @@ import { useModelingContext } from 'hooks/useModelingContext'
|
||||
import { useAbsoluteFilePath } from 'hooks/useAbsoluteFilePath'
|
||||
import { isTauri } from 'lib/isTauri'
|
||||
import { useLspContext } from 'components/LspProvider'
|
||||
import { useValidateSettings } from 'hooks/useValidateSettings'
|
||||
import { useRefreshSettings } from 'hooks/useRefreshSettings'
|
||||
|
||||
export function App() {
|
||||
useValidateSettings()
|
||||
useRefreshSettings(paths.FILE + 'SETTINGS')
|
||||
const { project, file } = useLoaderData() as IndexLoaderData
|
||||
const navigate = useNavigate()
|
||||
const filePath = useAbsoluteFilePath()
|
||||
@ -64,10 +64,14 @@ export function App() {
|
||||
}))
|
||||
|
||||
const { settings } = useSettingsAuthContext()
|
||||
const { showDebugPanel, onboardingStatus, theme } = settings?.context || {}
|
||||
const {
|
||||
modeling: { showDebugPanel },
|
||||
app: { theme, onboardingStatus },
|
||||
} = settings.context
|
||||
const { state, send } = useModelingContext()
|
||||
|
||||
const editorTheme = theme === Themes.System ? getSystemTheme() : theme
|
||||
const editorTheme =
|
||||
theme.current === Themes.System ? getSystemTheme() : theme.current
|
||||
|
||||
// Pane toggling keyboard shortcuts
|
||||
const togglePane = useCallback(
|
||||
@ -95,7 +99,7 @@ export function App() {
|
||||
)
|
||||
|
||||
const paneOpacity = [onboardingPaths.CAMERA, onboardingPaths.STREAMING].some(
|
||||
(p) => p === onboardingStatus
|
||||
(p) => p === onboardingStatus.current
|
||||
)
|
||||
? 'opacity-20'
|
||||
: didDragInStream
|
||||
@ -163,7 +167,7 @@ export function App() {
|
||||
handleClasses={{
|
||||
right:
|
||||
'hover:bg-chalkboard-10/50 bg-transparent transition-colors duration-75 transition-ease-out delay-100 ' +
|
||||
(buttonDownInStream || onboardingStatus === 'camera'
|
||||
(buttonDownInStream || onboardingStatus.current === 'camera'
|
||||
? 'pointer-events-none '
|
||||
: 'pointer-events-auto'),
|
||||
}}
|
||||
@ -204,7 +208,7 @@ export function App() {
|
||||
</div>
|
||||
</Resizable>
|
||||
<Stream className="absolute inset-0 z-0" />
|
||||
{showDebugPanel && (
|
||||
{showDebugPanel.current && (
|
||||
<DebugPanel
|
||||
title="Debug"
|
||||
className={
|
||||
|
@ -22,19 +22,18 @@ import { paths } from 'lib/paths'
|
||||
import {
|
||||
fileLoader,
|
||||
homeLoader,
|
||||
indexLoader,
|
||||
onboardingRedirectLoader,
|
||||
settingsLoader,
|
||||
} from 'lib/routeLoaders'
|
||||
import { CommandBarProvider } from 'components/CommandBar/CommandBarProvider'
|
||||
import SettingsAuthProvider from 'components/SettingsAuthProvider'
|
||||
import LspProvider from 'components/LspProvider'
|
||||
import { KclContextProvider } from 'lang/KclProvider'
|
||||
|
||||
export const BROWSER_FILE_NAME = 'new'
|
||||
import { BROWSER_PROJECT_NAME } from 'lib/constants'
|
||||
|
||||
const router = createBrowserRouter([
|
||||
{
|
||||
loader: indexLoader,
|
||||
loader: settingsLoader,
|
||||
id: paths.INDEX,
|
||||
element: (
|
||||
<CommandBarProvider>
|
||||
@ -47,14 +46,14 @@ const router = createBrowserRouter([
|
||||
</KclContextProvider>
|
||||
</CommandBarProvider>
|
||||
),
|
||||
errorElement: <ErrorPage />,
|
||||
children: [
|
||||
{
|
||||
path: paths.INDEX,
|
||||
loader: () =>
|
||||
isTauri()
|
||||
? redirect(paths.HOME)
|
||||
: redirect(paths.FILE + '/' + BROWSER_FILE_NAME),
|
||||
errorElement: <ErrorPage />,
|
||||
: redirect(paths.FILE + '/%2F' + BROWSER_PROJECT_NAME),
|
||||
},
|
||||
{
|
||||
loader: fileLoader,
|
||||
@ -75,21 +74,21 @@ const router = createBrowserRouter([
|
||||
),
|
||||
children: [
|
||||
{
|
||||
loader: onboardingRedirectLoader,
|
||||
index: true,
|
||||
element: <></>,
|
||||
},
|
||||
{
|
||||
id: paths.FILE + 'SETTINGS',
|
||||
loader: settingsLoader,
|
||||
children: [
|
||||
{
|
||||
loader: onboardingRedirectLoader,
|
||||
index: true,
|
||||
element: <></>,
|
||||
},
|
||||
{
|
||||
path: makeUrlPathRelative(paths.SETTINGS),
|
||||
loader: indexLoader, // very rare someone will load into settings first, but it's possible in the browser
|
||||
element: <Settings />,
|
||||
},
|
||||
{
|
||||
path: makeUrlPathRelative(paths.ONBOARDING.INDEX),
|
||||
element: <Onboarding />,
|
||||
loader: indexLoader, // very rare someone will load into settings first, but it's possible in the browser
|
||||
children: onboardingRoutes,
|
||||
},
|
||||
],
|
||||
@ -108,8 +107,15 @@ const router = createBrowserRouter([
|
||||
id: paths.HOME,
|
||||
loader: homeLoader,
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
element: <></>,
|
||||
id: paths.HOME + 'SETTINGS',
|
||||
loader: settingsLoader,
|
||||
},
|
||||
{
|
||||
path: makeUrlPathRelative(paths.SETTINGS),
|
||||
loader: settingsLoader,
|
||||
element: <Settings />,
|
||||
},
|
||||
],
|
||||
|
@ -21,7 +21,7 @@ import {
|
||||
Subscription,
|
||||
EngineCommandManager,
|
||||
} from 'lang/std/engineConnection'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { uuidv4 } from 'lib/utils'
|
||||
import { deg2Rad } from 'lib/utils2d'
|
||||
import { isReducedMotion, roundOff, throttle } from 'lib/utils'
|
||||
import * as TWEEN from '@tweenjs/tween.js'
|
||||
|
@ -4,10 +4,15 @@ import { useModelingContext } from 'hooks/useModelingContext'
|
||||
import { cameraMouseDragGuards } from 'lib/cameraControls'
|
||||
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
|
||||
import { useStore } from 'useStore'
|
||||
import { DEBUG_SHOW_BOTH_SCENES } from './sceneInfra'
|
||||
import { ARROWHEAD, DEBUG_SHOW_BOTH_SCENES } from './sceneInfra'
|
||||
import { ReactCameraProperties } from './CameraControls'
|
||||
import { throttle } from 'lib/utils'
|
||||
import { sceneInfra } from 'lib/singletons'
|
||||
import {
|
||||
EXTRA_SEGMENT_HANDLE,
|
||||
PROFILE_START,
|
||||
getParentGroup,
|
||||
} from './sceneEntities'
|
||||
|
||||
function useShouldHideScene(): { hideClient: boolean; hideServer: boolean } {
|
||||
const [isCamMoving, setIsCamMoving] = useState(false)
|
||||
@ -37,10 +42,10 @@ export const ClientSideScene = ({
|
||||
}: {
|
||||
cameraControls: ReturnType<
|
||||
typeof useSettingsAuthContext
|
||||
>['settings']['context']['cameraControls']
|
||||
>['settings']['context']['modeling']['mouseControls']['current']
|
||||
}) => {
|
||||
const canvasRef = useRef<HTMLDivElement>(null)
|
||||
const { state, send } = useModelingContext()
|
||||
const { state, send, context } = useModelingContext()
|
||||
const { hideClient, hideServer } = useShouldHideScene()
|
||||
const { setHighlightRange } = useStore((s) => ({
|
||||
setHighlightRange: s.setHighlightRange,
|
||||
@ -76,9 +81,33 @@ export const ClientSideScene = ({
|
||||
}
|
||||
}, [])
|
||||
|
||||
let cursor = 'default'
|
||||
if (state.matches('Sketch')) {
|
||||
if (
|
||||
context.mouseState.type === 'isHovering' &&
|
||||
getParentGroup(context.mouseState.on, [
|
||||
ARROWHEAD,
|
||||
EXTRA_SEGMENT_HANDLE,
|
||||
PROFILE_START,
|
||||
])
|
||||
) {
|
||||
cursor = 'move'
|
||||
} else if (context.mouseState.type === 'isDragging') {
|
||||
cursor = 'grabbing'
|
||||
} else if (
|
||||
state.matches('Sketch.Line tool') ||
|
||||
state.matches('Sketch.Tangential arc to')
|
||||
) {
|
||||
cursor = 'crosshair'
|
||||
} else {
|
||||
cursor = 'default'
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={canvasRef}
|
||||
style={{ cursor: cursor }}
|
||||
className={`absolute inset-0 h-full w-full transition-all duration-300 ${
|
||||
hideClient ? 'opacity-0' : 'opacity-100'
|
||||
} ${hideServer ? 'bg-black' : ''} ${
|
||||
|
@ -28,12 +28,15 @@ export function createGridHelper({
|
||||
gridHelper.rotation.x = Math.PI / 2
|
||||
return gridHelper
|
||||
}
|
||||
const fudgeFactor = 72.66985970437086
|
||||
|
||||
export const orthoScale = (cam: OrthographicCamera | PerspectiveCamera) =>
|
||||
0.55 / cam.zoom
|
||||
(0.55 * fudgeFactor) / cam.zoom / window.innerHeight
|
||||
|
||||
export const perspScale = (cam: PerspectiveCamera, group: Group | Mesh) =>
|
||||
(group.position.distanceTo(cam.position) * cam.fov) / 4000
|
||||
(group.position.distanceTo(cam.position) * cam.fov * fudgeFactor) /
|
||||
4000 /
|
||||
window.innerHeight
|
||||
|
||||
export function isQuaternionVertical(q: Quaternion) {
|
||||
const v = new Vector3(0, 0, 1).applyQuaternion(q)
|
||||
|
@ -12,6 +12,7 @@ import {
|
||||
OrthographicCamera,
|
||||
PerspectiveCamera,
|
||||
PlaneGeometry,
|
||||
Points,
|
||||
Quaternion,
|
||||
Scene,
|
||||
Shape,
|
||||
@ -81,20 +82,25 @@ import {
|
||||
import { getTangentPointFromPreviousArc } from 'lib/utils2d'
|
||||
import { createGridHelper, orthoScale, perspScale } from './helpers'
|
||||
import { Models } from '@kittycad/lib'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { uuidv4 } from 'lib/utils'
|
||||
import { SketchDetails } from 'machines/modelingMachine'
|
||||
import { EngineCommandManager } from 'lang/std/engineConnection'
|
||||
|
||||
type DraftSegment = 'line' | 'tangentialArcTo'
|
||||
|
||||
export const EXTRA_SEGMENT_HANDLE = 'extraSegmentHandle'
|
||||
export const EXTRA_SEGMENT_OFFSET_PX = 8
|
||||
export const PROFILE_START = 'profile-start'
|
||||
export const STRAIGHT_SEGMENT = 'straight-segment'
|
||||
export const STRAIGHT_SEGMENT_BODY = 'straight-segment-body'
|
||||
export const STRAIGHT_SEGMENT_DASH = 'straight-segment-body-dashed'
|
||||
export const TANGENTIAL_ARC_TO_SEGMENT = 'tangential-arc-to-segment'
|
||||
export const TANGENTIAL_ARC_TO_SEGMENT_BODY = 'tangential-arc-to-segment-body'
|
||||
export const TANGENTIAL_ARC_TO__SEGMENT_DASH =
|
||||
'tangential-arc-to-segment-body-dashed'
|
||||
export const PROFILE_START = 'profile-start'
|
||||
export const TANGENTIAL_ARC_TO_SEGMENT = 'tangential-arc-to-segment'
|
||||
export const TANGENTIAL_ARC_TO_SEGMENT_BODY = 'tangential-arc-to-segment-body'
|
||||
export const SEGMENT_WIDTH_PX = 1.6
|
||||
export const HIDE_SEGMENT_LENGTH = 75 // in pixels
|
||||
export const HIDE_HOVER_SEGMENT_LENGTH = 60 // in pixels
|
||||
|
||||
// This singleton Class is responsible for all of the things the user sees and interacts with.
|
||||
// That mostly mean sketch elements.
|
||||
@ -111,8 +117,12 @@ export class SceneEntities {
|
||||
this.engineCommandManager = engineCommandManager
|
||||
this.scene = sceneInfra?.scene
|
||||
sceneInfra?.camControls.subscribeToCamChange(this.onCamChange)
|
||||
window.addEventListener('resize', this.onWindowResize)
|
||||
}
|
||||
|
||||
onWindowResize = () => {
|
||||
this.onCamChange()
|
||||
}
|
||||
onCamChange = () => {
|
||||
const orthoFactor = orthoScale(sceneInfra.camControls.camera)
|
||||
|
||||
@ -282,7 +292,6 @@ export class SceneEntities {
|
||||
sketchGroup: SketchGroup
|
||||
variableDeclarationName: string
|
||||
}> {
|
||||
sceneInfra.resetMouseListeners()
|
||||
this.createIntersectionPlane()
|
||||
|
||||
const { truncatedAst, programMemoryOverride, variableDeclarationName } =
|
||||
@ -295,7 +304,7 @@ export class SceneEntities {
|
||||
})
|
||||
const sketchGroup = sketchGroupFromPathToNode({
|
||||
pathToNode: sketchPathToNode,
|
||||
ast: kclManager.ast,
|
||||
ast: maybeModdedAst,
|
||||
programMemory,
|
||||
})
|
||||
if (!Array.isArray(sketchGroup?.value))
|
||||
@ -383,6 +392,7 @@ export class SceneEntities {
|
||||
pathToNode: segPathToNode,
|
||||
isDraftSegment,
|
||||
scale: factor,
|
||||
texture: sceneInfra.extraSegmentTexture,
|
||||
})
|
||||
} else {
|
||||
seg = straightSegment({
|
||||
@ -393,6 +403,7 @@ export class SceneEntities {
|
||||
isDraftSegment,
|
||||
scale: factor,
|
||||
callExpName,
|
||||
texture: sceneInfra.extraSegmentTexture,
|
||||
})
|
||||
}
|
||||
seg.layers.set(SKETCH_LAYER)
|
||||
@ -435,6 +446,7 @@ export class SceneEntities {
|
||||
) => {
|
||||
await kclManager.updateAst(modifiedAst, false)
|
||||
await this.tearDownSketch({ removeAxis: false })
|
||||
sceneInfra.resetMouseListeners()
|
||||
await this.setupSketch({
|
||||
sketchPathToNode,
|
||||
forward,
|
||||
@ -442,7 +454,12 @@ export class SceneEntities {
|
||||
position: origin,
|
||||
maybeModdedAst: kclManager.ast,
|
||||
})
|
||||
this.setupSketchIdleCallbacks(sketchPathToNode)
|
||||
this.setupSketchIdleCallbacks({
|
||||
forward,
|
||||
up,
|
||||
position: origin,
|
||||
pathToNode: sketchPathToNode,
|
||||
})
|
||||
}
|
||||
setUpDraftSegment = async (
|
||||
sketchPathToNode: PathToNode,
|
||||
@ -467,19 +484,20 @@ export class SceneEntities {
|
||||
|
||||
const index = sg.value.length // because we've added a new segment that's not in the memory yet, no need for `-1`
|
||||
|
||||
let modifiedAst = addNewSketchLn({
|
||||
node: kclManager.ast,
|
||||
const mod = addNewSketchLn({
|
||||
node: _ast,
|
||||
programMemory: kclManager.programMemory,
|
||||
to: [lastSeg.to[0], lastSeg.to[1]],
|
||||
from: [lastSeg.to[0], lastSeg.to[1]],
|
||||
fnName: segmentName,
|
||||
pathToNode: sketchPathToNode,
|
||||
}).modifiedAst
|
||||
modifiedAst = parse(recast(modifiedAst))
|
||||
})
|
||||
const modifiedAst = parse(recast(mod.modifiedAst))
|
||||
|
||||
const draftExpressionsIndices = { start: index, end: index }
|
||||
|
||||
if (shouldTearDown) await this.tearDownSketch({ removeAxis: false })
|
||||
sceneInfra.resetMouseListeners()
|
||||
const { truncatedAst, programMemoryOverride, sketchGroup } =
|
||||
await this.setupSketch({
|
||||
sketchPathToNode,
|
||||
@ -546,13 +564,104 @@ export class SceneEntities {
|
||||
},
|
||||
})
|
||||
},
|
||||
...mouseEnterLeaveCallbacks(),
|
||||
...this.mouseEnterLeaveCallbacks(),
|
||||
})
|
||||
}
|
||||
setupSketchIdleCallbacks = (pathToNode: PathToNode) => {
|
||||
setupSketchIdleCallbacks = ({
|
||||
pathToNode,
|
||||
up,
|
||||
forward,
|
||||
position,
|
||||
}: {
|
||||
pathToNode: PathToNode
|
||||
forward: [number, number, number]
|
||||
up: [number, number, number]
|
||||
position?: [number, number, number]
|
||||
}) => {
|
||||
let addingNewSegmentStatus: 'nothing' | 'pending' | 'added' = 'nothing'
|
||||
sceneInfra.setCallbacks({
|
||||
onDrag: ({ selected, intersectionPoint, mouseEvent, intersects }) => {
|
||||
onDragEnd: async () => {
|
||||
if (addingNewSegmentStatus !== 'nothing') {
|
||||
await this.tearDownSketch({ removeAxis: false })
|
||||
this.setupSketch({
|
||||
sketchPathToNode: pathToNode,
|
||||
maybeModdedAst: kclManager.ast,
|
||||
up,
|
||||
forward,
|
||||
position,
|
||||
})
|
||||
// setting up the callbacks again resets value in closures
|
||||
this.setupSketchIdleCallbacks({
|
||||
pathToNode,
|
||||
up,
|
||||
forward,
|
||||
position,
|
||||
})
|
||||
}
|
||||
},
|
||||
onDrag: async ({
|
||||
selected,
|
||||
intersectionPoint,
|
||||
mouseEvent,
|
||||
intersects,
|
||||
}) => {
|
||||
if (mouseEvent.which !== 1) return
|
||||
|
||||
const group = getParentGroup(selected, [EXTRA_SEGMENT_HANDLE])
|
||||
if (group?.name === EXTRA_SEGMENT_HANDLE) {
|
||||
const segGroup = getParentGroup(selected)
|
||||
const pathToNode: PathToNode = segGroup?.userData?.pathToNode
|
||||
const pathToNodeIndex = pathToNode.findIndex(
|
||||
(x) => x[1] === 'PipeExpression'
|
||||
)
|
||||
|
||||
const sketchGroup = sketchGroupFromPathToNode({
|
||||
pathToNode,
|
||||
ast: kclManager.ast,
|
||||
programMemory: kclManager.programMemory,
|
||||
})
|
||||
|
||||
const pipeIndex = pathToNode[pathToNodeIndex + 1][0] as number
|
||||
if (addingNewSegmentStatus === 'nothing') {
|
||||
const prevSegment = sketchGroup.value[pipeIndex - 2]
|
||||
const mod = addNewSketchLn({
|
||||
node: kclManager.ast,
|
||||
programMemory: kclManager.programMemory,
|
||||
to: [intersectionPoint.twoD.x, intersectionPoint.twoD.y],
|
||||
from: [prevSegment.from[0], prevSegment.from[1]],
|
||||
// TODO assuming it's always a straight segments being added
|
||||
// as this is easiest, and we'll need to add "tabbing" behavior
|
||||
// to support other segment types
|
||||
fnName: 'line',
|
||||
pathToNode: pathToNode,
|
||||
spliceBetween: true,
|
||||
})
|
||||
addingNewSegmentStatus = 'pending'
|
||||
await kclManager.executeAstMock(mod.modifiedAst, {
|
||||
updates: 'code',
|
||||
})
|
||||
await this.tearDownSketch({ removeAxis: false })
|
||||
this.setupSketch({
|
||||
sketchPathToNode: pathToNode,
|
||||
maybeModdedAst: kclManager.ast,
|
||||
up,
|
||||
forward,
|
||||
position,
|
||||
})
|
||||
addingNewSegmentStatus = 'added'
|
||||
} else if (addingNewSegmentStatus === 'added') {
|
||||
const pathToNodeForNewSegment = pathToNode.slice(0, pathToNodeIndex)
|
||||
pathToNodeForNewSegment.push([pipeIndex - 2, 'index'])
|
||||
this.onDragSegment({
|
||||
sketchPathToNode: pathToNodeForNewSegment,
|
||||
object: selected,
|
||||
intersection2d: intersectionPoint.twoD,
|
||||
intersects,
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
this.onDragSegment({
|
||||
object: selected,
|
||||
intersection2d: intersectionPoint.twoD,
|
||||
@ -577,7 +686,7 @@ export class SceneEntities {
|
||||
if (!event) return
|
||||
sceneInfra.modelingSend(event)
|
||||
},
|
||||
...mouseEnterLeaveCallbacks(),
|
||||
...this.mouseEnterLeaveCallbacks(),
|
||||
})
|
||||
}
|
||||
prepareTruncatedMemoryAndAst = (
|
||||
@ -755,8 +864,7 @@ export class SceneEntities {
|
||||
group.userData.to = to
|
||||
group.userData.prevSegment = prevSegment
|
||||
const arrowGroup = group.getObjectByName(ARROWHEAD) as Group
|
||||
|
||||
arrowGroup.position.set(to[0], to[1], 0)
|
||||
const extraSegmentGroup = group.getObjectByName(EXTRA_SEGMENT_HANDLE)
|
||||
|
||||
const previousPoint =
|
||||
prevSegment?.type === 'TangentialArcTo'
|
||||
@ -774,13 +882,49 @@ export class SceneEntities {
|
||||
obtuse: true,
|
||||
})
|
||||
|
||||
const arrowheadAngle =
|
||||
arcInfo.endAngle + (Math.PI / 2) * (arcInfo.ccw ? 1 : -1)
|
||||
arrowGroup.quaternion.setFromUnitVectors(
|
||||
new Vector3(0, 1, 0),
|
||||
new Vector3(Math.cos(arrowheadAngle), Math.sin(arrowheadAngle), 0)
|
||||
)
|
||||
arrowGroup.scale.set(scale, scale, scale)
|
||||
const pxLength = arcInfo.arcLength / scale
|
||||
const shouldHideIdle = pxLength < HIDE_SEGMENT_LENGTH
|
||||
const shouldHideHover = pxLength < HIDE_HOVER_SEGMENT_LENGTH
|
||||
|
||||
const hoveredParent =
|
||||
sceneInfra.hoveredObject &&
|
||||
getParentGroup(sceneInfra.hoveredObject, [TANGENTIAL_ARC_TO_SEGMENT])
|
||||
let isHandlesVisible = !shouldHideIdle
|
||||
if (hoveredParent && hoveredParent?.uuid === group?.uuid) {
|
||||
isHandlesVisible = !shouldHideHover
|
||||
}
|
||||
|
||||
if (arrowGroup) {
|
||||
arrowGroup.position.set(to[0], to[1], 0)
|
||||
|
||||
const arrowheadAngle =
|
||||
arcInfo.endAngle + (Math.PI / 2) * (arcInfo.ccw ? 1 : -1)
|
||||
arrowGroup.quaternion.setFromUnitVectors(
|
||||
new Vector3(0, 1, 0),
|
||||
new Vector3(Math.cos(arrowheadAngle), Math.sin(arrowheadAngle), 0)
|
||||
)
|
||||
arrowGroup.scale.set(scale, scale, scale)
|
||||
arrowGroup.visible = isHandlesVisible
|
||||
}
|
||||
|
||||
if (extraSegmentGroup) {
|
||||
const circumferenceInPx = (2 * Math.PI * arcInfo.radius) / scale
|
||||
const extraSegmentAngleDelta =
|
||||
(EXTRA_SEGMENT_OFFSET_PX / circumferenceInPx) * Math.PI * 2
|
||||
const extraSegmentAngle =
|
||||
arcInfo.startAngle + (arcInfo.ccw ? 1 : -1) * extraSegmentAngleDelta
|
||||
const extraSegmentOffset = new Vector2(
|
||||
Math.cos(extraSegmentAngle) * arcInfo.radius,
|
||||
Math.sin(extraSegmentAngle) * arcInfo.radius
|
||||
)
|
||||
extraSegmentGroup.position.set(
|
||||
arcInfo.center[0] + extraSegmentOffset.x,
|
||||
arcInfo.center[1] + extraSegmentOffset.y,
|
||||
0
|
||||
)
|
||||
extraSegmentGroup.scale.set(scale, scale, scale)
|
||||
extraSegmentGroup.visible = isHandlesVisible
|
||||
}
|
||||
|
||||
const tangentialArcToSegmentBody = group.children.find(
|
||||
(child) => child.userData.type === TANGENTIAL_ARC_TO_SEGMENT_BODY
|
||||
@ -827,10 +971,26 @@ export class SceneEntities {
|
||||
group.userData.from = from
|
||||
group.userData.to = to
|
||||
const shape = new Shape()
|
||||
shape.moveTo(0, -0.08 * scale)
|
||||
shape.lineTo(0, 0.08 * scale) // The width of the line
|
||||
shape.moveTo(0, (-SEGMENT_WIDTH_PX / 2) * scale) // The width of the line in px (2.4px in this case)
|
||||
shape.lineTo(0, (SEGMENT_WIDTH_PX / 2) * scale)
|
||||
const arrowGroup = group.getObjectByName(ARROWHEAD) as Group
|
||||
|
||||
const length = Math.sqrt(
|
||||
Math.pow(to[0] - from[0], 2) + Math.pow(to[1] - from[1], 2)
|
||||
)
|
||||
|
||||
const pxLength = length / scale
|
||||
const shouldHideIdle = pxLength < HIDE_SEGMENT_LENGTH
|
||||
const shouldHideHover = pxLength < HIDE_HOVER_SEGMENT_LENGTH
|
||||
|
||||
const hoveredParent =
|
||||
sceneInfra.hoveredObject &&
|
||||
getParentGroup(sceneInfra.hoveredObject, [STRAIGHT_SEGMENT])
|
||||
let isHandlesVisible = !shouldHideIdle
|
||||
if (hoveredParent && hoveredParent?.uuid === group?.uuid) {
|
||||
isHandlesVisible = !shouldHideHover
|
||||
}
|
||||
|
||||
if (arrowGroup) {
|
||||
arrowGroup.position.set(to[0], to[1], 0)
|
||||
|
||||
@ -842,6 +1002,21 @@ export class SceneEntities {
|
||||
.normalize()
|
||||
arrowGroup.quaternion.setFromUnitVectors(new Vector3(0, 1, 0), dir)
|
||||
arrowGroup.scale.set(scale, scale, scale)
|
||||
arrowGroup.visible = isHandlesVisible
|
||||
}
|
||||
|
||||
const extraSegmentGroup = group.getObjectByName(EXTRA_SEGMENT_HANDLE)
|
||||
if (extraSegmentGroup) {
|
||||
const offsetFromBase = new Vector2(to[0] - from[0], to[1] - from[1])
|
||||
.normalize()
|
||||
.multiplyScalar(EXTRA_SEGMENT_OFFSET_PX * scale)
|
||||
extraSegmentGroup.position.set(
|
||||
from[0] + offsetFromBase.x,
|
||||
from[1] + offsetFromBase.y,
|
||||
0
|
||||
)
|
||||
extraSegmentGroup.scale.set(scale, scale, scale)
|
||||
extraSegmentGroup.visible = isHandlesVisible
|
||||
}
|
||||
|
||||
const straightSegmentBody = group.children.find(
|
||||
@ -1019,6 +1194,119 @@ export class SceneEntities {
|
||||
},
|
||||
})
|
||||
}
|
||||
mouseEnterLeaveCallbacks() {
|
||||
return {
|
||||
onMouseEnter: ({ selected, dragSelected }: 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)
|
||||
const extraSegmentGroup = parent.getObjectByName(EXTRA_SEGMENT_HANDLE)
|
||||
if (extraSegmentGroup) {
|
||||
extraSegmentGroup.traverse((child) => {
|
||||
if (child instanceof Points || child instanceof Mesh) {
|
||||
child.material.opacity = dragSelected ? 0 : 1
|
||||
}
|
||||
})
|
||||
}
|
||||
const orthoFactor = orthoScale(sceneInfra.camControls.camera)
|
||||
|
||||
const factor =
|
||||
(sceneInfra.camControls.camera instanceof OrthographicCamera
|
||||
? orthoFactor
|
||||
: perspScale(sceneInfra.camControls.camera, parent)) /
|
||||
sceneInfra._baseUnitMultiplier
|
||||
if (parent.name === STRAIGHT_SEGMENT) {
|
||||
this.updateStraightSegment({
|
||||
from: parent.userData.from,
|
||||
to: parent.userData.to,
|
||||
group: parent,
|
||||
scale: factor,
|
||||
})
|
||||
} else if (parent.name === TANGENTIAL_ARC_TO_SEGMENT) {
|
||||
this.updateTangentialArcToSegment({
|
||||
prevSegment: parent.userData.prevSegment,
|
||||
from: parent.userData.from,
|
||||
to: parent.userData.to,
|
||||
group: parent,
|
||||
scale: factor,
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
sceneInfra.highlightCallback([0, 0])
|
||||
},
|
||||
onMouseLeave: ({ selected, ...rest }: OnMouseEnterLeaveArgs) => {
|
||||
sceneInfra.highlightCallback([0, 0])
|
||||
const parent = getParentGroup(selected, [
|
||||
STRAIGHT_SEGMENT,
|
||||
TANGENTIAL_ARC_TO_SEGMENT,
|
||||
PROFILE_START,
|
||||
])
|
||||
if (parent) {
|
||||
const orthoFactor = orthoScale(sceneInfra.camControls.camera)
|
||||
|
||||
const factor =
|
||||
(sceneInfra.camControls.camera instanceof OrthographicCamera
|
||||
? orthoFactor
|
||||
: perspScale(sceneInfra.camControls.camera, parent)) /
|
||||
sceneInfra._baseUnitMultiplier
|
||||
if (parent.name === STRAIGHT_SEGMENT) {
|
||||
this.updateStraightSegment({
|
||||
from: parent.userData.from,
|
||||
to: parent.userData.to,
|
||||
group: parent,
|
||||
scale: factor,
|
||||
})
|
||||
} else if (parent.name === TANGENTIAL_ARC_TO_SEGMENT) {
|
||||
this.updateTangentialArcToSegment({
|
||||
prevSegment: parent.userData.prevSegment,
|
||||
from: parent.userData.from,
|
||||
to: parent.userData.to,
|
||||
group: parent,
|
||||
scale: factor,
|
||||
})
|
||||
}
|
||||
}
|
||||
const isSelected = parent?.userData?.isSelected
|
||||
colorSegment(
|
||||
selected,
|
||||
isSelected ? 0x0000ff : parent?.userData?.baseColor || 0xffffff
|
||||
)
|
||||
const extraSegmentGroup = parent?.getObjectByName(EXTRA_SEGMENT_HANDLE)
|
||||
if (extraSegmentGroup) {
|
||||
extraSegmentGroup.traverse((child) => {
|
||||
if (child instanceof Points || child instanceof Mesh) {
|
||||
child.material.opacity = 0
|
||||
}
|
||||
})
|
||||
}
|
||||
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)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type DefaultPlaneStr = 'XY' | 'XZ' | 'YZ' | '-XY' | '-XZ' | '-YZ'
|
||||
@ -1160,7 +1448,7 @@ function colorSegment(object: any, color: number) {
|
||||
])
|
||||
if (straightSegmentBody) {
|
||||
straightSegmentBody.traverse((child) => {
|
||||
if (child instanceof Mesh) {
|
||||
if (child instanceof Mesh && !child.userData.ignoreColorChange) {
|
||||
child.material.color.set(color)
|
||||
}
|
||||
})
|
||||
@ -1261,53 +1549,3 @@ function massageFormats(a: any): Vector3 {
|
||||
? new Vector3(a[0], a[1], a[2])
|
||||
: 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)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
@ -18,6 +18,8 @@ import {
|
||||
Intersection,
|
||||
Object3D,
|
||||
Object3DEventMap,
|
||||
TextureLoader,
|
||||
Texture,
|
||||
} from 'three'
|
||||
import { compareVec2Epsilon2 } from 'lang/std/sketch'
|
||||
import { useModelingContext } from 'hooks/useModelingContext'
|
||||
@ -25,9 +27,10 @@ import * as TWEEN from '@tweenjs/tween.js'
|
||||
import { SourceRange } from 'lang/wasm'
|
||||
import { Axis } from 'lib/selections'
|
||||
import { type BaseUnit } from 'lib/settings/settingsTypes'
|
||||
import { SETTINGS_PERSIST_KEY } from 'lib/constants'
|
||||
import { CameraControls } from './CameraControls'
|
||||
import { EngineCommandManager } from 'lang/std/engineConnection'
|
||||
import { settings } from 'lib/settings/initialSettings'
|
||||
import { MouseState } from 'machines/modelingMachine'
|
||||
|
||||
type SendType = ReturnType<typeof useModelingContext>['send']
|
||||
|
||||
@ -54,6 +57,7 @@ export const ARROWHEAD = 'arrowhead'
|
||||
|
||||
export interface OnMouseEnterLeaveArgs {
|
||||
selected: Object3D<Object3DEventMap>
|
||||
dragSelected?: Object3D<Object3DEventMap>
|
||||
mouseEvent: MouseEvent
|
||||
}
|
||||
|
||||
@ -98,18 +102,26 @@ export class SceneInfra {
|
||||
isFovAnimationInProgress = false
|
||||
_baseUnit: BaseUnit = 'mm'
|
||||
_baseUnitMultiplier = 1
|
||||
extraSegmentTexture: Texture
|
||||
lastMouseState: MouseState = { type: 'idle' }
|
||||
onDragStartCallback: (arg: OnDragCallbackArgs) => void = () => {}
|
||||
onDragEndCallback: (arg: OnDragCallbackArgs) => void = () => {}
|
||||
onDragCallback: (arg: OnDragCallbackArgs) => void = () => {}
|
||||
onMoveCallback: (arg: OnMoveCallbackArgs) => void = () => {}
|
||||
onClickCallback: (arg: OnClickCallbackArgs) => void = () => {}
|
||||
onMouseEnter: (arg: OnMouseEnterLeaveArgs) => void = () => {}
|
||||
onMouseLeave: (arg: OnMouseEnterLeaveArgs) => void = () => {}
|
||||
setCallbacks = (callbacks: {
|
||||
onDragStart?: (arg: OnDragCallbackArgs) => void
|
||||
onDragEnd?: (arg: OnDragCallbackArgs) => void
|
||||
onDrag?: (arg: OnDragCallbackArgs) => void
|
||||
onMove?: (arg: OnMoveCallbackArgs) => void
|
||||
onClick?: (arg: OnClickCallbackArgs) => void
|
||||
onMouseEnter?: (arg: OnMouseEnterLeaveArgs) => void
|
||||
onMouseLeave?: (arg: OnMouseEnterLeaveArgs) => void
|
||||
}) => {
|
||||
this.onDragStartCallback = callbacks.onDragStart || this.onDragStartCallback
|
||||
this.onDragEndCallback = callbacks.onDragEnd || this.onDragEndCallback
|
||||
this.onDragCallback = callbacks.onDrag || this.onDragCallback
|
||||
this.onMoveCallback = callbacks.onMove || this.onMoveCallback
|
||||
this.onClickCallback = callbacks.onClick || this.onClickCallback
|
||||
@ -128,6 +140,8 @@ export class SceneInfra {
|
||||
}
|
||||
resetMouseListeners = () => {
|
||||
this.setCallbacks({
|
||||
onDragStart: () => {},
|
||||
onDragEnd: () => {},
|
||||
onDrag: () => {},
|
||||
onMove: () => {},
|
||||
onClick: () => {},
|
||||
@ -170,9 +184,7 @@ export class SceneInfra {
|
||||
|
||||
// CAMERA
|
||||
const camHeightDistanceRatio = 0.5
|
||||
const baseUnit: BaseUnit =
|
||||
JSON.parse(localStorage?.getItem(SETTINGS_PERSIST_KEY) || ('{}' as any))
|
||||
.baseUnit || 'mm'
|
||||
const baseUnit: BaseUnit = settings.modeling.defaultUnit.current
|
||||
const baseRadius = 5.6
|
||||
const length = baseUnitTomm(baseUnit) * baseRadius
|
||||
const ang = Math.atan(camHeightDistanceRatio)
|
||||
@ -212,6 +224,13 @@ export class SceneInfra {
|
||||
const light = new AmbientLight(0x505050) // soft white light
|
||||
this.scene.add(light)
|
||||
|
||||
const textureLoader = new TextureLoader()
|
||||
this.extraSegmentTexture = textureLoader.load(
|
||||
'/clientSideSceneAssets/extra-segment-texture.png'
|
||||
)
|
||||
this.extraSegmentTexture.anisotropy =
|
||||
this.renderer?.capabilities?.getMaxAnisotropy?.()
|
||||
|
||||
SceneInfra.instance = this
|
||||
}
|
||||
|
||||
@ -321,8 +340,6 @@ export class SceneInfra {
|
||||
planeIntersectPoint.twoD &&
|
||||
planeIntersectPoint.threeD
|
||||
) {
|
||||
// // console.log('onDrag', this.selected)
|
||||
|
||||
this.onDragCallback({
|
||||
mouseEvent,
|
||||
intersectionPoint: {
|
||||
@ -332,6 +349,10 @@ export class SceneInfra {
|
||||
intersects,
|
||||
selected: this.selected.object,
|
||||
})
|
||||
this.updateMouseState({
|
||||
type: 'isDragging',
|
||||
on: this.selected.object,
|
||||
})
|
||||
}
|
||||
} else if (
|
||||
planeIntersectPoint &&
|
||||
@ -351,25 +372,34 @@ export class SceneInfra {
|
||||
if (intersects[0]) {
|
||||
const firstIntersectObject = intersects[0].object
|
||||
if (this.hoveredObject !== firstIntersectObject) {
|
||||
if (this.hoveredObject) {
|
||||
this.onMouseLeave({
|
||||
selected: this.hoveredObject,
|
||||
mouseEvent: mouseEvent,
|
||||
})
|
||||
}
|
||||
const hoveredObj = this.hoveredObject
|
||||
this.hoveredObject = null
|
||||
this.onMouseLeave({
|
||||
selected: hoveredObj,
|
||||
mouseEvent: mouseEvent,
|
||||
})
|
||||
this.hoveredObject = firstIntersectObject
|
||||
this.onMouseEnter({
|
||||
selected: this.hoveredObject,
|
||||
dragSelected: this.selected?.object,
|
||||
mouseEvent: mouseEvent,
|
||||
})
|
||||
if (!this.selected)
|
||||
this.updateMouseState({
|
||||
type: 'isHovering',
|
||||
on: this.hoveredObject,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
if (this.hoveredObject) {
|
||||
const hoveredObj = this.hoveredObject
|
||||
this.hoveredObject = null
|
||||
this.onMouseLeave({
|
||||
selected: this.hoveredObject,
|
||||
selected: hoveredObj,
|
||||
dragSelected: this.selected?.object,
|
||||
mouseEvent: mouseEvent,
|
||||
})
|
||||
this.hoveredObject = null
|
||||
if (!this.selected) this.updateMouseState({ type: 'idle' })
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -426,6 +456,11 @@ export class SceneInfra {
|
||||
(a, b) => a.distance - b.distance
|
||||
)
|
||||
}
|
||||
updateMouseState(mouseState: MouseState) {
|
||||
if (this.lastMouseState.type === mouseState.type) return
|
||||
this.lastMouseState = mouseState
|
||||
this.modelingSend({ type: 'Set mouse state', data: mouseState })
|
||||
}
|
||||
|
||||
onMouseDown = (event: MouseEvent) => {
|
||||
this.currentMouseVector.x = (event.clientX / window.innerWidth) * 2 - 1
|
||||
@ -455,8 +490,26 @@ export class SceneInfra {
|
||||
|
||||
if (this.selected) {
|
||||
if (this.selected.hasBeenDragged) {
|
||||
// this is where we could fire a onDragEnd event
|
||||
// console.log('onDragEnd', this.selected)
|
||||
// TODO do the types properly here
|
||||
this.onDragEndCallback({
|
||||
intersectionPoint: {
|
||||
twoD: planeIntersectPoint?.twoD as any,
|
||||
threeD: planeIntersectPoint?.threeD as any,
|
||||
},
|
||||
intersects,
|
||||
mouseEvent,
|
||||
selected: this.selected as any,
|
||||
})
|
||||
if (intersects.length) {
|
||||
this.updateMouseState({
|
||||
type: 'isHovering',
|
||||
on: intersects[0].object,
|
||||
})
|
||||
} else {
|
||||
this.updateMouseState({
|
||||
type: 'idle',
|
||||
})
|
||||
}
|
||||
} else if (planeIntersectPoint?.twoD && planeIntersectPoint?.threeD) {
|
||||
// fire onClick event as there was no drags
|
||||
this.onClickCallback({
|
||||
|
@ -12,15 +12,22 @@ import {
|
||||
Mesh,
|
||||
MeshBasicMaterial,
|
||||
NormalBufferAttributes,
|
||||
Points,
|
||||
PointsMaterial,
|
||||
Shape,
|
||||
SphereGeometry,
|
||||
Texture,
|
||||
Vector2,
|
||||
Vector3,
|
||||
} from 'three'
|
||||
import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
|
||||
import { PathToNode, SketchGroup, getTangentialArcToInfo } from 'lang/wasm'
|
||||
import {
|
||||
EXTRA_SEGMENT_HANDLE,
|
||||
EXTRA_SEGMENT_OFFSET_PX,
|
||||
HIDE_SEGMENT_LENGTH,
|
||||
PROFILE_START,
|
||||
SEGMENT_WIDTH_PX,
|
||||
STRAIGHT_SEGMENT,
|
||||
STRAIGHT_SEGMENT_BODY,
|
||||
STRAIGHT_SEGMENT_DASH,
|
||||
@ -44,7 +51,7 @@ export function profileStart({
|
||||
}) {
|
||||
const group = new Group()
|
||||
|
||||
const geometry = new BoxGeometry(0.8, 0.8, 0.8)
|
||||
const geometry = new BoxGeometry(12, 12, 12) // in pixels scaled later
|
||||
const body = new MeshBasicMaterial({ color: 0xffffff })
|
||||
const mesh = new Mesh(geometry, body)
|
||||
|
||||
@ -71,6 +78,7 @@ export function straightSegment({
|
||||
isDraftSegment,
|
||||
scale = 1,
|
||||
callExpName,
|
||||
texture,
|
||||
}: {
|
||||
from: Coords2d
|
||||
to: Coords2d
|
||||
@ -79,12 +87,13 @@ export function straightSegment({
|
||||
isDraftSegment?: boolean
|
||||
scale?: number
|
||||
callExpName: string
|
||||
texture: Texture
|
||||
}): Group {
|
||||
const group = new Group()
|
||||
|
||||
const shape = new Shape()
|
||||
shape.moveTo(0, -0.08 * scale)
|
||||
shape.lineTo(0, 0.08 * scale) // The width of the line
|
||||
shape.moveTo(0, (-SEGMENT_WIDTH_PX / 2) * scale)
|
||||
shape.lineTo(0, (SEGMENT_WIDTH_PX / 2) * scale)
|
||||
|
||||
let geometry
|
||||
if (isDraftSegment) {
|
||||
@ -122,24 +131,44 @@ export function straightSegment({
|
||||
}
|
||||
group.name = STRAIGHT_SEGMENT
|
||||
|
||||
const length = Math.sqrt(
|
||||
Math.pow(to[0] - from[0], 2) + Math.pow(to[1] - from[1], 2)
|
||||
)
|
||||
const arrowGroup = createArrowhead(scale)
|
||||
arrowGroup.position.set(to[0], to[1], 0)
|
||||
const dir = new Vector3()
|
||||
.subVectors(new Vector3(to[0], to[1], 0), new Vector3(from[0], from[1], 0))
|
||||
.normalize()
|
||||
arrowGroup.quaternion.setFromUnitVectors(new Vector3(0, 1, 0), dir)
|
||||
const pxLength = length / scale
|
||||
const shouldHide = pxLength < HIDE_SEGMENT_LENGTH
|
||||
arrowGroup.visible = !shouldHide
|
||||
|
||||
group.add(mesh)
|
||||
if (callExpName !== 'close') group.add(arrowGroup)
|
||||
|
||||
const extraSegmentGroup = createExtraSegmentHandle(scale, texture)
|
||||
const offsetFromBase = new Vector2(to[0] - from[0], to[1] - from[1])
|
||||
.normalize()
|
||||
.multiplyScalar(EXTRA_SEGMENT_OFFSET_PX * scale)
|
||||
extraSegmentGroup.position.set(
|
||||
from[0] + offsetFromBase.x,
|
||||
from[1] + offsetFromBase.y,
|
||||
0
|
||||
)
|
||||
extraSegmentGroup.visible = !shouldHide
|
||||
group.add(extraSegmentGroup)
|
||||
|
||||
return group
|
||||
}
|
||||
|
||||
function createArrowhead(scale = 1): Group {
|
||||
const arrowMaterial = new MeshBasicMaterial({ color: 0xffffff })
|
||||
const arrowheadMesh = new Mesh(new ConeGeometry(0.31, 1.5, 12), arrowMaterial)
|
||||
arrowheadMesh.position.set(0, -0.6, 0)
|
||||
const sphereMesh = new Mesh(new SphereGeometry(0.27, 12, 12), arrowMaterial)
|
||||
// specify the size of the geometry in pixels (i.e. cone height = 20px, cone radius = 4.5px)
|
||||
// we'll scale the group to the correct size later to match these sizes in screen space
|
||||
const arrowheadMesh = new Mesh(new ConeGeometry(4.5, 20, 12), arrowMaterial)
|
||||
arrowheadMesh.position.set(0, -9, 0)
|
||||
const sphereMesh = new Mesh(new SphereGeometry(4, 12, 12), arrowMaterial)
|
||||
|
||||
const arrowGroup = new Group()
|
||||
arrowGroup.userData.type = ARROWHEAD
|
||||
@ -150,6 +179,36 @@ function createArrowhead(scale = 1): Group {
|
||||
return arrowGroup
|
||||
}
|
||||
|
||||
function createExtraSegmentHandle(scale: number, texture: Texture): Group {
|
||||
const particleMaterial = new PointsMaterial({
|
||||
size: 12, // in pixels
|
||||
map: texture,
|
||||
transparent: true,
|
||||
opacity: 0,
|
||||
depthTest: false,
|
||||
})
|
||||
const mat = new MeshBasicMaterial({
|
||||
transparent: true,
|
||||
color: 0xffffff,
|
||||
opacity: 0,
|
||||
})
|
||||
const particleGeometry = new BufferGeometry().setFromPoints([
|
||||
new Vector3(0, 0, 0),
|
||||
])
|
||||
const sphereMesh = new Mesh(new SphereGeometry(6, 12, 12), mat) // sphere radius in pixels
|
||||
const particle = new Points(particleGeometry, particleMaterial)
|
||||
particle.userData.ignoreColorChange = true
|
||||
particle.userData.type = EXTRA_SEGMENT_HANDLE
|
||||
|
||||
const extraSegmentGroup = new Group()
|
||||
extraSegmentGroup.userData.type = EXTRA_SEGMENT_HANDLE
|
||||
extraSegmentGroup.name = EXTRA_SEGMENT_HANDLE
|
||||
extraSegmentGroup.add(sphereMesh)
|
||||
extraSegmentGroup.add(particle)
|
||||
extraSegmentGroup.scale.set(scale, scale, scale)
|
||||
return extraSegmentGroup
|
||||
}
|
||||
|
||||
export function tangentialArcToSegment({
|
||||
prevSegment,
|
||||
from,
|
||||
@ -158,6 +217,7 @@ export function tangentialArcToSegment({
|
||||
pathToNode,
|
||||
isDraftSegment,
|
||||
scale = 1,
|
||||
texture,
|
||||
}: {
|
||||
prevSegment: SketchGroup['value'][number]
|
||||
from: Coords2d
|
||||
@ -166,6 +226,7 @@ export function tangentialArcToSegment({
|
||||
pathToNode: PathToNode
|
||||
isDraftSegment?: boolean
|
||||
scale?: number
|
||||
texture: Texture
|
||||
}): Group {
|
||||
const group = new Group()
|
||||
|
||||
@ -178,12 +239,13 @@ export function tangentialArcToSegment({
|
||||
)
|
||||
: prevSegment.from
|
||||
|
||||
const { center, radius, startAngle, endAngle, ccw } = getTangentialArcToInfo({
|
||||
arcStartPoint: from,
|
||||
arcEndPoint: to,
|
||||
tanPreviousPoint: previousPoint,
|
||||
obtuse: true,
|
||||
})
|
||||
const { center, radius, startAngle, endAngle, ccw, arcLength } =
|
||||
getTangentialArcToInfo({
|
||||
arcStartPoint: from,
|
||||
arcEndPoint: to,
|
||||
tanPreviousPoint: previousPoint,
|
||||
obtuse: true,
|
||||
})
|
||||
|
||||
const geometry = createArcGeometry({
|
||||
center,
|
||||
@ -219,8 +281,28 @@ export function tangentialArcToSegment({
|
||||
new Vector3(0, 1, 0),
|
||||
new Vector3(Math.cos(arrowheadAngle), Math.sin(arrowheadAngle), 0)
|
||||
)
|
||||
const pxLength = arcLength / scale
|
||||
const shouldHide = pxLength < HIDE_SEGMENT_LENGTH
|
||||
arrowGroup.visible = !shouldHide
|
||||
|
||||
group.add(mesh, arrowGroup)
|
||||
const extraSegmentGroup = createExtraSegmentHandle(scale, texture)
|
||||
const circumferenceInPx = (2 * Math.PI * radius) / scale
|
||||
const extraSegmentAngleDelta =
|
||||
(EXTRA_SEGMENT_OFFSET_PX / circumferenceInPx) * Math.PI * 2
|
||||
const extraSegmentAngle = startAngle + (ccw ? 1 : -1) * extraSegmentAngleDelta
|
||||
const extraSegmentOffset = new Vector2(
|
||||
Math.cos(extraSegmentAngle) * radius,
|
||||
Math.sin(extraSegmentAngle) * radius
|
||||
)
|
||||
extraSegmentGroup.position.set(
|
||||
center[0] + extraSegmentOffset.x,
|
||||
center[1] + extraSegmentOffset.y,
|
||||
0
|
||||
)
|
||||
|
||||
extraSegmentGroup.visible = !shouldHide
|
||||
|
||||
group.add(mesh, arrowGroup, extraSegmentGroup)
|
||||
|
||||
return group
|
||||
}
|
||||
@ -242,8 +324,8 @@ export function createArcGeometry({
|
||||
isDashed?: boolean
|
||||
scale?: number
|
||||
}): BufferGeometry {
|
||||
const dashSize = 1.2 * scale
|
||||
const gapSize = 1.2 * scale
|
||||
const dashSizePx = 18 * scale
|
||||
const gapSizePx = 18 * scale
|
||||
const arcStart = new EllipseCurve(
|
||||
center[0],
|
||||
center[1],
|
||||
@ -265,8 +347,8 @@ export function createArcGeometry({
|
||||
0
|
||||
)
|
||||
const shape = new Shape()
|
||||
shape.moveTo(0, -0.08 * scale)
|
||||
shape.lineTo(0, 0.08 * scale) // The width of the line
|
||||
shape.moveTo(0, (-SEGMENT_WIDTH_PX / 2) * scale)
|
||||
shape.lineTo(0, (SEGMENT_WIDTH_PX / 2) * scale) // The width of the line
|
||||
|
||||
if (!isDashed) {
|
||||
const points = arcStart.getPoints(50)
|
||||
@ -281,7 +363,7 @@ export function createArcGeometry({
|
||||
}
|
||||
|
||||
const length = arcStart.getLength()
|
||||
const totalDashes = length / (dashSize + gapSize) // rounding makes the dashes jittery since the new dash is suddenly appears instead of growing into place
|
||||
const totalDashes = length / (dashSizePx + gapSizePx) // rounding makes the dashes jittery since the new dash is suddenly appears instead of growing into place
|
||||
const dashesAtEachEnd = Math.min(100, totalDashes / 2) // Assuming we want 50 dashes total, 25 at each end
|
||||
|
||||
const dashGeometries = []
|
||||
@ -289,8 +371,8 @@ export function createArcGeometry({
|
||||
// Function to create a dash at a specific t value (0 to 1 along the curve)
|
||||
const createDashAt = (t: number, curve: EllipseCurve) => {
|
||||
const startVec = curve.getPoint(t)
|
||||
const endVec = curve.getPoint(Math.min(0.5, t + dashSize / length))
|
||||
const midVec = curve.getPoint(Math.min(0.5, t + dashSize / length / 2))
|
||||
const endVec = curve.getPoint(Math.min(0.5, t + dashSizePx / length))
|
||||
const midVec = curve.getPoint(Math.min(0.5, t + dashSizePx / length / 2))
|
||||
const dashCurve = new CurvePath<Vector3>()
|
||||
dashCurve.add(
|
||||
new CatmullRomCurve3([
|
||||
@ -314,7 +396,8 @@ export function createArcGeometry({
|
||||
}
|
||||
|
||||
// fill in the remaining arc
|
||||
const remainingArcLength = length - dashesAtEachEnd * 2 * (dashSize + gapSize)
|
||||
const remainingArcLength =
|
||||
length - dashesAtEachEnd * 2 * (dashSizePx + gapSizePx)
|
||||
if (remainingArcLength > 0) {
|
||||
const remainingArcStartT = dashesAtEachEnd / totalDashes
|
||||
const remainingArcEndT = 1 - remainingArcStartT
|
||||
@ -359,8 +442,8 @@ export function dashedStraight(
|
||||
shape: Shape,
|
||||
scale = 1
|
||||
): BufferGeometry<NormalBufferAttributes> {
|
||||
const dashSize = 1.2 * scale
|
||||
const gapSize = 1.2 * scale // todo: gabSize is not respected
|
||||
const dashSize = 18 * scale
|
||||
const gapSize = 18 * scale // TODO: gapSize is not respected
|
||||
const dashLine = new LineCurve3(
|
||||
new Vector3(from[0], from[1], 0),
|
||||
new Vector3(to[0], to[1], 0)
|
||||
|
@ -1,29 +1,35 @@
|
||||
import { Combobox } from '@headlessui/react'
|
||||
import { useSelector } from '@xstate/react'
|
||||
import Fuse from 'fuse.js'
|
||||
import { useCommandsContext } from 'hooks/useCommandsContext'
|
||||
import { CommandArgument, CommandArgumentOption } from 'lib/commandTypes'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { AnyStateMachine, StateFrom } from 'xstate'
|
||||
|
||||
const contextSelector = (snapshot: StateFrom<AnyStateMachine>) =>
|
||||
snapshot.context
|
||||
|
||||
function CommandArgOptionInput({
|
||||
options,
|
||||
arg,
|
||||
argName,
|
||||
stepBack,
|
||||
onSubmit,
|
||||
placeholder,
|
||||
}: {
|
||||
options: (CommandArgument<unknown> & { inputType: 'options' })['options']
|
||||
arg: CommandArgument<unknown> & { inputType: 'options' }
|
||||
argName: string
|
||||
stepBack: () => void
|
||||
onSubmit: (data: unknown) => void
|
||||
placeholder?: string
|
||||
}) {
|
||||
const actorContext = useSelector(arg.machineActor, contextSelector)
|
||||
const { commandBarSend, commandBarState } = useCommandsContext()
|
||||
const resolvedOptions = useMemo(
|
||||
() =>
|
||||
typeof options === 'function'
|
||||
? options(commandBarState.context)
|
||||
: options,
|
||||
[argName, options, commandBarState.context]
|
||||
typeof arg.options === 'function'
|
||||
? arg.options(commandBarState.context, actorContext)
|
||||
: arg.options,
|
||||
[argName, arg, commandBarState.context, actorContext]
|
||||
)
|
||||
// The initial current option is either an already-input value or the configured default
|
||||
const currentOption = useMemo(
|
||||
@ -38,7 +44,7 @@ function CommandArgOptionInput({
|
||||
const [selectedOption, setSelectedOption] = useState<
|
||||
CommandArgumentOption<unknown>
|
||||
>(currentOption || resolvedOptions[0])
|
||||
const initialQuery = useMemo(() => '', [options, argName])
|
||||
const initialQuery = useMemo(() => '', [arg.options, argName])
|
||||
const [query, setQuery] = useState(initialQuery)
|
||||
const [filteredOptions, setFilteredOptions] =
|
||||
useState<typeof resolvedOptions>()
|
||||
|
@ -51,7 +51,24 @@ function ArgumentInput({
|
||||
case 'options':
|
||||
return (
|
||||
<CommandArgOptionInput
|
||||
options={arg.options}
|
||||
arg={arg}
|
||||
argName={arg.name}
|
||||
stepBack={stepBack}
|
||||
onSubmit={onSubmit}
|
||||
placeholder="Select an option"
|
||||
/>
|
||||
)
|
||||
case 'boolean':
|
||||
return (
|
||||
<CommandArgOptionInput
|
||||
arg={{
|
||||
...arg,
|
||||
inputType: 'options',
|
||||
options: [
|
||||
{ name: 'On', value: true },
|
||||
{ name: 'Off', value: false },
|
||||
],
|
||||
}}
|
||||
argName={arg.name}
|
||||
stepBack={stepBack}
|
||||
onSubmit={onSubmit}
|
||||
|
@ -74,7 +74,7 @@ function CommandBarHeader({ children }: React.PropsWithChildren<{}>) {
|
||||
selectedCommand.icon && (
|
||||
<CustomIcon name={selectedCommand.icon} className="w-5 h-5" />
|
||||
)}
|
||||
{selectedCommand?.name}
|
||||
{selectedCommand.displayName || selectedCommand.name}
|
||||
</p>
|
||||
{Object.entries(selectedCommand?.args || {})
|
||||
.filter(([_, argConfig]) =>
|
||||
|
@ -76,9 +76,9 @@ function CommandBarKclInput({
|
||||
},
|
||||
accessKey: 'command-bar',
|
||||
theme:
|
||||
settings.context.theme === 'system'
|
||||
settings.context.app.theme.current === 'system'
|
||||
? getSystemTheme()
|
||||
: settings.context.theme,
|
||||
: settings.context.app.theme.current,
|
||||
extensions: [
|
||||
EditorView.domEventHandlers({
|
||||
keydown: (event) => {
|
||||
|
@ -20,7 +20,7 @@ function CommandComboBox({
|
||||
options.find((o) => 'isCurrent' in o && o.isCurrent) || null
|
||||
|
||||
const fuse = new Fuse(options, {
|
||||
keys: ['name', 'description'],
|
||||
keys: ['displayName', 'name', 'description'],
|
||||
threshold: 0.3,
|
||||
})
|
||||
|
||||
@ -80,7 +80,12 @@ function CommandComboBox({
|
||||
className="w-5 h-5 dark:text-energy-10"
|
||||
/>
|
||||
)}
|
||||
<p className="flex-grow">{option.name} </p>
|
||||
<p className="flex-grow">{option.displayName || option.name} </p>
|
||||
{option.description && (
|
||||
<p className="text-xs text-chalkboard-60 dark:text-chalkboard-40">
|
||||
{option.description}
|
||||
</p>
|
||||
)}
|
||||
</Combobox.Option>
|
||||
))}
|
||||
</Combobox.Options>
|
||||
|
@ -25,7 +25,9 @@ export type CustomIconName =
|
||||
| 'network'
|
||||
| 'networkCrossedOut'
|
||||
| 'parallel'
|
||||
| 'person'
|
||||
| 'plus'
|
||||
| 'refresh'
|
||||
| 'search'
|
||||
| 'settings'
|
||||
| 'sketch'
|
||||
@ -453,6 +455,22 @@ export const CustomIcon = ({
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
case 'person':
|
||||
return (
|
||||
<svg
|
||||
{...props}
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M12 6C12 7.10457 11.1046 8 10 8C8.89543 8 8 7.10457 8 6C8 4.89543 8.89543 4 10 4C11.1046 4 12 4.89543 12 6ZM13 6C13 7.65685 11.6569 9 10 9C8.34315 9 7 7.65685 7 6C7 4.34315 8.34315 3 10 3C11.6569 3 13 4.34315 13 6ZM5 12V11L9 10H11L15 11V12C15 14.7614 12.7614 17 10 17C7.23858 17 5 14.7614 5 12ZM6 11.7808L9.12311 11H10.8769L14 11.7808V12C14 14.2091 12.2091 16 10 16C7.79086 16 6 14.2091 6 12V11.7808Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
case 'plus':
|
||||
return (
|
||||
<svg
|
||||
@ -462,13 +480,29 @@ export const CustomIcon = ({
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M9.5 9.5V5.5H10.5V9.5H14.5V10.5H10.5V14.5H9.5V10.5H5.5V9.5H9.5Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
case 'refresh':
|
||||
return (
|
||||
<svg
|
||||
{...props}
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M8.93434 4.43959L9.4014 4.26111L14.0251 2.49432L14.382 3.42845L11.5647 4.50499L10.5648 4.51221C10.8323 4.53935 11.0173 4.58539 11.2161 4.63484C11.2179 4.63528 11.2197 4.63572 11.2214 4.63616L11.2195 4.6369C11.8713 4.78513 12.4941 5.05172 13.0556 5.42692C13.9601 6.03127 14.6651 6.89025 15.0813 7.89524C15.4976 8.90024 15.6065 10.0061 15.3943 11.073C15.1821 12.1399 14.6583 13.1199 13.8891 13.8891C13.1199 14.6583 12.1399 15.1821 11.073 15.3943C10.0061 15.6065 8.90023 15.4976 7.89524 15.0813C6.89025 14.6651 6.03126 13.9601 5.42692 13.0556C4.82257 12.1512 4.5 11.0878 4.5 10H5.5C5.5 10.89 5.76392 11.76 6.25839 12.5001C6.75285 13.2401 7.45566 13.8169 8.27792 14.1575C9.10019 14.4981 10.005 14.5872 10.8779 14.4135C11.7508 14.2399 12.5526 13.8113 13.182 13.182C13.8113 12.5526 14.2399 11.7508 14.4135 10.8779C14.5872 10.005 14.4981 9.10019 14.1575 8.27793C13.8169 7.45566 13.2401 6.75286 12.5001 6.25839C11.8763 5.84159 11.1601 5.5886 10.4175 5.51941L11.8137 9.17339L10.8796 9.53033L9.11281 4.90665L8.93434 4.43959Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
case 'search':
|
||||
return (
|
||||
<svg
|
||||
|
@ -13,7 +13,7 @@ import {
|
||||
StateFrom,
|
||||
} from 'xstate'
|
||||
import { useCommandsContext } from 'hooks/useCommandsContext'
|
||||
import { DEFAULT_FILE_NAME, fileMachine } from 'machines/fileMachine'
|
||||
import { fileMachine } from 'machines/fileMachine'
|
||||
import {
|
||||
createDir,
|
||||
removeDir,
|
||||
@ -21,9 +21,10 @@ import {
|
||||
renameFile,
|
||||
writeFile,
|
||||
} from '@tauri-apps/api/fs'
|
||||
import { FILE_EXT, readProject } from 'lib/tauriFS'
|
||||
import { readProject } from 'lib/tauriFS'
|
||||
import { isTauri } from 'lib/isTauri'
|
||||
import { sep } from '@tauri-apps/api/path'
|
||||
import { DEFAULT_FILE_NAME, FILE_EXT } from 'lib/constants'
|
||||
|
||||
type MachineContext<T extends AnyStateMachine> = {
|
||||
state: StateFrom<T>
|
||||
|
@ -11,7 +11,8 @@ import { faChevronRight, faTrashAlt } from '@fortawesome/free-solid-svg-icons'
|
||||
import { useFileContext } from 'hooks/useFileContext'
|
||||
import { useHotkeys } from 'react-hotkeys-hook'
|
||||
import styles from './FileTree.module.css'
|
||||
import { FILE_EXT, sortProject } from 'lib/tauriFS'
|
||||
import { sortProject } from 'lib/tauriFS'
|
||||
import { FILE_EXT } from 'lib/constants'
|
||||
import { CustomIcon } from './CustomIcon'
|
||||
import { kclManager } from 'lib/singletons'
|
||||
import { useDocumentHasFocus } from 'hooks/useDocumentHasFocus'
|
||||
|
@ -66,13 +66,16 @@ export const ModelingMachineProvider = ({
|
||||
const {
|
||||
auth,
|
||||
settings: {
|
||||
context: { baseUnit, theme },
|
||||
context: {
|
||||
app: { theme },
|
||||
modeling: { defaultUnit },
|
||||
},
|
||||
},
|
||||
} = useSettingsAuthContext()
|
||||
const { code } = useKclContext()
|
||||
const token = auth?.context?.token
|
||||
const streamRef = useRef<HTMLDivElement>(null)
|
||||
useSetupEngineManager(streamRef, token, theme)
|
||||
useSetupEngineManager(streamRef, token, theme.current)
|
||||
|
||||
const {
|
||||
isShiftDown,
|
||||
@ -109,6 +112,9 @@ export const ModelingMachineProvider = ({
|
||||
kclManager.executeAst()
|
||||
}
|
||||
},
|
||||
'Set mouse state': assign({
|
||||
mouseState: (_, event) => event.data,
|
||||
}),
|
||||
'Set selection': assign(({ selectionRanges }, event) => {
|
||||
if (event.type !== 'Set selection') return {} // this was needed for ts after adding 'Set selection' action to on done modal events
|
||||
const setSelections = event.data
|
||||
@ -234,7 +240,7 @@ export const ModelingMachineProvider = ({
|
||||
format.type === 'stl' ||
|
||||
format.type === 'ply'
|
||||
) {
|
||||
format.units = baseUnit
|
||||
format.units = defaultUnit.current
|
||||
}
|
||||
|
||||
if (format.type === 'ply' || format.type === 'stl') {
|
||||
|
@ -9,7 +9,8 @@ import {
|
||||
faTrashAlt,
|
||||
faX,
|
||||
} from '@fortawesome/free-solid-svg-icons'
|
||||
import { FILE_EXT, getPartsCount, readProject } from '../lib/tauriFS'
|
||||
import { getPartsCount, readProject } from '../lib/tauriFS'
|
||||
import { FILE_EXT } from 'lib/constants'
|
||||
import { Dialog } from '@headlessui/react'
|
||||
import { useHotkeys } from 'react-hotkeys-hook'
|
||||
|
||||
|
@ -6,12 +6,9 @@ import withBaseUrl from '../lib/withBaseURL'
|
||||
import React, { createContext, useEffect } from 'react'
|
||||
import useStateMachineCommands from '../hooks/useStateMachineCommands'
|
||||
import { settingsMachine } from 'machines/settingsMachine'
|
||||
import {
|
||||
fallbackLoadedSettings,
|
||||
validateSettings,
|
||||
} from 'lib/settings/settingsUtils'
|
||||
import { toast } from 'react-hot-toast'
|
||||
import { getThemeColorForEngine, setThemeClass, Themes } from 'lib/theme'
|
||||
import decamelize from 'decamelize'
|
||||
import {
|
||||
AnyStateMachine,
|
||||
ContextFrom,
|
||||
@ -20,10 +17,19 @@ import {
|
||||
StateFrom,
|
||||
} from 'xstate'
|
||||
import { isTauri } from 'lib/isTauri'
|
||||
import { settingsCommandBarConfig } from 'lib/commandBarConfigs/settingsCommandConfig'
|
||||
import { authCommandBarConfig } from 'lib/commandBarConfigs/authCommandConfig'
|
||||
import { kclManager, sceneInfra, engineCommandManager } from 'lib/singletons'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { uuidv4 } from 'lib/utils'
|
||||
import { IndexLoaderData } from 'lib/types'
|
||||
import { settings } from 'lib/settings/initialSettings'
|
||||
import {
|
||||
createSettingsCommand,
|
||||
settingsWithCommandConfigs,
|
||||
} from 'lib/commandBarConfigs/settingsCommandConfig'
|
||||
import { useCommandsContext } from 'hooks/useCommandsContext'
|
||||
import { Command } from 'lib/commandTypes'
|
||||
import { BaseUnit } from 'lib/settings/settingsTypes'
|
||||
import { saveSettings } from 'lib/settings/settingsUtils'
|
||||
|
||||
type MachineContext<T extends AnyStateMachine> = {
|
||||
state: StateFrom<T>
|
||||
@ -49,11 +55,13 @@ export const SettingsAuthProvider = ({
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) => {
|
||||
const loadedSettings = useRouteLoaderData(paths.INDEX) as Awaited<
|
||||
ReturnType<typeof validateSettings>
|
||||
>
|
||||
const loadedSettings = useRouteLoaderData(paths.INDEX) as typeof settings
|
||||
const loadedProject = useRouteLoaderData(paths.FILE) as IndexLoaderData
|
||||
return (
|
||||
<SettingsAuthProviderBase loadedSettings={loadedSettings}>
|
||||
<SettingsAuthProviderBase
|
||||
loadedSettings={loadedSettings}
|
||||
loadedProject={loadedProject}
|
||||
>
|
||||
{children}
|
||||
</SettingsAuthProviderBase>
|
||||
)
|
||||
@ -66,7 +74,7 @@ export const SettingsAuthProviderJest = ({
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) => {
|
||||
const loadedSettings = fallbackLoadedSettings
|
||||
const loadedSettings = settings
|
||||
return (
|
||||
<SettingsAuthProviderBase loadedSettings={loadedSettings}>
|
||||
{children}
|
||||
@ -77,23 +85,25 @@ export const SettingsAuthProviderJest = ({
|
||||
export const SettingsAuthProviderBase = ({
|
||||
children,
|
||||
loadedSettings,
|
||||
loadedProject,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
loadedSettings: Awaited<ReturnType<typeof validateSettings>>
|
||||
loadedSettings: typeof settings
|
||||
loadedProject?: IndexLoaderData
|
||||
}) => {
|
||||
const { settings: initialLoadedContext } = loadedSettings
|
||||
const navigate = useNavigate()
|
||||
const { commandBarSend } = useCommandsContext()
|
||||
|
||||
const [settingsState, settingsSend, settingsActor] = useMachine(
|
||||
settingsMachine,
|
||||
{
|
||||
context: initialLoadedContext,
|
||||
context: loadedSettings,
|
||||
actions: {
|
||||
setClientSideSceneUnits: (context, event) => {
|
||||
const newBaseUnit =
|
||||
event.type === 'Set Base Unit'
|
||||
? event.data.baseUnit
|
||||
: context.baseUnit
|
||||
event.type === 'set.modeling.defaultUnit'
|
||||
? (event.data.value as BaseUnit)
|
||||
: context.modeling.defaultUnit.current
|
||||
sceneInfra.baseUnit = newBaseUnit
|
||||
},
|
||||
setEngineTheme: (context) => {
|
||||
@ -102,39 +112,76 @@ export const SettingsAuthProviderBase = ({
|
||||
type: 'modeling_cmd_req',
|
||||
cmd: {
|
||||
type: 'set_background_color',
|
||||
color: getThemeColorForEngine(context.theme),
|
||||
color: getThemeColorForEngine(context.app.theme.current),
|
||||
},
|
||||
})
|
||||
},
|
||||
toastSuccess: (context, event) => {
|
||||
const truncatedNewValue =
|
||||
'data' in event && event.data instanceof Object
|
||||
? (context[Object.keys(event.data)[0] as keyof typeof context]
|
||||
.toString()
|
||||
.substring(0, 28) as any)
|
||||
: undefined
|
||||
toast.success(
|
||||
event.type +
|
||||
(truncatedNewValue
|
||||
? ` to "${truncatedNewValue}${
|
||||
truncatedNewValue.length === 28 ? '...' : ''
|
||||
}"`
|
||||
: '')
|
||||
)
|
||||
const eventParts = event.type.replace(/^set./, '').split('.') as [
|
||||
keyof typeof settings,
|
||||
string
|
||||
]
|
||||
const truncatedNewValue = event.data.value?.toString().slice(0, 28)
|
||||
const message =
|
||||
`Set ${decamelize(eventParts[1], { separator: ' ' })}` +
|
||||
(truncatedNewValue
|
||||
? ` to "${truncatedNewValue}${
|
||||
truncatedNewValue.length === 28 ? '...' : ''
|
||||
}"${
|
||||
event.data.level === 'project'
|
||||
? ' for this project'
|
||||
: ' as a user default'
|
||||
}`
|
||||
: '')
|
||||
toast.success(message, {
|
||||
duration: message.split(' ').length * 100 + 1500,
|
||||
})
|
||||
},
|
||||
'Execute AST': () => kclManager.executeAst(),
|
||||
persistSettings: (context) =>
|
||||
saveSettings(context, loadedProject?.project?.path),
|
||||
},
|
||||
}
|
||||
)
|
||||
settingsStateRef = settingsState.context
|
||||
|
||||
useStateMachineCommands({
|
||||
machineId: 'settings',
|
||||
state: settingsState,
|
||||
send: settingsSend,
|
||||
commandBarConfig: settingsCommandBarConfig,
|
||||
actor: settingsActor,
|
||||
})
|
||||
// Add settings commands to the command bar
|
||||
// They're treated slightly differently than other commands
|
||||
// Because their state machine doesn't have a meaningful .nextEvents,
|
||||
// and they are configured statically in initialiSettings
|
||||
useEffect(() => {
|
||||
// If the user wants to hide the settings commands
|
||||
//from the command bar don't add them.
|
||||
if (settingsState.context.commandBar.includeSettings.current === false)
|
||||
return
|
||||
|
||||
const commands = settingsWithCommandConfigs(settingsState.context)
|
||||
.map((type) =>
|
||||
createSettingsCommand({
|
||||
type,
|
||||
send: settingsSend,
|
||||
context: settingsState.context,
|
||||
actor: settingsActor,
|
||||
isProjectAvailable: loadedProject !== undefined,
|
||||
})
|
||||
)
|
||||
.filter((c) => c !== null) as Command[]
|
||||
|
||||
commandBarSend({ type: 'Add commands', data: { commands: commands } })
|
||||
|
||||
return () => {
|
||||
commandBarSend({
|
||||
type: 'Remove commands',
|
||||
data: { commands },
|
||||
})
|
||||
}
|
||||
}, [
|
||||
settingsState,
|
||||
settingsSend,
|
||||
settingsActor,
|
||||
commandBarSend,
|
||||
settingsWithCommandConfigs,
|
||||
])
|
||||
|
||||
// Listen for changes to the system theme and update the app theme accordingly
|
||||
// This is only done if the theme setting is set to 'system'.
|
||||
@ -144,7 +191,7 @@ export const SettingsAuthProviderBase = ({
|
||||
useEffect(() => {
|
||||
const matcher = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
const listener = (e: MediaQueryListEvent) => {
|
||||
if (settingsState.context.theme !== 'system') return
|
||||
if (settingsState.context.app.theme.current !== 'system') return
|
||||
setThemeClass(e.matches ? Themes.Dark : Themes.Light)
|
||||
}
|
||||
|
||||
|
@ -106,7 +106,9 @@ export const Stream = ({ className = '' }: { className?: string }) => {
|
||||
style={{ transitionDuration: '200ms', transitionProperty: 'filter' }}
|
||||
id="video-stream"
|
||||
/>
|
||||
<ClientSideScene cameraControls={settings.context?.cameraControls} />
|
||||
<ClientSideScene
|
||||
cameraControls={settings.context.modeling.mouseControls.current}
|
||||
/>
|
||||
{!isNetworkOkay && !isLoading && (
|
||||
<div className="text-center absolute inset-0">
|
||||
<Loading>
|
||||
|
@ -81,7 +81,7 @@ export const TextEditor = ({
|
||||
} = useModelingContext()
|
||||
|
||||
const { settings } = useSettingsAuthContext()
|
||||
const textWrapping = settings.context?.textWrapping ?? 'On'
|
||||
const textWrapping = settings.context.textEditor.textWrapping
|
||||
const { commandBarSend } = useCommandsContext()
|
||||
const { enable: convertEnabled, handleClick: convertCallback } =
|
||||
useConvertToVariable()
|
||||
@ -218,11 +218,11 @@ export const TextEditor = ({
|
||||
],
|
||||
})
|
||||
)
|
||||
if (textWrapping === 'On') extensions.push(EditorView.lineWrapping)
|
||||
if (textWrapping.current) extensions.push(EditorView.lineWrapping)
|
||||
}
|
||||
|
||||
return extensions
|
||||
}, [kclLSP, textWrapping, convertCallback])
|
||||
}, [kclLSP, textWrapping.current, convertCallback])
|
||||
|
||||
return (
|
||||
<div
|
||||
|
@ -1,14 +1,11 @@
|
||||
import { BROWSER_FILE_NAME } from 'Router'
|
||||
import { type IndexLoaderData } from 'lib/types'
|
||||
import { paths } from 'lib/paths'
|
||||
import { BROWSER_PATH, paths } from 'lib/paths'
|
||||
import { useRouteLoaderData } from 'react-router-dom'
|
||||
|
||||
export function useAbsoluteFilePath() {
|
||||
const routeData = useRouteLoaderData(paths.FILE) as IndexLoaderData
|
||||
|
||||
return (
|
||||
paths.FILE +
|
||||
'/' +
|
||||
encodeURIComponent(routeData?.file?.path || BROWSER_FILE_NAME)
|
||||
paths.FILE + '/' + encodeURIComponent(routeData?.file?.path || BROWSER_PATH)
|
||||
)
|
||||
}
|
||||
|
28
src/hooks/useRefreshSettings.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import { useRouteLoaderData } from 'react-router-dom'
|
||||
import { useSettingsAuthContext } from './useSettingsAuthContext'
|
||||
import { paths } from 'lib/paths'
|
||||
import { settings } from 'lib/settings/initialSettings'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
/**
|
||||
* I was dismayed to learn that index route in Router.tsx where we initially load up the settings
|
||||
* doesn't re-run on subsequent navigations. This hook is a workaround,
|
||||
* in conjunction with additional uses of settingsLoader further down the router tree.
|
||||
* @param routeId - The id defined in Router.tsx to load the settings from.
|
||||
*/
|
||||
export function useRefreshSettings(routeId: string = paths.INDEX) {
|
||||
const ctx = useSettingsAuthContext()
|
||||
const routeData = useRouteLoaderData(routeId) as typeof settings
|
||||
|
||||
if (!ctx) {
|
||||
throw new Error(
|
||||
'useRefreshSettings must be used within a SettingsAuthProvider'
|
||||
)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
ctx.settings.send('Set all settings', {
|
||||
settings: routeData,
|
||||
})
|
||||
}, [])
|
||||
}
|
@ -1,33 +0,0 @@
|
||||
import { validateSettings } from 'lib/settings/settingsUtils'
|
||||
import { useEffect } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
import { useRouteLoaderData } from 'react-router-dom'
|
||||
import { useSettingsAuthContext } from './useSettingsAuthContext'
|
||||
import { paths } from 'lib/paths'
|
||||
|
||||
// This hook must only be used within a descendant of the SettingsAuthProvider component
|
||||
// (and, by extension, the Router component).
|
||||
// Specifically it relies on the Router's indexLoader data and the settingsMachine send function.
|
||||
// for the settings and validation errors to be available.
|
||||
export function useValidateSettings() {
|
||||
const {
|
||||
settings: { send },
|
||||
} = useSettingsAuthContext()
|
||||
const { settings, errors } = useRouteLoaderData(paths.INDEX) as Awaited<
|
||||
ReturnType<typeof validateSettings>
|
||||
>
|
||||
|
||||
// If there were validation errors either from local storage or from the file,
|
||||
// log them to the console and show a toast message to the user.
|
||||
useEffect(() => {
|
||||
if (errors.length > 0) {
|
||||
send('Set All Settings', settings)
|
||||
const errorMessage =
|
||||
'Error validating persisted settings: ' +
|
||||
errors.join(', ') +
|
||||
'. Using defaults.'
|
||||
console.error(errorMessage)
|
||||
toast.error(errorMessage)
|
||||
}
|
||||
}, [errors])
|
||||
}
|
@ -19,10 +19,10 @@ root.render(
|
||||
<HotkeysProvider>
|
||||
<Router />
|
||||
<Toaster
|
||||
position="top-center"
|
||||
position="bottom-center"
|
||||
toastOptions={{
|
||||
style: {
|
||||
borderRadius: '0.25rem',
|
||||
borderRadius: '3px',
|
||||
},
|
||||
className:
|
||||
'bg-chalkboard-10 dark:bg-chalkboard-90 text-chalkboard-110 dark:text-chalkboard-10 rounded-sm border-chalkboard-20/50 dark:border-chalkboard-80/50',
|
||||
|