Merge remote-tracking branch 'origin/main' into jess/cleaned-imports

This commit is contained in:
Paul Tagliamonte
2025-04-09 11:17:32 -04:00
44 changed files with 1496 additions and 807 deletions

View File

@ -5,11 +5,18 @@ all: install build check
# INSTALL # INSTALL
ifeq ($(OS),Windows_NT) ifeq ($(OS),Windows_NT)
CARGO ?= ~/.cargo/bin/cargo.exe export WINDOWS := true
WASM_PACK ?= ~/.cargo/bin/wasm-pack.exe ifndef MSYSTEM
export POWERSHELL := true
endif
endif
ifdef WINDOWS
CARGO ?= $(USERPROFILE)/.cargo/bin/cargo.exe
WASM_PACK ?= $(USERPROFILE)/.cargo/bin/wasm-pack.exe
else else
CARGO ?= ~/.cargo/bin/cargo CARGO ?= ~/.cargo/bin/cargo
WASM_PACK ?= ~/.cargo/bin/wasm-pack WASM_PACK ?= ~/.cargo/bin/wasm-pack
endif endif
.PHONY: install .PHONY: install
@ -17,21 +24,21 @@ install: node_modules/.yarn-integrity $(CARGO) $(WASM_PACK) ## Install dependenc
node_modules/.yarn-integrity: package.json yarn.lock node_modules/.yarn-integrity: package.json yarn.lock
yarn install yarn install
ifeq ($(OS),Windows_NT) ifdef POWERSHELL
@ type nul > $@ @ type nul > $@
else else
@ touch $@ @ touch $@
endif endif
$(CARGO): $(CARGO):
ifeq ($(OS),Windows_NT) ifdef WINDOWS
yarn install:rust:windows yarn install:rust:windows
else else
yarn install:rust yarn install:rust
endif endif
$(WASM_PACK): $(WASM_PACK):
ifeq ($(OS),Windows_NT) ifdef WINDOWS
yarn install:wasm-pack:cargo yarn install:wasm-pack:cargo
else else
yarn install:wasm-pack:sh yarn install:wasm-pack:sh
@ -57,7 +64,7 @@ build-web: install public/kcl_wasm_lib_bg.wasm build/index.html
build-desktop: install public/kcl_wasm_lib_bg.wasm .vite/build/main.js build-desktop: install public/kcl_wasm_lib_bg.wasm .vite/build/main.js
public/kcl_wasm_lib_bg.wasm: $(CARGO_SOURCES) $(RUST_SOURCES) public/kcl_wasm_lib_bg.wasm: $(CARGO_SOURCES) $(RUST_SOURCES)
ifeq ($(OS),Windows_NT) ifdef WINDOWS
yarn build:wasm:dev:windows yarn build:wasm:dev:windows
else else
yarn build:wasm:dev yarn build:wasm:dev
@ -140,8 +147,8 @@ endif
.PHONY: clean .PHONY: clean
clean: ## Delete all artifacts clean: ## Delete all artifacts
ifeq ($(OS),Windows_NT) ifdef POWERSHELL
git clean --force -d -X git clean --force -d -x --exclude=.env* --exclude=**/*.env
else else
rm -rf .vite/ build/ rm -rf .vite/ build/
rm -rf trace.zip playwright-report/ test-results/ rm -rf trace.zip playwright-report/ test-results/
@ -152,7 +159,7 @@ endif
.PHONY: help .PHONY: help
help: install help: install
ifeq ($(OS),Windows_NT) ifdef POWERSHELL
@ powershell -Command "Get-Content $(MAKEFILE_LIST) | Select-String -Pattern '^[^\s]+:.*##\s.*$$' | ForEach-Object { $$line = $$_.Line -split ':.*?##\s+'; Write-Host -NoNewline $$line[0].PadRight(30) -ForegroundColor Cyan; Write-Host $$line[1] }" @ powershell -Command "Get-Content $(MAKEFILE_LIST) | Select-String -Pattern '^[^\s]+:.*##\s.*$$' | ForEach-Object { $$line = $$_.Line -split ':.*?##\s+'; Write-Host -NoNewline $$line[0].PadRight(30) -ForegroundColor Cyan; Write-Host $$line[1] }"
else else
@ grep -E '^[^[:space:]]+:.*## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' @ grep -E '^[^[:space:]]+:.*## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'

View File

@ -78,7 +78,7 @@ assertEqual(sum, 6, 0.00001, "1 + 2 + 3 summed is 6")
```js ```js
// Declare a function that sketches a decagon. // Declare a function that sketches a decagon.
fn decagon(radius) { fn decagon(radius) {
// Each side of the decagon is turned this many degrees from the previous angle. // Each side of the decagon is turned this many radians from the previous angle.
stepAngle = 1 / 10 * TAU stepAngle = 1 / 10 * TAU
// Start the decagon sketch at this point. // Start the decagon sketch at this point.

View File

@ -9,7 +9,15 @@ Create a helix.
```js ```js
helix(revolutions: number(_), angleStart: number(deg), ccw?: bool, radius?: number(mm), axis?: Axis3d | Edge, length?: number(mm), cylinder?: Solid): Helix helix(
revolutions: number(_),
angleStart: number(deg),
ccw?: bool,
radius?: number(mm),
axis?: Axis3d | Edge,
length?: number(mm),
cylinder?: Solid,
): Helix
``` ```

View File

@ -6,10 +6,14 @@ layout: manual
Convert polar/sphere (azimuth, elevation, distance) coordinates tocartesian (x/y/z grid) coordinates. Convert polar/sphere (azimuth, elevation, distance) coordinates to
cartesian (x/y/z grid) coordinates.
```js ```js
polar(angle: number(deg), length: number(mm)): [number(mm); 2] polar(
angle: number(deg),
length: number(mm),
): [number(mm); 2]
``` ```

File diff suppressed because one or more lines are too long

View File

@ -18,7 +18,14 @@ You can provide more than one sketch to revolve, and they will all be
revolved around the same axis. revolved around the same axis.
```js ```js
revolve(@sketches: [Sketch; 1+], axis: Axis2d | Edge, angle?: number(deg), tolerance?: number(mm), tagStart?: tag, tagEnd?: tag): Solid revolve(
@sketches: [Sketch; 1+],
axis: Axis2d | Edge,
angle?: number(deg),
tolerance?: number(mm),
tagStart?: tag,
tagEnd?: tag,
): Solid
``` ```

View File

@ -6,10 +6,16 @@ layout: manual
Construct a 2-dimensional circle, of the specified radius, centered atthe provided (x, y) origin point. Construct a 2-dimensional circle, of the specified radius, centered at
the provided (x, y) origin point.
```js ```js
circle(@sketch_or_surface: Sketch | Plane | Face, center: Point2d, radius: number, tag?: tag): Sketch circle(
@sketch_or_surface: Sketch | Plane | Face,
center: Point2d,
radius: number,
tag?: tag,
): Sketch
``` ```

View File

@ -11,7 +11,10 @@ Only works on unclosed sketches for now.
Mirror occurs around a local sketch axis rather than a global axis. Mirror occurs around a local sketch axis rather than a global axis.
```js ```js
mirror2d(@sketches: [Sketch; 1+], axis: Axis2d | Edge): Sketch mirror2d(
@sketches: [Sketch; 1+],
axis: Axis2d | Edge,
): Sketch
``` ```

View File

@ -235702,7 +235702,7 @@
"examples": [ "examples": [
"// This function adds two numbers.\nfn add(a, b) {\n return a + b\n}\n\n// This function adds an array of numbers.\n// It uses the `reduce` function, to call the `add` function on every\n// element of the `arr` parameter. The starting value is 0.\nfn sum(arr) {\n return reduce(arr, 0, add)\n}\n\n/* The above is basically like this pseudo-code:\nfn sum(arr):\n sumSoFar = 0\n for i in arr:\n sumSoFar = add(sumSoFar, i)\n return sumSoFar */\n\n// We use `assertEqual` to check that our `sum` function gives the\n// expected result. It's good to check your work!\nassertEqual(sum([1, 2, 3]), 6, 0.00001, \"1 + 2 + 3 summed is 6\")", "// This function adds two numbers.\nfn add(a, b) {\n return a + b\n}\n\n// This function adds an array of numbers.\n// It uses the `reduce` function, to call the `add` function on every\n// element of the `arr` parameter. The starting value is 0.\nfn sum(arr) {\n return reduce(arr, 0, add)\n}\n\n/* The above is basically like this pseudo-code:\nfn sum(arr):\n sumSoFar = 0\n for i in arr:\n sumSoFar = add(sumSoFar, i)\n return sumSoFar */\n\n// We use `assertEqual` to check that our `sum` function gives the\n// expected result. It's good to check your work!\nassertEqual(sum([1, 2, 3]), 6, 0.00001, \"1 + 2 + 3 summed is 6\")",
"// This example works just like the previous example above, but it uses\n// an anonymous `add` function as its parameter, instead of declaring a\n// named function outside.\narr = [1, 2, 3]\nsum = reduce(arr, 0, fn(i, result_so_far) {\n return i + result_so_far\n})\n\n// We use `assertEqual` to check that our `sum` function gives the\n// expected result. It's good to check your work!\nassertEqual(sum, 6, 0.00001, \"1 + 2 + 3 summed is 6\")", "// This example works just like the previous example above, but it uses\n// an anonymous `add` function as its parameter, instead of declaring a\n// named function outside.\narr = [1, 2, 3]\nsum = reduce(arr, 0, fn(i, result_so_far) {\n return i + result_so_far\n})\n\n// We use `assertEqual` to check that our `sum` function gives the\n// expected result. It's good to check your work!\nassertEqual(sum, 6, 0.00001, \"1 + 2 + 3 summed is 6\")",
"// Declare a function that sketches a decagon.\nfn decagon(radius) {\n // Each side of the decagon is turned this many degrees from the previous angle.\n stepAngle = 1 / 10 * TAU\n\n // Start the decagon sketch at this point.\n startOfDecagonSketch = startSketchOn(XY)\n |> startProfileAt([cos(0) * radius, sin(0) * radius], %)\n\n // Use a `reduce` to draw the remaining decagon sides.\n // For each number in the array 1..10, run the given function,\n // which takes a partially-sketched decagon and adds one more edge to it.\n fullDecagon = reduce([1..10], startOfDecagonSketch, fn(i, partialDecagon) {\n // Draw one edge of the decagon.\n x = cos(stepAngle * i) * radius\n y = sin(stepAngle * i) * radius\n return line(partialDecagon, end = [x, y])\n })\n\n return fullDecagon\n}\n\n/* The `decagon` above is basically like this pseudo-code:\nfn decagon(radius):\n stepAngle = (1/10) * TAU\n plane = startSketchOn('XY')\n startOfDecagonSketch = startProfileAt([(cos(0)*radius), (sin(0) * radius)], plane)\n\n // Here's the reduce part.\n partialDecagon = startOfDecagonSketch\n for i in [1..10]:\n x = cos(stepAngle * i) * radius\n y = sin(stepAngle * i) * radius\n partialDecagon = line(partialDecagon, end = [x, y])\n fullDecagon = partialDecagon // it's now full\n return fullDecagon */\n\n// Use the `decagon` function declared above, to sketch a decagon with radius 5.\ndecagon(5.0)\n |> close()" "// Declare a function that sketches a decagon.\nfn decagon(radius) {\n // Each side of the decagon is turned this many radians from the previous angle.\n stepAngle = 1 / 10 * TAU\n\n // Start the decagon sketch at this point.\n startOfDecagonSketch = startSketchOn(XY)\n |> startProfileAt([cos(0) * radius, sin(0) * radius], %)\n\n // Use a `reduce` to draw the remaining decagon sides.\n // For each number in the array 1..10, run the given function,\n // which takes a partially-sketched decagon and adds one more edge to it.\n fullDecagon = reduce([1..10], startOfDecagonSketch, fn(i, partialDecagon) {\n // Draw one edge of the decagon.\n x = cos(stepAngle * i) * radius\n y = sin(stepAngle * i) * radius\n return line(partialDecagon, end = [x, y])\n })\n\n return fullDecagon\n}\n\n/* The `decagon` above is basically like this pseudo-code:\nfn decagon(radius):\n stepAngle = (1/10) * TAU\n plane = startSketchOn('XY')\n startOfDecagonSketch = startProfileAt([(cos(0)*radius), (sin(0) * radius)], plane)\n\n // Here's the reduce part.\n partialDecagon = startOfDecagonSketch\n for i in [1..10]:\n x = cos(stepAngle * i) * radius\n y = sin(stepAngle * i) * radius\n partialDecagon = line(partialDecagon, end = [x, y])\n fullDecagon = partialDecagon // it's now full\n return fullDecagon */\n\n// Use the `decagon` function declared above, to sketch a decagon with radius 5.\ndecagon(5.0)\n |> close()"
] ]
}, },
{ {

View File

@ -137,7 +137,7 @@ async function doBasicSketch(
await page.waitForTimeout(100) await page.waitForTimeout(100)
} }
await page.getByRole('button', { name: 'Length: open menu' }).click() await page.getByRole('button', { name: 'constraints: open menu' }).click()
await page.getByRole('button', { name: 'Equal Length' }).click() await page.getByRole('button', { name: 'Equal Length' }).click()
// Open the code pane. // Open the code pane.

View File

@ -1353,4 +1353,51 @@ sketch001 = startSketchOn(XZ)
15 15
) )
}) })
test(`test-toolbar-buttons`, async ({
page,
homePage,
toolbar,
scene,
cmdBar,
}) => {
await test.step('Load an empty file', async () => {
await page.addInitScript(async () => {
localStorage.setItem('persistCode', '')
})
await page.setBodyDimensions({ width: 1200, height: 500 })
await homePage.goToModelingScene()
// wait until scene is ready to be interacted with
await scene.connectionEstablished()
await scene.settled(cmdBar)
})
await test.step('Test toolbar button correct selection', async () => {
await toolbar.expectToolbarMode.toBe('modeling')
await toolbar.startSketchPlaneSelection()
// Click on a default plane
await page.mouse.click(700, 200)
// tools cannot be selected immediately, couldn't find an event to await instead.
await page.waitForTimeout(1000)
await toolbar.selectCenterRectangle()
await expect(page.getByTestId('center-rectangle')).toHaveAttribute(
'aria-pressed',
'true'
)
})
await test.step('Test Toolbar dropdown remembering last selection', async () => {
// Select another tool
await page.getByTestId('circle-center').click()
// center-rectangle should still be the active option in the rectangle dropdown
await expect(page.getByTestId('center-rectangle')).toBeVisible()
})
})
}) })

View File

@ -49,7 +49,9 @@ export class SceneFixture {
constructor(page: Page) { constructor(page: Page) {
this.page = page this.page = page
this.streamWrapper = page.getByTestId('stream') this.streamWrapper = page.getByTestId('stream')
this.networkToggleConnected = page.getByTestId('network-toggle-ok') this.networkToggleConnected = page
.getByTestId('network-toggle-ok')
.or(page.getByTestId('network-toggle-other'))
this.startEditSketchBtn = page this.startEditSketchBtn = page
.getByRole('button', { name: 'Start Sketch' }) .getByRole('button', { name: 'Start Sketch' })
.or(page.getByRole('button', { name: 'Edit Sketch' })) .or(page.getByRole('button', { name: 'Edit Sketch' }))

View File

@ -169,7 +169,7 @@ export class ToolbarFixture {
} }
selectCenterRectangle = async () => { selectCenterRectangle = async () => {
await this.page await this.page
.getByRole('button', { name: 'caret down Corner rectangle:' }) .getByRole('button', { name: 'caret down rectangles:' })
.click() .click()
await expect( await expect(
this.page.getByTestId('dropdown-center-rectangle') this.page.getByTestId('dropdown-center-rectangle')
@ -178,7 +178,7 @@ export class ToolbarFixture {
} }
selectBoolean = async (operation: 'union' | 'subtract' | 'intersect') => { selectBoolean = async (operation: 'union' | 'subtract' | 'intersect') => {
await this.page await this.page
.getByRole('button', { name: 'caret down Union: open menu' }) .getByRole('button', { name: 'caret down booleans: open menu' })
.click() .click()
const operationTestId = `dropdown-boolean-${operation}` const operationTestId = `dropdown-boolean-${operation}`
await expect(this.page.getByTestId(operationTestId)).toBeVisible() await expect(this.page.getByTestId(operationTestId)).toBeVisible()
@ -186,25 +186,19 @@ export class ToolbarFixture {
} }
selectCircleThreePoint = async () => { selectCircleThreePoint = async () => {
await this.page await this.page.getByRole('button', { name: 'caret down circles:' }).click()
.getByRole('button', { name: 'caret down Center circle:' })
.click()
await expect( await expect(
this.page.getByTestId('dropdown-circle-three-points') this.page.getByTestId('dropdown-circle-three-points')
).toBeVisible() ).toBeVisible()
await this.page.getByTestId('dropdown-circle-three-points').click() await this.page.getByTestId('dropdown-circle-three-points').click()
} }
selectArc = async () => { selectArc = async () => {
await this.page await this.page.getByRole('button', { name: 'caret down arcs:' }).click()
.getByRole('button', { name: 'caret down Tangential Arc:' })
.click()
await expect(this.page.getByTestId('dropdown-arc')).toBeVisible() await expect(this.page.getByTestId('dropdown-arc')).toBeVisible()
await this.page.getByTestId('dropdown-arc').click() await this.page.getByTestId('dropdown-arc').click()
} }
selectThreePointArc = async () => { selectThreePointArc = async () => {
await this.page await this.page.getByRole('button', { name: 'caret down arcs:' }).click()
.getByRole('button', { name: 'caret down Tangential Arc:' })
.click()
await expect( await expect(
this.page.getByTestId('dropdown-three-point-arc') this.page.getByTestId('dropdown-three-point-arc')
).toBeVisible() ).toBeVisible()

View File

@ -61,6 +61,7 @@ function tomlStringOverWriteNamedViewUuids(toml: string): string {
} }
test.describe('Named view tests', () => { test.describe('Named view tests', () => {
test.skip() // TODO: Jace is working on these
test('Verify project.toml is not created', async ({ page }, testInfo) => { test('Verify project.toml is not created', async ({ page }, testInfo) => {
// Create project and load it // Create project and load it
const projectName = 'named-views' const projectName = 'named-views'

View File

@ -7,6 +7,7 @@ import { expect, test } from '@e2e/playwright/zoo-test'
* Test file menu actions that trigger something in the frontend * Test file menu actions that trigger something in the frontend
*/ */
test.describe('Native file menu', { tag: ['@electron'] }, () => { test.describe('Native file menu', { tag: ['@electron'] }, () => {
test.skip() // TODO: Reimplement native file menu tests
test.describe('Home page', () => { test.describe('Home page', () => {
test.describe('File role', () => { test.describe('File role', () => {
test('Home.File.Create project', async ({ tronApp, cmdBar, page }) => { test('Home.File.Create project', async ({ tronApp, cmdBar, page }) => {

View File

@ -1,13 +1,47 @@
import * as fsp from 'fs/promises' import * as fsp from 'fs/promises'
import path from 'path' import path from 'path'
import { executorInputPath } from '@e2e/playwright/test-utils' import type { CmdBarFixture } from '@e2e/playwright/fixtures/cmdBarFixture'
import { test } from '@e2e/playwright/zoo-test' import type { ToolbarFixture } from '@e2e/playwright/fixtures/toolbarFixture'
import {
executorInputPath,
getUtils,
testsInputPath,
} from '@e2e/playwright/test-utils'
import { expect, test } from '@e2e/playwright/zoo-test'
import type { Page } from '@playwright/test'
async function insertPartIntoAssembly(
path: string,
alias: string,
toolbar: ToolbarFixture,
cmdBar: CmdBarFixture,
page: Page
) {
await toolbar.insertButton.click()
await cmdBar.selectOption({ name: path }).click()
await cmdBar.expectState({
stage: 'arguments',
currentArgKey: 'localName',
currentArgValue: '',
headerArguments: { Path: path, LocalName: '' },
highlightedHeaderArg: 'localName',
commandName: 'Insert',
})
await page.keyboard.insertText(alias)
await cmdBar.progressCmdBar()
await cmdBar.expectState({
stage: 'review',
headerArguments: { Path: path, LocalName: alias },
commandName: 'Insert',
})
await cmdBar.progressCmdBar()
}
// test file is for testing point an click code gen functionality that's assemblies related // test file is for testing point an click code gen functionality that's assemblies related
test.describe('Point-and-click assemblies tests', () => { test.describe('Point-and-click assemblies tests', () => {
test( test(
`Insert kcl part into assembly as whole module import`, `Insert kcl parts into assembly as whole module import`,
{ tag: ['@electron'] }, { tag: ['@electron'] },
async ({ async ({
context, context,
@ -23,11 +57,14 @@ test.describe('Point-and-click assemblies tests', () => {
fail() fail()
} }
// One dumb hardcoded screen pixel value const midPoint = { x: 500, y: 250 }
const testPoint = { x: 575, y: 200 } const partPoint = { x: midPoint.x + 30, y: midPoint.y - 30 } // mid point, just off top right
const initialColor: [number, number, number] = [50, 50, 50] const defaultPlanesColor: [number, number, number] = [180, 220, 180]
const partColor: [number, number, number] = [150, 150, 150] const partColor: [number, number, number] = [100, 100, 100]
const tolerance = 50 const tolerance = 50
const u = await getUtils(page)
const gizmo = page.locator('[aria-label*=gizmo]')
const resetCameraButton = page.getByRole('button', { name: 'Reset view' })
await test.step('Setup parts and expect empty assembly scene', async () => { await test.step('Setup parts and expect empty assembly scene', async () => {
const projectName = 'assembly' const projectName = 'assembly'
@ -36,41 +73,36 @@ test.describe('Point-and-click assemblies tests', () => {
await fsp.mkdir(bracketDir, { recursive: true }) await fsp.mkdir(bracketDir, { recursive: true })
await Promise.all([ await Promise.all([
fsp.copyFile( fsp.copyFile(
executorInputPath('cylinder-inches.kcl'), executorInputPath('cylinder.kcl'),
path.join(bracketDir, 'cylinder.kcl') path.join(bracketDir, 'cylinder.kcl')
), ),
fsp.copyFile( fsp.copyFile(
executorInputPath('e2e-can-sketch-on-chamfer.kcl'), executorInputPath('e2e-can-sketch-on-chamfer.kcl'),
path.join(bracketDir, 'bracket.kcl') path.join(bracketDir, 'bracket.kcl')
), ),
fsp.copyFile(
testsInputPath('cube.step'),
path.join(bracketDir, 'cube.step')
),
fsp.writeFile(path.join(bracketDir, 'main.kcl'), ''), fsp.writeFile(path.join(bracketDir, 'main.kcl'), ''),
]) ])
}) })
await page.setBodyDimensions({ width: 1000, height: 500 }) await page.setBodyDimensions({ width: 1000, height: 500 })
await homePage.openProject(projectName) await homePage.openProject(projectName)
await scene.settled(cmdBar) await scene.settled(cmdBar)
await scene.expectPixelColor(initialColor, testPoint, tolerance) await toolbar.closePane('code')
await scene.expectPixelColor(defaultPlanesColor, midPoint, tolerance)
}) })
await test.step('Insert first part into the assembly', async () => { await test.step('Insert kcl as first part as module', async () => {
await toolbar.insertButton.click() await insertPartIntoAssembly(
await cmdBar.selectOption({ name: 'cylinder.kcl' }).click() 'cylinder.kcl',
await cmdBar.expectState({ 'cylinder',
stage: 'arguments', toolbar,
currentArgKey: 'localName', cmdBar,
currentArgValue: '', page
headerArguments: { Path: 'cylinder.kcl', LocalName: '' }, )
highlightedHeaderArg: 'localName', await toolbar.openPane('code')
commandName: 'Insert',
})
await page.keyboard.insertText('cylinder')
await cmdBar.progressCmdBar()
await cmdBar.expectState({
stage: 'review',
headerArguments: { Path: 'cylinder.kcl', LocalName: 'cylinder' },
commandName: 'Insert',
})
await cmdBar.progressCmdBar()
await editor.expectEditor.toContain( await editor.expectEditor.toContain(
` `
import "cylinder.kcl" as cylinder import "cylinder.kcl" as cylinder
@ -78,28 +110,27 @@ test.describe('Point-and-click assemblies tests', () => {
`, `,
{ shouldNormalise: true } { shouldNormalise: true }
) )
await scene.expectPixelColor(partColor, testPoint, tolerance) await scene.settled(cmdBar)
// Check scene for changes
await toolbar.closePane('code')
await u.doAndWaitForCmd(async () => {
await gizmo.click({ button: 'right' })
await resetCameraButton.click()
}, 'zoom_to_fit')
await toolbar.closePane('debug')
await scene.expectPixelColor(partColor, partPoint, tolerance)
await toolbar.openPane('code')
}) })
await test.step('Insert second part into the assembly', async () => { await test.step('Insert kcl second part as module', async () => {
await toolbar.insertButton.click() await insertPartIntoAssembly(
await cmdBar.selectOption({ name: 'bracket.kcl' }).click() 'bracket.kcl',
await cmdBar.expectState({ 'bracket',
stage: 'arguments', toolbar,
currentArgKey: 'localName', cmdBar,
currentArgValue: '', page
headerArguments: { Path: 'bracket.kcl', LocalName: '' }, )
highlightedHeaderArg: 'localName',
commandName: 'Insert',
})
await page.keyboard.insertText('bracket')
await cmdBar.progressCmdBar()
await cmdBar.expectState({
stage: 'review',
headerArguments: { Path: 'bracket.kcl', LocalName: 'bracket' },
commandName: 'Insert',
})
await cmdBar.progressCmdBar()
await editor.expectEditor.toContain( await editor.expectEditor.toContain(
` `
import "cylinder.kcl" as cylinder import "cylinder.kcl" as cylinder
@ -109,6 +140,152 @@ test.describe('Point-and-click assemblies tests', () => {
`, `,
{ shouldNormalise: true } { shouldNormalise: true }
) )
await scene.settled(cmdBar)
})
await test.step('Insert a second time and expect error', async () => {
// TODO: revisit once we have clone with #6209
await insertPartIntoAssembly(
'bracket.kcl',
'bracket',
toolbar,
cmdBar,
page
)
await editor.expectEditor.toContain(
`
import "cylinder.kcl" as cylinder
import "bracket.kcl" as bracket
import "bracket.kcl" as bracket
cylinder
bracket
bracket
`,
{ shouldNormalise: true }
)
await scene.settled(cmdBar)
await expect(page.locator('.cm-lint-marker-error')).toBeVisible()
})
}
)
test(
`Insert foreign parts into assembly as whole module import`,
{ tag: ['@electron'] },
async ({
context,
page,
homePage,
scene,
editor,
toolbar,
cmdBar,
tronApp,
}) => {
if (!tronApp) {
fail()
}
const midPoint = { x: 500, y: 250 }
const partPoint = { x: midPoint.x + 30, y: midPoint.y - 30 } // mid point, just off top right
const defaultPlanesColor: [number, number, number] = [180, 220, 180]
const partColor: [number, number, number] = [150, 150, 150]
const tolerance = 50
const complexPlmFileName = 'cube_Complex-PLM_Name_-001.sldprt'
const camelCasedSolidworksFileName = 'cubeComplexPLMName001'
await test.step('Setup parts and expect empty assembly scene', async () => {
const projectName = 'assembly'
await context.folderSetupFn(async (dir) => {
const bracketDir = path.join(dir, projectName)
await fsp.mkdir(bracketDir, { recursive: true })
await Promise.all([
fsp.copyFile(
testsInputPath('cube.step'),
path.join(bracketDir, 'cube.step')
),
fsp.copyFile(
testsInputPath('cube.sldprt'),
path.join(bracketDir, complexPlmFileName)
),
fsp.writeFile(path.join(bracketDir, 'main.kcl'), ''),
])
})
await page.setBodyDimensions({ width: 1000, height: 500 })
await homePage.openProject(projectName)
await scene.settled(cmdBar)
await toolbar.closePane('code')
await scene.expectPixelColor(defaultPlanesColor, midPoint, tolerance)
})
await test.step('Insert step part as module', async () => {
await insertPartIntoAssembly('cube.step', 'cube', toolbar, cmdBar, page)
await toolbar.openPane('code')
await editor.expectEditor.toContain(
`
import "cube.step" as cube
cube
`,
{ shouldNormalise: true }
)
await scene.settled(cmdBar)
// TODO: remove this once #5780 is fixed
await page.reload()
await scene.settled(cmdBar)
await expect(page.locator('.cm-lint-marker-error')).not.toBeVisible()
await toolbar.closePane('code')
await scene.expectPixelColor(partColor, partPoint, tolerance)
})
await test.step('Insert second step part by clicking', async () => {
await toolbar.openPane('files')
await toolbar.expectFileTreeState([
complexPlmFileName,
'cube.step',
'main.kcl',
])
await toolbar.openFile(complexPlmFileName)
// Go through the ToastInsert prompt
await page.getByText('Insert into my current file').click()
// Check getPathFilenameInVariableCase output
const parsedValueFromFile =
await cmdBar.currentArgumentInput.inputValue()
expect(parsedValueFromFile).toEqual(camelCasedSolidworksFileName)
// Continue on with the flow
await page.keyboard.insertText('cubeSw')
await cmdBar.progressCmdBar()
await cmdBar.expectState({
stage: 'review',
headerArguments: { Path: complexPlmFileName, LocalName: 'cubeSw' },
commandName: 'Insert',
})
await cmdBar.progressCmdBar()
await toolbar.closePane('files')
await toolbar.openPane('code')
await editor.expectEditor.toContain(
`
import "cube.step" as cube
import "${complexPlmFileName}" as cubeSw
cube
cubeSw
`,
{ shouldNormalise: true }
)
await scene.settled(cmdBar)
// TODO: remove this once #5780 is fixed
await page.reload()
await scene.settled(cmdBar)
await expect(page.locator('.cm-lint-marker-error')).not.toBeVisible()
await toolbar.closePane('code')
await scene.expectPixelColor(partColor, partPoint, tolerance)
}) })
} }
) )

View File

@ -1021,6 +1021,10 @@ export function executorInputPath(fileName: string): string {
return path.join('rust', 'kcl-lib', 'e2e', 'executor', 'inputs', fileName) return path.join('rust', 'kcl-lib', 'e2e', 'executor', 'inputs', fileName)
} }
export function testsInputPath(fileName: string): string {
return path.join('rust', 'kcl-lib', 'tests', 'inputs', fileName)
}
export async function doAndWaitForImageDiff( export async function doAndWaitForImageDiff(
page: Page, page: Page,
fn: () => Promise<unknown>, fn: () => Promise<unknown>,

View File

@ -115,7 +115,7 @@ test.describe('Testing constraints', { tag: ['@skipWin'] }, () => {
await page.waitForTimeout(100) // this wait is needed for webkit - not sure why await page.waitForTimeout(100) // this wait is needed for webkit - not sure why
await page await page
.getByRole('button', { .getByRole('button', {
name: 'Length: open menu', name: 'constraints: open menu',
}) })
.click() .click()
await page.getByRole('button', { name: 'remove constraints' }).click() await page.getByRole('button', { name: 'remove constraints' }).click()
@ -189,7 +189,7 @@ test.describe('Testing constraints', { tag: ['@skipWin'] }, () => {
await page.waitForTimeout(100) await page.waitForTimeout(100)
await page await page
.getByRole('button', { .getByRole('button', {
name: 'Length: open menu', name: 'constraints: open menu',
}) })
.click() .click()
await page await page
@ -299,7 +299,7 @@ test.describe('Testing constraints', { tag: ['@skipWin'] }, () => {
await page.keyboard.up('Shift') await page.keyboard.up('Shift')
await page await page
.getByRole('button', { .getByRole('button', {
name: 'Length: open menu', name: 'constraints: open menu',
}) })
.click() .click()
await page.getByRole('button', { name: constraint }).click() await page.getByRole('button', { name: constraint }).click()
@ -420,7 +420,7 @@ test.describe('Testing constraints', { tag: ['@skipWin'] }, () => {
await page.waitForTimeout(100) await page.waitForTimeout(100)
await page await page
.getByRole('button', { .getByRole('button', {
name: 'Length: open menu', name: 'constraints: open menu',
}) })
.click() .click()
await page await page
@ -533,7 +533,7 @@ test.describe('Testing constraints', { tag: ['@skipWin'] }, () => {
await page.keyboard.up('Shift') await page.keyboard.up('Shift')
await page await page
.getByRole('button', { .getByRole('button', {
name: 'Length: open menu', name: 'constraints: open menu',
}) })
.click() .click()
await page.getByTestId('dropdown-constraint-angle').click() await page.getByTestId('dropdown-constraint-angle').click()
@ -627,7 +627,7 @@ test.describe('Testing constraints', { tag: ['@skipWin'] }, () => {
await page.mouse.click(line3.x, line3.y) await page.mouse.click(line3.x, line3.y)
await page await page
.getByRole('button', { .getByRole('button', {
name: 'Length: open menu', name: 'constraints: open menu',
}) })
.click() .click()
await page.getByTestId('dropdown-constraint-' + constraint).click() await page.getByTestId('dropdown-constraint-' + constraint).click()
@ -719,7 +719,7 @@ part002 = startSketchOn(XZ)
await page.mouse.click(line3.x, line3.y) await page.mouse.click(line3.x, line3.y)
await page await page
.getByRole('button', { .getByRole('button', {
name: 'Length: open menu', name: 'constraints: open menu',
}) })
.click() .click()
await page.getByTestId('dropdown-constraint-' + constraint).click() await page.getByTestId('dropdown-constraint-' + constraint).click()
@ -817,7 +817,7 @@ part002 = startSketchOn(XZ)
const activeLinesContent = await page.locator('.cm-activeLine').all() const activeLinesContent = await page.locator('.cm-activeLine').all()
const constraintMenuButton = page.getByRole('button', { const constraintMenuButton = page.getByRole('button', {
name: 'Length: open menu', name: 'constraints: open menu',
}) })
const constraintButton = page const constraintButton = page
.getByRole('button', { .getByRole('button', {
@ -905,7 +905,7 @@ part002 = startSketchOn(XZ)
await page.mouse.click(line3.x - 3, line3.y + 20) await page.mouse.click(line3.x - 3, line3.y + 20)
await page.keyboard.up('Shift') await page.keyboard.up('Shift')
const constraintMenuButton = page.getByRole('button', { const constraintMenuButton = page.getByRole('button', {
name: 'Length: open menu', name: 'constraints: open menu',
}) })
const constraintButton = page.getByRole('button', { const constraintButton = page.getByRole('button', {
name: constraintName, name: constraintName,
@ -990,7 +990,7 @@ part002 = startSketchOn(XZ)
await page.keyboard.up('Shift') await page.keyboard.up('Shift')
await page.waitForTimeout(100) await page.waitForTimeout(100)
const constraintMenuButton = page.getByRole('button', { const constraintMenuButton = page.getByRole('button', {
name: 'Length: open menu', name: 'constraints: open menu',
}) })
const constraintButton = page.getByRole('button', { const constraintButton = page.getByRole('button', {
name: constraintName, name: constraintName,
@ -1057,7 +1057,7 @@ part002 = startSketchOn(XZ)
await page await page
.getByRole('button', { .getByRole('button', {
name: 'Length: open menu', name: 'constraints: open menu',
}) })
.click() .click()
await page.waitForTimeout(500) await page.waitForTimeout(500)

View File

@ -124,7 +124,7 @@ test.describe('Testing selections', { tag: ['@skipWin'] }, () => {
// click a segment hold shift and click an axis, see that a relevant constraint is enabled // click a segment hold shift and click an axis, see that a relevant constraint is enabled
const constrainButton = page.getByRole('button', { const constrainButton = page.getByRole('button', {
name: 'Length: open menu', name: 'constraints: open menu',
}) })
const absXButton = page.getByRole('button', { name: 'Absolute X' }) const absXButton = page.getByRole('button', { name: 'Absolute X' })

View File

@ -1,4 +1,4 @@
use std::{collections::HashSet, str::FromStr}; use std::{collections::HashSet, fmt, str::FromStr};
use regex::Regex; use regex::Regex;
use tower_lsp::lsp_types::{ use tower_lsp::lsp_types::{
@ -389,21 +389,23 @@ impl FnData {
pub fn fn_signature(&self) -> String { pub fn fn_signature(&self) -> String {
let mut signature = String::new(); let mut signature = String::new();
if self.args.is_empty() {
signature.push_str("()");
} else if self.args.len() == 1 {
signature.push('('); signature.push('(');
for (i, arg) in self.args.iter().enumerate() { signature.push_str(&self.args[0].to_string());
if i > 0 {
signature.push_str(", ");
}
match &arg.kind {
ArgKind::Special => signature.push_str(&format!("@{}", arg.name)),
ArgKind::Labelled(false) => signature.push_str(&arg.name),
ArgKind::Labelled(true) => signature.push_str(&format!("{}?", arg.name)),
}
if let Some(ty) = &arg.ty {
signature.push_str(&format!(": {ty}"));
}
}
signature.push(')'); signature.push(')');
} else {
signature.push('(');
for a in &self.args {
signature.push_str("\n ");
signature.push_str(&a.to_string());
signature.push(',');
}
signature.push('\n');
signature.push(')');
}
if let Some(ty) = &self.return_type { if let Some(ty) = &self.return_type {
signature.push_str(&format!(": {ty}")); signature.push_str(&format!(": {ty}"));
} }
@ -515,6 +517,20 @@ pub struct ArgData {
pub docs: Option<String>, pub docs: Option<String>,
} }
impl fmt::Display for ArgData {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match &self.kind {
ArgKind::Special => write!(f, "@{}", self.name)?,
ArgKind::Labelled(false) => f.write_str(&self.name)?,
ArgKind::Labelled(true) => write!(f, "{}?", self.name)?,
}
if let Some(ty) = &self.ty {
write!(f, ": {ty}")?;
}
Ok(())
}
}
#[derive(Debug, Clone, Copy, PartialEq)] #[derive(Debug, Clone, Copy, PartialEq)]
pub enum ArgKind { pub enum ArgKind {
Special, Special,
@ -766,8 +782,8 @@ trait ApplyMeta {
description = summary; description = summary;
summary = None; summary = None;
let d = description.as_mut().unwrap(); let d = description.as_mut().unwrap();
d.push_str(l);
d.push('\n'); d.push('\n');
d.push_str(l);
} }
continue; continue;
} }

View File

@ -360,15 +360,6 @@ impl KclValue {
result result
} }
/// Put the number into a KCL value.
pub const fn from_number(f: f64, meta: Vec<Metadata>) -> Self {
Self::Number {
value: f,
meta,
ty: NumericType::Unknown,
}
}
pub const fn from_number_with_type(f: f64, ty: NumericType, meta: Vec<Metadata>) -> Self { pub const fn from_number_with_type(f: f64, ty: NumericType, meta: Vec<Metadata>) -> Self {
Self::Number { value: f, meta, ty } Self::Number { value: f, meta, ty }
} }

View File

@ -19,7 +19,7 @@ use crate::{
}; };
lazy_static::lazy_static! { lazy_static::lazy_static! {
pub(super) static ref CHECK_NUMERIC_TYPES: bool = { pub(crate) static ref CHECK_NUMERIC_TYPES: bool = {
let env_var = std::env::var("ZOO_NUM_TYS"); let env_var = std::env::var("ZOO_NUM_TYS");
let Ok(env_var) = env_var else { let Ok(env_var) = env_var else {
return false; return false;
@ -416,6 +416,80 @@ impl NumericType {
} }
} }
pub fn combine_eq_array(input: &[TyF64]) -> (Vec<f64>, NumericType) {
use NumericType::*;
let mut result = input.iter().map(|t| t.n).collect();
let mut ty = Any;
// Invariant mismatch is true => ty is Known
let mut mismatch = false;
for i in input {
if i.ty == Any || ty == i.ty {
continue;
}
match (&ty, &i.ty) {
(Any, t) => {
ty = t.clone();
}
(_, Unknown) | (Default { .. }, Default { .. }) => return (result, Unknown),
// Known types and compatible, but needs adjustment.
(Known(UnitType::Length(_)), Known(UnitType::Length(_)))
| (Known(UnitType::Angle(_)), Known(UnitType::Angle(_))) => {
mismatch = true;
}
// Known but incompatible.
(Known(_), Known(_)) => return (result, Unknown),
// Known and unknown, no adjustment for counting numbers.
(Known(UnitType::Count), Default { .. }) | (Default { .. }, Known(UnitType::Count)) => {
ty = Known(UnitType::Count);
}
(Known(UnitType::Length(l1)), Default { len: l2, .. }) => {
mismatch |= l1 != l2;
}
(Known(UnitType::Angle(a1)), Default { angle: a2, .. }) => {
mismatch |= a1 != a2;
}
(Default { len: l1, .. }, Known(UnitType::Length(l2))) => {
mismatch |= l1 != l2;
ty = Known(UnitType::Length(*l2));
}
(Default { angle: a1, .. }, Known(UnitType::Angle(a2))) => {
mismatch |= a1 != a2;
ty = Known(UnitType::Angle(*a2));
}
(Unknown, _) | (_, Any) => unreachable!(),
}
}
if !mismatch {
return (result, ty);
}
result = result
.into_iter()
.zip(input)
.map(|(n, i)| match (&ty, &i.ty) {
(Known(UnitType::Length(l1)), Known(UnitType::Length(l2)) | Default { len: l2, .. }) => {
l2.adjust_to(n, *l1)
}
(Known(UnitType::Angle(a1)), Known(UnitType::Angle(a2)) | Default { angle: a2, .. }) => {
a2.adjust_to(n, *a1)
}
_ => unreachable!(),
})
.collect();
(result, ty)
}
/// Combine two types for multiplication-like operations. /// Combine two types for multiplication-like operations.
pub fn combine_mul(a: TyF64, b: TyF64) -> (f64, f64, NumericType) { pub fn combine_mul(a: TyF64, b: TyF64) -> (f64, f64, NumericType) {
use NumericType::*; use NumericType::*;
@ -621,7 +695,7 @@ pub enum UnitLen {
impl UnitLen { impl UnitLen {
fn adjust_to(self, value: f64, to: UnitLen) -> f64 { fn adjust_to(self, value: f64, to: UnitLen) -> f64 {
if self == to { if !*CHECK_NUMERIC_TYPES || self == to {
return value; return value;
} }
@ -734,6 +808,11 @@ impl UnitAngle {
fn adjust_to(self, value: f64, to: UnitAngle) -> f64 { fn adjust_to(self, value: f64, to: UnitAngle) -> f64 {
use std::f64::consts::PI; use std::f64::consts::PI;
use UnitAngle::*; use UnitAngle::*;
if !*CHECK_NUMERIC_TYPES {
return value;
}
match (self, to) { match (self, to) {
(Degrees, Degrees) => value, (Degrees, Degrees) => value,
(Degrees, Radians) => (value / 180.0) * PI, (Degrees, Radians) => (value / 180.0) * PI,
@ -1847,11 +1926,16 @@ n = 10inch / 2mm
o = 3mm / 3 o = 3mm / 3
p = 3_ / 4 p = 3_ / 4
q = 4inch / 2_ q = 4inch / 2_
r = min(0, 3, 42)
s = min(0, 3mm, -42)
t = min(100, 3in, 142mm)
u = min(3rad, 4in)
"#; "#;
let result = parse_execute(program).await.unwrap(); let result = parse_execute(program).await.unwrap();
if *CHECK_NUMERIC_TYPES { if *CHECK_NUMERIC_TYPES {
assert_eq!(result.exec_state.errors().len(), 2); assert_eq!(result.exec_state.errors().len(), 3);
} else { } else {
assert!(result.exec_state.errors().is_empty()); assert!(result.exec_state.errors().is_empty());
} }
@ -1861,7 +1945,9 @@ q = 4inch / 2_
assert_value_and_type("c", &result, 13.0, NumericType::mm()); assert_value_and_type("c", &result, 13.0, NumericType::mm());
assert_value_and_type("d", &result, 13.0, NumericType::mm()); assert_value_and_type("d", &result, 13.0, NumericType::mm());
assert_value_and_type("e", &result, 13.0, NumericType::mm()); assert_value_and_type("e", &result, 13.0, NumericType::mm());
if *CHECK_NUMERIC_TYPES {
assert_value_and_type("f", &result, 5.0, NumericType::mm()); assert_value_and_type("f", &result, 5.0, NumericType::mm());
}
assert_value_and_type("g", &result, 20.0, NumericType::default()); assert_value_and_type("g", &result, 20.0, NumericType::default());
assert_value_and_type("h", &result, 20.0, NumericType::mm()); assert_value_and_type("h", &result, 20.0, NumericType::mm());
@ -1871,9 +1957,30 @@ q = 4inch / 2_
assert_value_and_type("l", &result, 0.0, NumericType::count()); assert_value_and_type("l", &result, 0.0, NumericType::count());
assert_value_and_type("m", &result, 2.0, NumericType::count()); assert_value_and_type("m", &result, 2.0, NumericType::count());
if *CHECK_NUMERIC_TYPES {
assert_value_and_type("n", &result, 127.0, NumericType::count()); assert_value_and_type("n", &result, 127.0, NumericType::count());
}
assert_value_and_type("o", &result, 1.0, NumericType::mm()); assert_value_and_type("o", &result, 1.0, NumericType::mm());
assert_value_and_type("p", &result, 1.0, NumericType::count()); assert_value_and_type("p", &result, 1.0, NumericType::count());
assert_value_and_type("q", &result, 2.0, NumericType::Known(UnitType::Length(UnitLen::Inches))); assert_value_and_type("q", &result, 2.0, NumericType::Known(UnitType::Length(UnitLen::Inches)));
assert_value_and_type("r", &result, 0.0, NumericType::default());
assert_value_and_type("s", &result, -42.0, NumericType::mm());
assert_value_and_type("t", &result, 3.0, NumericType::Known(UnitType::Length(UnitLen::Inches)));
assert_value_and_type("u", &result, 3.0, NumericType::Unknown);
}
#[tokio::test(flavor = "multi_thread")]
async fn bad_typed_arithmetic() {
let program = r#"
a = 1rad
b = 180 / PI * a + 360
"#;
let result = parse_execute(program).await.unwrap();
assert_value_and_type("a", &result, 1.0, NumericType::radians());
// TODO type is not ideal
assert_value_and_type("b", &result, 417.0, NumericType::radians());
} }
} }

View File

@ -523,15 +523,6 @@ impl Args {
}) })
} }
pub(super) fn make_user_val_from_f64(&self, f: f64) -> KclValue {
KclValue::from_number(
f,
vec![Metadata {
source_range: self.source_range,
}],
)
}
pub(super) fn make_user_val_from_f64_with_type(&self, f: TyF64) -> KclValue { pub(super) fn make_user_val_from_f64_with_type(&self, f: TyF64) -> KclValue {
KclValue::from_number_with_type( KclValue::from_number_with_type(
f.n, f.n,

View File

@ -133,7 +133,7 @@ pub async fn reduce(exec_state: &mut ExecState, args: Args) -> Result<KclValue,
/// ```no_run /// ```no_run
/// // Declare a function that sketches a decagon. /// // Declare a function that sketches a decagon.
/// fn decagon(radius) { /// fn decagon(radius) {
/// // Each side of the decagon is turned this many degrees from the previous angle. /// // Each side of the decagon is turned this many radians from the previous angle.
/// stepAngle = (1/10) * TAU /// stepAngle = (1/10) * TAU
/// ///
/// // Start the decagon sketch at this point. /// // Start the decagon sketch at this point.

View File

@ -6,18 +6,31 @@ use kcl_derive_docs::stdlib;
use super::args::FromArgs; use super::args::FromArgs;
use crate::{ use crate::{
errors::{KclError, KclErrorDetails}, errors::{KclError, KclErrorDetails},
execution::{types::NumericType, ExecState, KclValue}, execution::{
types::{self, NumericType},
ExecState, KclValue,
},
std::args::{Args, TyF64}, std::args::{Args, TyF64},
CompilationError,
}; };
/// Compute the remainder after dividing `num` by `div`. /// Compute the remainder after dividing `num` by `div`.
/// If `num` is negative, the result will be too. /// If `num` is negative, the result will be too.
pub async fn rem(_exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> { pub async fn rem(exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
let n = args.get_unlabeled_kw_arg("number to divide")?; let n: TyF64 = args.get_unlabeled_kw_arg("number to divide")?;
let d = args.get_kw_arg("divisor")?; let d: TyF64 = args.get_kw_arg("divisor")?;
let (n, d, ty) = NumericType::combine_div(n, d);
if *types::CHECK_NUMERIC_TYPES && ty == NumericType::Unknown {
// TODO suggest how to fix this
exec_state.warn(CompilationError::err(
args.source_range,
"Remainder of numbers which have unknown or incompatible units.",
));
}
let remainder = inner_rem(n, d); let remainder = inner_rem(n, d);
Ok(args.make_user_val_from_f64(remainder)) Ok(args.make_user_val_from_f64_with_type(TyF64::new(remainder, ty)))
} }
/// Compute the remainder after dividing `num` by `div`. /// Compute the remainder after dividing `num` by `div`.
@ -243,11 +256,19 @@ fn inner_ceil(num: f64) -> Result<f64, KclError> {
} }
/// Compute the minimum of the given arguments. /// Compute the minimum of the given arguments.
pub async fn min(_exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> { pub async fn min(exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
let nums = args.get_number_array()?; let nums = args.get_number_array_with_types()?;
let (nums, ty) = NumericType::combine_eq_array(&nums);
if *types::CHECK_NUMERIC_TYPES && ty == NumericType::Unknown {
// TODO suggest how to fix this
exec_state.warn(CompilationError::err(
args.source_range,
"Calling `min` on numbers which have unknown or incompatible units.",
));
}
let result = inner_min(nums); let result = inner_min(nums);
Ok(args.make_user_val_from_f64(result)) Ok(args.make_user_val_from_f64_with_type(TyF64::new(result, ty)))
} }
/// Compute the minimum of the given arguments. /// Compute the minimum of the given arguments.
@ -280,11 +301,19 @@ fn inner_min(args: Vec<f64>) -> f64 {
} }
/// Compute the maximum of the given arguments. /// Compute the maximum of the given arguments.
pub async fn max(_exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> { pub async fn max(exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
let nums = args.get_number_array()?; let nums = args.get_number_array_with_types()?;
let (nums, ty) = NumericType::combine_eq_array(&nums);
if *types::CHECK_NUMERIC_TYPES && ty == NumericType::Unknown {
// TODO suggest how to fix this
exec_state.warn(CompilationError::err(
args.source_range,
"Calling `max` on numbers which have unknown or incompatible units.",
));
}
let result = inner_max(nums); let result = inner_max(nums);
Ok(args.make_user_val_from_f64(result)) Ok(args.make_user_val_from_f64_with_type(TyF64::new(result, ty)))
} }
/// Compute the maximum of the given arguments. /// Compute the maximum of the given arguments.

View File

@ -69,7 +69,7 @@ export fn cos(@num: number(rad)): number(_) {}
/// |> startProfileAt([0, 0], %) /// |> startProfileAt([0, 0], %)
/// |> angledLine({ /// |> angledLine({
/// angle = 50, /// angle = 50,
/// length = 15 / sin(toDegrees(135)), /// length = 15 / sin(toRadians(135)),
/// }, %) /// }, %)
/// |> yLine(endAbsolute = 0) /// |> yLine(endAbsolute = 0)
/// |> close() /// |> close()

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 58 KiB

View File

@ -17,12 +17,14 @@ import { isDesktop } from '@src/lib/isDesktop'
import { openExternalBrowserIfDesktop } from '@src/lib/openWindow' import { openExternalBrowserIfDesktop } from '@src/lib/openWindow'
import { editorManager, kclManager } from '@src/lib/singletons' import { editorManager, kclManager } from '@src/lib/singletons'
import type { import type {
ToolbarDropdown,
ToolbarItem, ToolbarItem,
ToolbarItemCallbackProps, ToolbarItemCallbackProps,
ToolbarItemResolved, ToolbarItemResolved,
ToolbarItemResolvedDropdown,
ToolbarModeName, ToolbarModeName,
} from '@src/lib/toolbar' } from '@src/lib/toolbar'
import { toolbarConfig } from '@src/lib/toolbar' import { isToolbarItemResolvedDropdown, toolbarConfig } from '@src/lib/toolbar'
import { isArray } from '@src/lib/utils' import { isArray } from '@src/lib/utils'
import { commandBarActor } from '@src/machines/commandBarMachine' import { commandBarActor } from '@src/machines/commandBarMachine'
@ -131,21 +133,27 @@ export function Toolbar({
*/ */
const currentModeItems: ( const currentModeItems: (
| ToolbarItemResolved | ToolbarItemResolved
| ToolbarItemResolved[] | ToolbarItemResolvedDropdown
| 'break' | 'break'
)[] = useMemo(() => { )[] = useMemo(() => {
return toolbarConfig[currentMode].items.map((maybeIconConfig) => { return toolbarConfig[currentMode].items.map((maybeIconConfig) => {
if (maybeIconConfig === 'break') { if (maybeIconConfig === 'break') {
return 'break' return 'break'
} else if (isArray(maybeIconConfig)) { } else if (isToolbarDropdown(maybeIconConfig)) {
return maybeIconConfig.map(resolveItemConfig) return {
id: maybeIconConfig.id,
array: maybeIconConfig.array.map((item) =>
resolveItemConfig(item, maybeIconConfig.id)
),
}
} else { } else {
return resolveItemConfig(maybeIconConfig) return resolveItemConfig(maybeIconConfig)
} }
}) })
function resolveItemConfig( function resolveItemConfig(
maybeIconConfig: ToolbarItem maybeIconConfig: ToolbarItem,
dropdownId?: string
): ToolbarItemResolved { ): ToolbarItemResolved {
const isDisabled = const isDisabled =
disableAllButtons || disableAllButtons ||
@ -176,6 +184,14 @@ export function Toolbar({
} }
}, [currentMode, disableAllButtons, configCallbackProps]) }, [currentMode, disableAllButtons, configCallbackProps])
// To remember the last selected item in an ActionButtonDropdown
const [lastSelectedMultiActionItem, _] = useState(
new Map<
number /* index in currentModeItems */,
number /* index in maybeIconConfig */
>()
)
return ( return (
<menu <menu
data-current-mode={currentMode} data-current-mode={currentMode}
@ -199,24 +215,33 @@ export function Toolbar({
className="h-5 w-[1px] block bg-chalkboard-30 dark:bg-chalkboard-80" className="h-5 w-[1px] block bg-chalkboard-30 dark:bg-chalkboard-80"
/> />
) )
} else if (isArray(maybeIconConfig)) { } else if (isToolbarItemResolvedDropdown(maybeIconConfig)) {
// A button with a dropdown // A button with a dropdown
const selectedIcon =
maybeIconConfig.array.find((c) => c.isActive) ||
maybeIconConfig.array[lastSelectedMultiActionItem.get(i) ?? 0]
// Save the last selected item in the dropdown
lastSelectedMultiActionItem.set(
i,
maybeIconConfig.array.indexOf(selectedIcon)
)
return ( return (
<ActionButtonDropdown <ActionButtonDropdown
Element="button" Element="button"
key={maybeIconConfig[0].id} key={selectedIcon.id}
data-testid={maybeIconConfig[0].id + '-dropdown'} data-testid={selectedIcon.id + '-dropdown'}
id={maybeIconConfig[0].id + '-dropdown'} id={selectedIcon.id + '-dropdown'}
name={maybeIconConfig[0].title} name={maybeIconConfig.id}
className={ className={
(maybeIconConfig[0].alwaysDark (maybeIconConfig.array[0].alwaysDark
? 'dark bg-chalkboard-90 ' ? 'dark bg-chalkboard-90 '
: '!bg-transparent ') + : '!bg-transparent ') +
'group/wrapper ' + 'group/wrapper ' +
buttonBorderClassName + buttonBorderClassName +
' relative group !gap-0' ' relative group !gap-0'
} }
splitMenuItems={maybeIconConfig.map((itemConfig) => ({ splitMenuItems={maybeIconConfig.array.map((itemConfig) => ({
id: itemConfig.id, id: itemConfig.id,
label: itemConfig.title, label: itemConfig.title,
hotkey: itemConfig.hotkey, hotkey: itemConfig.hotkey,
@ -236,11 +261,11 @@ export function Toolbar({
> >
<ActionButton <ActionButton
Element="button" Element="button"
id={maybeIconConfig[0].id} id={selectedIcon.id}
data-testid={maybeIconConfig[0].id} data-testid={selectedIcon.id}
iconStart={{ iconStart={{
icon: maybeIconConfig[0].icon, icon: selectedIcon.icon,
iconColor: maybeIconConfig[0].iconColor, iconColor: selectedIcon.iconColor,
className: iconClassName, className: iconClassName,
bgClassName: bgClassName, bgClassName: bgClassName,
}} }}
@ -248,40 +273,36 @@ export function Toolbar({
'!border-transparent !px-0 pressed:!text-chalkboard-10 pressed:enabled:hovered:!text-chalkboard-10 ' + '!border-transparent !px-0 pressed:!text-chalkboard-10 pressed:enabled:hovered:!text-chalkboard-10 ' +
buttonBgClassName buttonBgClassName
} }
aria-pressed={maybeIconConfig[0].isActive} aria-pressed={selectedIcon.isActive}
disabled={ disabled={
disableAllButtons || disableAllButtons ||
maybeIconConfig[0].status !== 'available' || selectedIcon.status !== 'available' ||
maybeIconConfig[0].disabled selectedIcon.disabled
} }
name={maybeIconConfig[0].title} name={selectedIcon.title}
// aria-description is still in ARIA 1.3 draft. // aria-description is still in ARIA 1.3 draft.
// eslint-disable-next-line jsx-a11y/aria-props // eslint-disable-next-line jsx-a11y/aria-props
aria-description={maybeIconConfig[0].description} aria-description={selectedIcon.description}
onClick={() => onClick={() => selectedIcon.onClick(configCallbackProps)}
maybeIconConfig[0].onClick(configCallbackProps)
}
> >
<span <span className={!selectedIcon.showTitle ? 'sr-only' : ''}>
className={!maybeIconConfig[0].showTitle ? 'sr-only' : ''} {selectedIcon.title}
>
{maybeIconConfig[0].title}
</span> </span>
<ToolbarItemTooltip <ToolbarItemTooltip
itemConfig={maybeIconConfig[0]} itemConfig={selectedIcon}
configCallbackProps={configCallbackProps} configCallbackProps={configCallbackProps}
wrapperClassName="ui-open:!hidden" wrapperClassName="ui-open:!hidden"
contentClassName={tooltipContentClassName} contentClassName={tooltipContentClassName}
> >
{showRichContent ? ( {showRichContent ? (
<ToolbarItemTooltipRichContent <ToolbarItemTooltipRichContent
itemConfig={maybeIconConfig[0]} itemConfig={selectedIcon}
/> />
) : ( ) : (
<ToolbarItemTooltipShortContent <ToolbarItemTooltipShortContent
status={maybeIconConfig[0].status} status={selectedIcon.status}
title={maybeIconConfig[0].title} title={selectedIcon.title}
hotkey={maybeIconConfig[0].hotkey} hotkey={selectedIcon.hotkey}
/> />
)} )}
</ToolbarItemTooltip> </ToolbarItemTooltip>
@ -430,7 +451,9 @@ const ToolbarItemTooltipShortContent = ({
> >
{title} {title}
{hotkey && ( {hotkey && (
<kbd className="inline-block ml-2 flex-none hotkey">{hotkey}</kbd> <kbd className="inline-block ml-2 flex-none hotkey">
{displayHotkeys(hotkey)}
</kbd>
)} )}
</span> </span>
) )
@ -461,7 +484,9 @@ const ToolbarItemTooltipRichContent = ({
{itemConfig.title} {itemConfig.title}
</span> </span>
{itemConfig.status === 'available' && itemConfig.hotkey ? ( {itemConfig.status === 'available' && itemConfig.hotkey ? (
<kbd className="flex-none hotkey">{itemConfig.hotkey}</kbd> <kbd className="flex-none hotkey">
{displayHotkeys(itemConfig.hotkey)}
</kbd>
) : itemConfig.status === 'kcl-only' ? ( ) : itemConfig.status === 'kcl-only' ? (
<> <>
<span className="text-wrap font-sans flex-0 text-chalkboard-70 dark:text-chalkboard-40"> <span className="text-wrap font-sans flex-0 text-chalkboard-70 dark:text-chalkboard-40">
@ -522,3 +547,14 @@ const ToolbarItemTooltipRichContent = ({
</> </>
) )
} }
// We don't want to display Esc hotkeys to avoid confusion in the Toolbar UI (eg. "EscR")
function displayHotkeys(hotkey: string | string[]) {
return (isArray(hotkey) ? hotkey : [hotkey]).filter((h) => h !== 'Esc')
}
function isToolbarDropdown(
item: ToolbarItem | ToolbarDropdown
): item is ToolbarDropdown {
return 'array' in item
}

View File

@ -71,6 +71,7 @@ export const ActionButton = forwardRef((props: ActionButtonProps, ref) => {
<button <button
ref={ref as ForwardedRef<HTMLButtonElement>} ref={ref as ForwardedRef<HTMLButtonElement>}
className={classNames} className={classNames}
tabIndex={-1}
{...rest} {...rest}
> >
{iconStart && <ActionIcon {...iconStart} />} {iconStart && <ActionIcon {...iconStart} />}

View File

@ -69,6 +69,7 @@ export function ActionButtonDropdown({
close() close()
}} }}
className="group/button flex items-center gap-6 px-3 py-1 font-sans text-xs hover:bg-primary/10 dark:hover:bg-chalkboard-80 border-0 m-0 w-full rounded-none text-left disabled:!bg-transparent dark:disabled:text-chalkboard-60" className="group/button flex items-center gap-6 px-3 py-1 font-sans text-xs hover:bg-primary/10 dark:hover:bg-chalkboard-80 border-0 m-0 w-full rounded-none text-left disabled:!bg-transparent dark:disabled:text-chalkboard-60"
tabIndex={-1}
disabled={item.disabled} disabled={item.disabled}
data-testid={'dropdown-' + item.id} data-testid={'dropdown-' + item.id}
> >

View File

@ -1,4 +1,5 @@
import { useEffect, useRef } from 'react' import { useSelector } from '@xstate/react'
import { useEffect, useMemo, useRef } from 'react'
import { useHotkeys } from 'react-hotkeys-hook' import { useHotkeys } from 'react-hotkeys-hook'
import type { CommandArgument } from '@src/lib/commandTypes' import type { CommandArgument } from '@src/lib/commandTypes'
@ -6,6 +7,11 @@ import {
commandBarActor, commandBarActor,
useCommandBarState, useCommandBarState,
} from '@src/machines/commandBarMachine' } from '@src/machines/commandBarMachine'
import type { AnyStateMachine, SnapshotFrom } from 'xstate'
// TODO: remove the need for this selector once we decouple all actors from React
const machineContextSelector = (snapshot?: SnapshotFrom<AnyStateMachine>) =>
snapshot?.context
function CommandBarBasicInput({ function CommandBarBasicInput({
arg, arg,
@ -22,6 +28,19 @@ function CommandBarBasicInput({
const commandBarState = useCommandBarState() const commandBarState = useCommandBarState()
useHotkeys('mod + k, mod + /', () => commandBarActor.send({ type: 'Close' })) useHotkeys('mod + k, mod + /', () => commandBarActor.send({ type: 'Close' }))
const inputRef = useRef<HTMLInputElement>(null) const inputRef = useRef<HTMLInputElement>(null)
const argMachineContext = useSelector(
arg.machineActor,
machineContextSelector
)
const defaultValue = useMemo(
() =>
arg.defaultValue
? arg.defaultValue instanceof Function
? arg.defaultValue(commandBarState.context, argMachineContext)
: arg.defaultValue
: '',
[arg.defaultValue, commandBarState.context, argMachineContext]
)
useEffect(() => { useEffect(() => {
if (inputRef.current) { if (inputRef.current) {
@ -53,11 +72,7 @@ function CommandBarBasicInput({
required required
className="flex-grow px-2 py-1 border-b border-b-chalkboard-100 dark:border-b-chalkboard-80 !bg-transparent focus:outline-none" className="flex-grow px-2 py-1 border-b border-b-chalkboard-100 dark:border-b-chalkboard-80 !bg-transparent focus:outline-none"
placeholder="Enter a value" placeholder="Enter a value"
defaultValue={ defaultValue={defaultValue}
(commandBarState.context.argumentsToSubmit[arg.name] as
| string
| undefined) || (arg.defaultValue as string)
}
onKeyDown={(event) => { onKeyDown={(event) => {
if (event.key === 'Backspace' && event.shiftKey) { if (event.key === 'Backspace' && event.shiftKey) {
stepBack() stepBack()

View File

@ -19,7 +19,7 @@ import { useKclContext } from '@src/lang/KclProvider'
import type { KCLError } from '@src/lang/errors' import type { KCLError } from '@src/lang/errors'
import { kclErrorsByFilename } from '@src/lang/errors' import { kclErrorsByFilename } from '@src/lang/errors'
import { normalizeLineEndings } from '@src/lib/codeEditor' import { normalizeLineEndings } from '@src/lib/codeEditor'
import { FILE_EXT } from '@src/lib/constants' import { FILE_EXT, INSERT_FOREIGN_TOAST_ID } from '@src/lib/constants'
import { sortFilesAndDirectories } from '@src/lib/desktopFS' import { sortFilesAndDirectories } from '@src/lib/desktopFS'
import useHotkeyWrapper from '@src/lib/hotkeyWrapper' import useHotkeyWrapper from '@src/lib/hotkeyWrapper'
import { PATHS } from '@src/lib/paths' import { PATHS } from '@src/lib/paths'
@ -28,6 +28,9 @@ import { codeManager, kclManager } from '@src/lib/singletons'
import { reportRejection } from '@src/lib/trap' import { reportRejection } from '@src/lib/trap'
import type { IndexLoaderData } from '@src/lib/types' import type { IndexLoaderData } from '@src/lib/types'
import { ToastInsert } from '@src/components/ToastInsert'
import { commandBarActor } from '@src/machines/commandBarMachine'
import toast from 'react-hot-toast'
import styles from './FileTree.module.css' import styles from './FileTree.module.css'
function getIndentationCSS(level: number) { function getIndentationCSS(level: number) {
@ -264,16 +267,26 @@ const FileTreeItem = ({
if (fileOrDir.children !== null) return // Don't open directories if (fileOrDir.children !== null) return // Don't open directories
if (fileOrDir.name?.endsWith(FILE_EXT) === false && project?.path) { if (fileOrDir.name?.endsWith(FILE_EXT) === false && project?.path) {
// Import non-kcl files toast.custom(
// We want to update both the state and editor here. ToastInsert({
codeManager.updateCodeStateEditor( onInsert: () => {
`import("${fileOrDir.path.replace(project.path, '.')}")\n` + const relativeFilePath = fileOrDir.path.replace(
codeManager.code project.path + window.electron.sep,
''
)
commandBarActor.send({
type: 'Find and select command',
data: {
name: 'Insert',
groupId: 'code',
argDefaultValues: { path: relativeFilePath },
},
})
toast.dismiss(INSERT_FOREIGN_TOAST_ID)
},
}),
{ duration: 30000, id: INSERT_FOREIGN_TOAST_ID }
) )
await codeManager.writeToFile()
// Prevent seeing the model built one piece at a time when changing files
await kclManager.executeCode()
} else { } else {
// Let the lsp servers know we closed a file. // Let the lsp servers know we closed a file.
onFileClose(currentFile?.path || null, project?.path || null) onFileClose(currentFile?.path || null, project?.path || null)

View File

@ -0,0 +1,40 @@
import toast from 'react-hot-toast'
import { ActionButton } from '@src/components/ActionButton'
export function ToastInsert({ onInsert }: { onInsert: () => void }) {
return (
<div className="inset-0 z-50 grid place-content-center rounded bg-chalkboard-110/50 shadow-md">
<div className="max-w-3xl min-w-[35rem] p-8 rounded bg-chalkboard-10 dark:bg-chalkboard-90">
<p className="text-md">
Non-KCL files aren't editable here in Zoo Studio, but you may insert
them using the button below or the Insert command.
</p>
<div className="mt-4 flex justify-between gap-8">
<ActionButton
Element="button"
iconStart={{
icon: 'checkmark',
}}
name="insert"
onClick={onInsert}
>
Insert into my current file
</ActionButton>
<ActionButton
Element="button"
iconStart={{
icon: 'close',
}}
name="dismiss"
onClick={() => {
toast.dismiss()
}}
>
Dismiss
</ActionButton>
</div>
</div>
</div>
)
}

View File

@ -86,7 +86,7 @@ textarea,
button { button {
@apply border border-chalkboard-30 m-0.5 px-3 rounded text-xs; @apply border border-chalkboard-30 m-0.5 px-3 rounded text-xs;
@apply focus-visible:outline-chalkboard-100; @apply focus-visible:outline-none;
} }
button:hover { button:hover {
@ -94,7 +94,7 @@ button:hover {
} }
.dark button { .dark button {
@apply border-chalkboard-70 focus-visible:outline-chalkboard-10; @apply border-chalkboard-70;
} }
.dark button:hover { .dark button:hover {

View File

@ -1,4 +1,5 @@
import type { Models } from '@kittycad/lib/dist/types/src' import type { Models } from '@kittycad/lib/dist/types/src'
import type { FileImportFormat_type } from '@kittycad/lib/dist/types/src/models'
import type { UnitAngle, UnitLength } from '@rust/kcl-lib/bindings/ModelingCmd' import type { UnitAngle, UnitLength } from '@rust/kcl-lib/bindings/ModelingCmd'
@ -37,13 +38,24 @@ export const FILE_PERSIST_KEY = `${PROJECT_FOLDER}-last-opened` as const
export const DEFAULT_FILE_NAME = 'Untitled' export const DEFAULT_FILE_NAME = 'Untitled'
/** The file endings that will appear in /** The file endings that will appear in
* the file explorer if found in a project directory */ * the file explorer if found in a project directory */
export const RELEVANT_FILE_TYPES = [ // TODO: make stp part of this enum as an alias to step
// TODO: make glb part of this enum as it is in fact supported
export type NativeFileType = 'kcl'
export type RelevantFileType =
| FileImportFormat_type
| NativeFileType
| 'stp'
| 'glb'
export const NATIVE_FILE_TYPE: NativeFileType = 'kcl'
export const RELEVANT_FILE_TYPES: RelevantFileType[] = [
'kcl', 'kcl',
'fbx', 'fbx',
'gltf', 'gltf',
'glb', 'glb',
'obj', 'obj',
'ply', 'ply',
'sldprt',
'stp',
'step', 'step',
'stl', 'stl',
] as const ] as const
@ -131,6 +143,9 @@ export const CREATE_FILE_URL_PARAM = 'create-file'
/** Toast id for the app auto-updater toast */ /** Toast id for the app auto-updater toast */
export const AUTO_UPDATER_TOAST_ID = 'auto-updater-toast' export const AUTO_UPDATER_TOAST_ID = 'auto-updater-toast'
/** Toast id for the insert foreign part toast */
export const INSERT_FOREIGN_TOAST_ID = 'insert-foreign-toast'
/** Local sketch axis values in KCL for operations, it could either be 'X' or 'Y' */ /** Local sketch axis values in KCL for operations, it could either be 'X' or 'Y' */
export const KCL_AXIS_X = 'X' export const KCL_AXIS_X = 'X'
export const KCL_AXIS_Y = 'Y' export const KCL_AXIS_Y = 'Y'

View File

@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Configuration } from '@rust/kcl-lib/bindings/Configuration' import type { Configuration } from '@rust/kcl-lib/bindings/Configuration'
import { listProjects } from '@src/lib/desktop' import { isRelevantFile, listProjects } from '@src/lib/desktop'
import type { DeepPartial } from '@src/lib/types' import type { DeepPartial } from '@src/lib/types'
// Mock the electron window global // Mock the electron window global
@ -112,6 +112,41 @@ describe('desktop utilities', () => {
mockElectron.kittycad.mockResolvedValue({}) mockElectron.kittycad.mockResolvedValue({})
}) })
describe('isRelevantFile', () => {
it('finds supported extension files relevant', () => {
expect(isRelevantFile('part.kcl')).toEqual(true)
expect(isRelevantFile('part.fbx')).toEqual(true)
expect(isRelevantFile('part.gltf')).toEqual(true)
expect(isRelevantFile('part.glb')).toEqual(true)
expect(isRelevantFile('part.obj')).toEqual(true)
expect(isRelevantFile('part.ply')).toEqual(true)
expect(isRelevantFile('part.sldprt')).toEqual(true)
expect(isRelevantFile('part.stp')).toEqual(true)
expect(isRelevantFile('part.step')).toEqual(true)
expect(isRelevantFile('part.stl')).toEqual(true)
})
// TODO: we should be lowercasing the extension here to check. .sldprt or .SLDPRT should be supported
// But the api doesn't allow it today, so revisit this and the tests once this is done
it('finds (now) supported uppercase extension files *not* relevant', () => {
expect(isRelevantFile('part.KCL')).toEqual(false)
expect(isRelevantFile('part.FBX')).toEqual(false)
expect(isRelevantFile('part.GLTF')).toEqual(false)
expect(isRelevantFile('part.GLB')).toEqual(false)
expect(isRelevantFile('part.OBJ')).toEqual(false)
expect(isRelevantFile('part.PLY')).toEqual(false)
expect(isRelevantFile('part.SLDPRT')).toEqual(false)
expect(isRelevantFile('part.STP')).toEqual(false)
expect(isRelevantFile('part.STEP')).toEqual(false)
expect(isRelevantFile('part.STL')).toEqual(false)
})
it("doesn't find .docx or .SLDASM relevant", () => {
expect(isRelevantFile('paper.docx')).toEqual(false)
expect(isRelevantFile('assembly.SLDASM')).toEqual(false)
})
})
describe('listProjects', () => { describe('listProjects', () => {
it('does not list .git directories', async () => { it('does not list .git directories', async () => {
const projects = await listProjects(mockConfig) const projects = await listProjects(mockConfig)

View File

@ -16,6 +16,7 @@ import {
PROJECT_FOLDER, PROJECT_FOLDER,
PROJECT_IMAGE_NAME, PROJECT_IMAGE_NAME,
PROJECT_SETTINGS_FILE_NAME, PROJECT_SETTINGS_FILE_NAME,
RELEVANT_FILE_TYPES,
SETTINGS_FILE_NAME, SETTINGS_FILE_NAME,
TELEMETRY_FILE_NAME, TELEMETRY_FILE_NAME,
TELEMETRY_RAW_FILE_NAME, TELEMETRY_RAW_FILE_NAME,
@ -24,6 +25,7 @@ import {
import type { FileEntry, Project } from '@src/lib/project' import type { FileEntry, Project } from '@src/lib/project'
import { err } from '@src/lib/trap' import { err } from '@src/lib/trap'
import type { DeepPartial } from '@src/lib/types' import type { DeepPartial } from '@src/lib/types'
import { getInVariableCase } from '@src/lib/utils'
export async function renameProjectDirectory( export async function renameProjectDirectory(
projectPath: string, projectPath: string,
@ -199,16 +201,10 @@ export async function listProjects(
return projects return projects
} }
const IMPORT_FILE_EXTENSIONS = [ // TODO: we should be lowercasing the extension here to check. .sldprt or .SLDPRT should be supported
// TODO Use ImportFormat enum // But the api doesn't allow it today, so revisit this and the tests once this is done
'stp', export const isRelevantFile = (filename: string): boolean =>
'glb', RELEVANT_FILE_TYPES.some((ext) => filename.endsWith('.' + ext))
'fbxb',
'kcl',
]
const isRelevantFile = (filename: string): boolean =>
IMPORT_FILE_EXTENSIONS.some((ext) => filename.endsWith('.' + ext))
const collectAllFilesRecursiveFrom = async ( const collectAllFilesRecursiveFrom = async (
path: string, path: string,
@ -731,3 +727,12 @@ export const writeProjectThumbnailFile = async (
} }
return window.electron.writeFile(filePath, asArray) return window.electron.writeFile(filePath, asArray)
} }
export function getPathFilenameInVariableCase(path: string) {
// from https://nodejs.org/en/learn/manipulating-files/nodejs-file-paths#example
const basenameNoExt = window.electron.path.basename(
path,
window.electron.path.extname(path)
)
return getInVariableCase(basenameNoExt)
}

View File

@ -1,31 +1,17 @@
import type { Models } from '@kittycad/lib/dist/types/src'
import type { Stats } from 'fs' import type { Stats } from 'fs'
import * as fs from 'fs/promises' import * as fs from 'fs/promises'
import * as path from 'path' import * as path from 'path'
import { PROJECT_ENTRYPOINT } from '@src/lib/constants' import {
NATIVE_FILE_TYPE,
PROJECT_ENTRYPOINT,
RELEVANT_FILE_TYPES,
type RelevantFileType,
} from '@src/lib/constants'
// Create a const object with the values const shouldWrapExtension = (extension: string) =>
const FILE_IMPORT_FORMATS = { RELEVANT_FILE_TYPES.includes(extension as RelevantFileType) &&
fbx: 'fbx', extension !== NATIVE_FILE_TYPE
gltf: 'gltf',
obj: 'obj',
ply: 'ply',
sldprt: 'sldprt',
step: 'step',
stl: 'stl',
} as const
// Extract the values into an array
const fileImportFormats: Models['FileImportFormat_type'][] =
Object.values(FILE_IMPORT_FORMATS)
export const allFileImportFormats: string[] = [
...fileImportFormats,
'stp',
'fbxb',
'glb',
]
export const relevantExtensions = ['kcl', ...allFileImportFormats]
/// Get the current project file from the path. /// Get the current project file from the path.
/// This is used for double-clicking on a file in the file explorer, /// This is used for double-clicking on a file in the file explorer,
@ -83,11 +69,14 @@ export default async function getCurrentProjectFile(
} }
// Check if the extension on what we are trying to open is a relevant file type. // Check if the extension on what we are trying to open is a relevant file type.
const extension = path.extname(sourcePath).slice(1) const extension = path.extname(sourcePath).slice(1).toLowerCase()
if (!relevantExtensions.includes(extension) && extension !== 'toml') { if (
!RELEVANT_FILE_TYPES.includes(extension as RelevantFileType) &&
extension !== 'toml'
) {
return new Error( return new Error(
`File type (${extension}) cannot be opened with this app: '${sourcePath}', try opening one of the following file types: ${relevantExtensions.join( `File type (${extension}) cannot be opened with this app: '${sourcePath}', try opening one of the following file types: ${RELEVANT_FILE_TYPES.join(
', ' ', '
)}` )}`
) )
@ -99,7 +88,9 @@ export default async function getCurrentProjectFile(
// If we got an import model file, we need to check if we have a file in the project for // If we got an import model file, we need to check if we have a file in the project for
// this import model. // this import model.
if (allFileImportFormats.includes(extension)) { // TODO: once we have some sort of a load file into project it would make sense to stop creating these wrapper files
// and let people save their own kcl file importing
if (shouldWrapExtension(extension)) {
const importFileName = path.basename(sourcePath) const importFileName = path.basename(sourcePath)
// Check if we have a file in the project for this import model. // Check if we have a file in the project for this import model.
const kclWrapperFilename = `${importFileName}.kcl` const kclWrapperFilename = `${importFileName}.kcl`
@ -115,7 +106,8 @@ export default async function getCurrentProjectFile(
// But we recommend you keep the import statement as it is. // But we recommend you keep the import statement as it is.
// For more information on the import statement, see the documentation at: // For more information on the import statement, see the documentation at:
// https://zoo.dev/docs/kcl/import // https://zoo.dev/docs/kcl/import
const model = import("${importFileName}")` import "${importFileName}" as model
model`
await fs.writeFile(kclWrapperFilePath, content) await fs.writeFile(kclWrapperFilePath, content)
} }

View File

@ -17,12 +17,14 @@ import {
EXECUTION_TYPE_REAL, EXECUTION_TYPE_REAL,
FILE_EXT, FILE_EXT,
} from '@src/lib/constants' } from '@src/lib/constants'
import { getPathFilenameInVariableCase } from '@src/lib/desktop'
import { isDesktop } from '@src/lib/isDesktop' import { isDesktop } from '@src/lib/isDesktop'
import { copyFileShareLink } from '@src/lib/links' import { copyFileShareLink } from '@src/lib/links'
import { baseUnitsUnion } from '@src/lib/settings/settingsTypes' import { baseUnitsUnion } from '@src/lib/settings/settingsTypes'
import { codeManager, editorManager, kclManager } from '@src/lib/singletons' import { codeManager, editorManager, kclManager } from '@src/lib/singletons'
import { err, reportRejection } from '@src/lib/trap' import { err, reportRejection } from '@src/lib/trap'
import type { IndexLoaderData } from '@src/lib/types' import type { IndexLoaderData } from '@src/lib/types'
import type { CommandBarContext } from '@src/machines/commandBarMachine'
import { IS_NIGHTLY_OR_DEBUG } from '@src/routes/utils' import { IS_NIGHTLY_OR_DEBUG } from '@src/routes/utils'
interface OnSubmitProps { interface OnSubmitProps {
@ -122,6 +124,14 @@ export function kclCommands(commandProps: KclCommandConfig): Command[] {
localName: { localName: {
inputType: 'string', inputType: 'string',
required: true, required: true,
defaultValue: (context: CommandBarContext) => {
if (!context.argumentsToSubmit['path']) {
return
}
const path = context.argumentsToSubmit['path'] as string
return getPathFilenameInVariableCase(path)
},
}, },
}, },
onSubmit: (data) => { onSubmit: (data) => {

View File

@ -27,12 +27,31 @@ function userCall(name: string): Operation {
sourceRange: defaultSourceRange(), sourceRange: defaultSourceRange(),
} }
} }
function userReturn(): Operation { function userReturn(): Operation {
return { return {
type: 'GroupEnd', type: 'GroupEnd',
} }
} }
function moduleBegin(name: string): Operation {
return {
type: 'GroupBegin',
group: {
type: 'ModuleInstance',
name,
moduleId: 0,
},
sourceRange: defaultSourceRange(),
}
}
function moduleEnd(): Operation {
return {
type: 'GroupEnd',
}
}
describe('operations filtering', () => { describe('operations filtering', () => {
it('drops stdlib operations inside a user-defined function call', async () => { it('drops stdlib operations inside a user-defined function call', async () => {
const operations = [ const operations = [
@ -65,6 +84,25 @@ describe('operations filtering', () => {
const actual = filterOperations(operations) const actual = filterOperations(operations)
expect(actual).toEqual([stdlib('std1'), stdlib('std2'), stdlib('std3')]) expect(actual).toEqual([stdlib('std1'), stdlib('std2'), stdlib('std3')])
}) })
it('does not drop module instances that contain no operations', async () => {
const operations = [
stdlib('std1'),
moduleBegin('foo'),
moduleEnd(),
stdlib('std2'),
moduleBegin('bar'),
moduleEnd(),
stdlib('std3'),
]
const actual = filterOperations(operations)
expect(actual).toEqual([
stdlib('std1'),
moduleBegin('foo'),
stdlib('std2'),
moduleBegin('bar'),
stdlib('std3'),
])
})
it('preserves user-defined function calls at the end of the list', async () => { it('preserves user-defined function calls at the end of the list', async () => {
const operations = [stdlib('std1'), userCall('foo')] const operations = [stdlib('std1'), userCall('foo')]
const actual = filterOperations(operations) const actual = filterOperations(operations)

View File

@ -1168,7 +1168,7 @@ export function filterOperations(operations: Operation[]): Operation[] {
* for use in the feature tree UI * for use in the feature tree UI
*/ */
const operationFilters = [ const operationFilters = [
isNotGroupWithNoOperations, isNotUserFunctionWithNoOperations,
isNotInsideGroup, isNotInsideGroup,
isNotGroupEnd, isNotGroupEnd,
] ]
@ -1202,22 +1202,28 @@ function isNotInsideGroup(operations: Operation[]): Operation[] {
/** /**
* A filter to exclude GroupBegin operations and their corresponding GroupEnd * A filter to exclude GroupBegin operations and their corresponding GroupEnd
* that don't have any operations inside them from a list of operations. * that don't have any operations inside them from a list of operations, if it's
* a function call.
*/ */
function isNotGroupWithNoOperations(operations: Operation[]): Operation[] { function isNotUserFunctionWithNoOperations(
operations: Operation[]
): Operation[] {
return operations.filter((op, index) => { return operations.filter((op, index) => {
if ( if (
op.type === 'GroupBegin' && op.type === 'GroupBegin' &&
// If this is a begin at the end of the array, it's preserved. op.group.type === 'FunctionCall' &&
// If this is a "begin" at the end of the array, it's preserved.
index < operations.length - 1 && index < operations.length - 1 &&
operations[index + 1].type === 'GroupEnd' operations[index + 1].type === 'GroupEnd'
) )
return false return false
const previousOp = index > 0 ? operations[index - 1] : undefined
if ( if (
op.type === 'GroupEnd' && op.type === 'GroupEnd' &&
// If this is an end at the beginning of the array, it's preserved. // If this is an "end" at the beginning of the array, it's preserved.
index > 0 && previousOp !== undefined &&
operations[index - 1].type === 'GroupBegin' previousOp.type === 'GroupBegin' &&
previousOp.group.type === 'FunctionCall'
) )
return false return false

View File

@ -15,7 +15,12 @@ export type ToolbarModeName = 'modeling' | 'sketching'
type ToolbarMode = { type ToolbarMode = {
check: (state: StateFrom<typeof modelingMachine>) => boolean check: (state: StateFrom<typeof modelingMachine>) => boolean
items: (ToolbarItem | ToolbarItem[] | 'break')[] items: (ToolbarItem | ToolbarDropdown | 'break')[]
}
export type ToolbarDropdown = {
id: string
array: ToolbarItem[]
} }
export interface ToolbarItemCallbackProps { export interface ToolbarItemCallbackProps {
@ -58,6 +63,17 @@ export type ToolbarItemResolved = Omit<
isActive?: boolean isActive?: boolean
} }
export type ToolbarItemResolvedDropdown = {
id: string
array: ToolbarItemResolved[]
}
export const isToolbarItemResolvedDropdown = (
item: ToolbarItemResolved | ToolbarItemResolvedDropdown
): item is ToolbarItemResolvedDropdown => {
return (item as ToolbarItemResolvedDropdown).array !== undefined
}
export const toolbarConfig: Record<ToolbarModeName, ToolbarMode> = { export const toolbarConfig: Record<ToolbarModeName, ToolbarMode> = {
modeling: { modeling: {
check: (state) => check: (state) =>
@ -208,7 +224,9 @@ export const toolbarConfig: Record<ToolbarModeName, ToolbarMode> = {
links: [{ label: 'KCL docs', url: 'https://zoo.dev/docs/kcl/shell' }], links: [{ label: 'KCL docs', url: 'https://zoo.dev/docs/kcl/shell' }],
}, },
'break', 'break',
[ {
id: 'booleans',
array: [
{ {
id: 'boolean-union', id: 'boolean-union',
onClick: () => onClick: () =>
@ -267,8 +285,11 @@ export const toolbarConfig: Record<ToolbarModeName, ToolbarMode> = {
], ],
}, },
], ],
},
'break', 'break',
[ {
id: 'planes',
array: [
{ {
id: 'plane-offset', id: 'plane-offset',
onClick: () => { onClick: () => {
@ -299,6 +320,7 @@ export const toolbarConfig: Record<ToolbarModeName, ToolbarMode> = {
links: [], links: [],
}, },
], ],
},
{ {
id: 'helix', id: 'helix',
onClick: () => { onClick: () => {
@ -315,7 +337,9 @@ export const toolbarConfig: Record<ToolbarModeName, ToolbarMode> = {
links: [{ label: 'KCL docs', url: 'https://zoo.dev/docs/kcl/helix' }], links: [{ label: 'KCL docs', url: 'https://zoo.dev/docs/kcl/helix' }],
}, },
'break', 'break',
[ {
id: 'ai',
array: [
{ {
id: 'text-to-cad', id: 'text-to-cad',
onClick: () => onClick: () =>
@ -352,6 +376,7 @@ export const toolbarConfig: Record<ToolbarModeName, ToolbarMode> = {
links: [], links: [],
}, },
], ],
},
], ],
}, },
sketching: { sketching: {
@ -403,7 +428,9 @@ export const toolbarConfig: Record<ToolbarModeName, ToolbarMode> = {
links: [], links: [],
isActive: (state) => state.matches({ Sketch: 'Line tool' }), isActive: (state) => state.matches({ Sketch: 'Line tool' }),
}, },
[ {
id: 'arcs',
array: [
{ {
id: 'tangential-arc', id: 'tangential-arc',
onClick: ({ modelingState, modelingSend }) => onClick: ({ modelingState, modelingSend }) =>
@ -428,7 +455,9 @@ export const toolbarConfig: Record<ToolbarModeName, ToolbarMode> = {
: undefined, : undefined,
title: 'Tangential Arc', title: 'Tangential Arc',
hotkey: (state) => hotkey: (state) =>
state.matches({ Sketch: 'Tangential arc to' }) ? ['Esc', 'A'] : 'A', state.matches({ Sketch: 'Tangential arc to' })
? ['Esc', 'A']
: 'A',
description: 'Start drawing an arc tangent to the current segment', description: 'Start drawing an arc tangent to the current segment',
links: [], links: [],
isActive: (state) => state.matches({ Sketch: 'Tangential arc to' }), isActive: (state) => state.matches({ Sketch: 'Tangential arc to' }),
@ -439,7 +468,9 @@ export const toolbarConfig: Record<ToolbarModeName, ToolbarMode> = {
modelingSend({ modelingSend({
type: 'change tool', type: 'change tool',
data: { data: {
tool: !modelingState.matches({ Sketch: 'Arc three point tool' }) tool: !modelingState.matches({
Sketch: 'Arc three point tool',
})
? 'arcThreePoint' ? 'arcThreePoint'
: 'none', : 'none',
}, },
@ -481,6 +512,7 @@ export const toolbarConfig: Record<ToolbarModeName, ToolbarMode> = {
isActive: (state) => state.matches({ Sketch: 'Arc tool' }), isActive: (state) => state.matches({ Sketch: 'Arc tool' }),
}, },
], ],
},
{ {
id: 'spline', id: 'spline',
onClick: () => console.error('Spline not yet implemented'), onClick: () => console.error('Spline not yet implemented'),
@ -492,7 +524,9 @@ export const toolbarConfig: Record<ToolbarModeName, ToolbarMode> = {
links: [], links: [],
}, },
'break', 'break',
[ {
id: 'circles',
array: [
{ {
id: 'circle-center', id: 'circle-center',
onClick: ({ modelingState, modelingSend }) => onClick: ({ modelingState, modelingSend }) =>
@ -508,9 +542,7 @@ export const toolbarConfig: Record<ToolbarModeName, ToolbarMode> = {
status: 'available', status: 'available',
title: 'Center circle', title: 'Center circle',
disabled: (state) => state.matches('Sketch no face'), disabled: (state) => state.matches('Sketch no face'),
isActive: (state) => isActive: (state) => state.matches({ Sketch: 'Circle tool' }),
state.matches({ Sketch: 'Circle tool' }) ||
state.matches({ Sketch: 'Circle three point tool' }),
hotkey: (state) => hotkey: (state) =>
state.matches({ Sketch: 'Circle tool' }) ? ['Esc', 'C'] : 'C', state.matches({ Sketch: 'Circle tool' }) ? ['Esc', 'C'] : 'C',
showTitle: false, showTitle: false,
@ -533,12 +565,19 @@ export const toolbarConfig: Record<ToolbarModeName, ToolbarMode> = {
icon: 'circle', icon: 'circle',
status: 'available', status: 'available',
title: '3-point circle', title: '3-point circle',
isActive: (state) =>
state.matches({ Sketch: 'Circle three point tool' }),
hotkey: (state) =>
state.matches({ Sketch: 'Circle three point tool' }) ? 'Esc' : [],
showTitle: false, showTitle: false,
description: 'Draw a circle defined by three points', description: 'Draw a circle defined by three points',
links: [], links: [],
}, },
], ],
[ },
{
id: 'rectangles',
array: [
{ {
id: 'corner-rectangle', id: 'corner-rectangle',
onClick: ({ modelingState, modelingSend }) => onClick: ({ modelingState, modelingSend }) =>
@ -573,7 +612,7 @@ export const toolbarConfig: Record<ToolbarModeName, ToolbarMode> = {
: 'none', : 'none',
}, },
}), }),
icon: 'arc', icon: 'rectangle',
status: 'available', status: 'available',
disabled: (state) => state.matches('Sketch no face'), disabled: (state) => state.matches('Sketch no face'),
title: 'Center rectangle', title: 'Center rectangle',
@ -588,6 +627,7 @@ export const toolbarConfig: Record<ToolbarModeName, ToolbarMode> = {
}, },
}, },
], ],
},
{ {
id: 'polygon', id: 'polygon',
onClick: () => console.error('Polygon not yet implemented'), onClick: () => console.error('Polygon not yet implemented'),
@ -619,7 +659,9 @@ export const toolbarConfig: Record<ToolbarModeName, ToolbarMode> = {
description: 'Mirror sketch entities about a line or axis', description: 'Mirror sketch entities about a line or axis',
links: [], links: [],
}, },
[ {
id: 'constraints',
array: [
{ {
id: 'constraint-length', id: 'constraint-length',
disabled: (state) => disabled: (state) =>
@ -882,6 +924,7 @@ export const toolbarConfig: Record<ToolbarModeName, ToolbarMode> = {
links: [], links: [],
}, },
], ],
},
], ],
}, },
} }

View File

@ -1,6 +1,7 @@
import type { SourceRange } from '@rust/kcl-lib/bindings/SourceRange' import type { SourceRange } from '@rust/kcl-lib/bindings/SourceRange'
import { topLevelRange } from '@src/lang/util' import { topLevelRange } from '@src/lang/util'
import { import {
getInVariableCase,
hasDigitsLeftOfDecimal, hasDigitsLeftOfDecimal,
hasLeadingZero, hasLeadingZero,
isClockwise, isClockwise,
@ -1308,3 +1309,24 @@ describe('testing isClockwise', () => {
expect(isClockwise(counterClockwiseTriangle)).toBe(true) expect(isClockwise(counterClockwiseTriangle)).toBe(true)
}) })
}) })
describe('testing getInVariableCase', () => {
it('properly parses cylinder into cylinder', () => {
expect(getInVariableCase('cylinder')).toBe('cylinder')
})
it('properly parses my-ugly_Cased_Part123 into myUglyCasedPart', () => {
expect(getInVariableCase('my-ugly_Cased_Part123')).toBe(
'myUglyCasedPart123'
)
})
it('properly parses PascalCase into pascalCase', () => {
expect(getInVariableCase('PascalCase')).toBe('pascalCase')
})
it('properly parses my/File/Path into myFilePath', () => {
expect(getInVariableCase('my/File/Path')).toBe('myFilePath')
})
it('properly parses prefixes 1120t74-pipe.step', () => {
expect(getInVariableCase('1120t74-pipe')).toBe('m1120T74Pipe')
expect(getInVariableCase('1120t74-pipe', 'p')).toBe('p1120T74Pipe')
})
})

View File

@ -473,3 +473,25 @@ export function binaryToUuid(
export function getModuleId(sourceRange: SourceRange) { export function getModuleId(sourceRange: SourceRange) {
return sourceRange[2] return sourceRange[2]
} }
export function getInVariableCase(name: string, prefixIfDigit = 'm') {
// As of 2025-04-08, standard case for KCL variables is camelCase
const startsWithANumber = !Number.isNaN(Number(name.charAt(0)))
const paddedName = startsWithANumber ? `${prefixIfDigit}${name}` : name
// From https://www.30secondsofcode.org/js/s/string-case-conversion/#word-boundary-identification
const r = /[A-Z]{2,}(?=[A-Z][a-z]+[0-9]*|\b)|[A-Z]?[a-z]+[0-9]*|[A-Z]|[0-9]+/g
const boundaryIdentification = paddedName.match(r)
if (!boundaryIdentification) {
return undefined
}
const likelyPascalCase = boundaryIdentification
.map((x) => x.slice(0, 1).toUpperCase() + x.slice(1).toLowerCase())
.join('')
if (!likelyPascalCase) {
return undefined
}
return likelyPascalCase.slice(0, 1).toLowerCase() + likelyPascalCase.slice(1)
}