diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 1af3d4078..299d2a1d5 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -25,7 +25,7 @@ updates: - adamchalmers - jessfraz - package-ecosystem: 'cargo' # See documentation for possible values - directory: '/src/wasm-lib/' # Location of package manifests + directory: '/rust/' # Location of package manifests schedule: interval: weekly day: monday @@ -39,3 +39,11 @@ updates: wasm-bindgen-deps: patterns: - "wasm-bindgen*" + - package-ecosystem: "pip" # See documentation for possible values + directory: "/rust/kcl-python-bindings/" # Location of package manifests + schedule: + interval: weekly + day: monday + reviewers: + - adamchalmers + - jessfraz diff --git a/.github/workflows/kcl-python-bindings.yml b/.github/workflows/kcl-python-bindings.yml new file mode 100644 index 000000000..e972a090d --- /dev/null +++ b/.github/workflows/kcl-python-bindings.yml @@ -0,0 +1,179 @@ +# This file is autogenerated by maturin v1.6.0 and then modified +# To update, run +# +# maturin generate-ci github +# +name: kcl-python-bindings + +on: + push: + branches: + - main + paths: + - '**/Cargo.toml' + - '**/Cargo.lock' + - '**/rust-toolchain.toml' + - 'rust/kcl-python-bindings/**' + - '**.rs' + - .github/workflows/kcl-python-bindings.yml + tags: + - 'kcl-*' + pull_request: + paths: + - '**/Cargo.toml' + - '**/Cargo.lock' + - '**/rust-toolchain.toml' + - 'rust/kcl-python-bindings/**' + - '**.rs' + - .github/workflows/kcl-python-bindings.yml + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +jobs: + linux-x86_64: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: 3.x + - name: Build wheels + uses: PyO3/maturin-action@v1 + with: + working-directory: rust/kcl-python-bindings + target: x86_64 + args: --release --out dist --find-interpreter + sccache: 'true' + manylinux: auto + before-script-linux: | + yum install openssl-devel -y + - name: Upload wheels + uses: actions/upload-artifact@v4 + with: + name: wheels-linux-x86_64 + path: rust/kcl-python-bindings/dist + + windows: + runs-on: windows-16-cores + strategy: + matrix: + target: + - x64 + fail-fast: false + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: 3.x + architecture: ${{ matrix.target }} + - name: Build wheels + uses: PyO3/maturin-action@v1 + with: + working-directory: rust/kcl-python-bindings + target: ${{ matrix.target }} + args: --release --out dist --find-interpreter + sccache: 'true' + - name: Upload wheels + uses: actions/upload-artifact@v4 + with: + name: wheels-windows-${{ matrix.target }} + path: rust/kcl-python-bindings/dist + + macos: + runs-on: macos-latest + strategy: + matrix: + target: + - x86_64 + - aarch64 + fail-fast: false + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: 3.x + - name: Build wheels + uses: PyO3/maturin-action@v1 + with: + working-directory: rust/kcl-python-bindings + target: ${{ matrix.target }} + args: --release --out dist --find-interpreter + sccache: 'true' + - name: Upload wheels + uses: actions/upload-artifact@v4 + with: + name: wheels-macos-${{ matrix.target }} + path: rust/kcl-python-bindings/dist + + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install uv + uses: astral-sh/setup-uv@v5 + - uses: actions-rust-lang/setup-rust-toolchain@v1 + - uses: taiki-e/install-action@just + - name: Run tests + run: | + cd rust/kcl-python-bindings + just setup-uv + just test + env: + KITTYCAD_API_TOKEN: ${{ secrets.KITTYCAD_API_TOKEN }} + + sdist: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install the latest version of uv + uses: astral-sh/setup-uv@v5 + - name: Install codespell + run: | + uv venv .venv + echo "VIRTUAL_ENV=.venv" >> $GITHUB_ENV + echo "$PWD/.venv/bin" >> $GITHUB_PATH + uv pip install pip --upgrade + - name: Build sdist + uses: PyO3/maturin-action@v1 + with: + working-directory: rust/kcl-python-bindings + command: sdist + args: --out dist + - name: Upload sdist + uses: actions/upload-artifact@v4 + with: + name: wheels-sdist + path: rust/kcl-python-bindings/dist + + release: + name: Release + runs-on: ubuntu-latest + permissions: + contents: write + if: startsWith(github.ref, 'refs/tags/') + needs: [linux-x86_64, windows, macos, sdist] + steps: + - uses: actions/download-artifact@v4 + - name: Install the latest version of uv + uses: astral-sh/setup-uv@v5 + - name: Install codespell + run: | + cd rust/kcl-python-bindings + uv venv .venv + echo "VIRTUAL_ENV=.venv" >> $GITHUB_ENV + echo "$PWD/.venv/bin" >> $GITHUB_PATH + uv pip install pip --upgrade + - name: Publish to PyPI + uses: PyO3/maturin-action@v1 + env: + MATURIN_PYPI_TOKEN: ${{ secrets.PYPI_API_TOKEN }} + with: + working-directory: rust/kcl-python-bindings + command: upload + args: --non-interactive --skip-existing wheels-*/* diff --git a/.github/workflows/ruff.yml b/.github/workflows/ruff.yml new file mode 100644 index 000000000..be1d46285 --- /dev/null +++ b/.github/workflows/ruff.yml @@ -0,0 +1,24 @@ +name: ruff +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true +on: + push: + branches: main + paths: + - '**.py' + - .github/workflows/ruff.yml + pull_request: + paths: + - '**.py' + - .github/workflows/ruff.yml +permissions: + contents: read + pull-requests: write +jobs: + ruff: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/ruff-action@v3 + diff --git a/.gitignore b/.gitignore index b08188e19..14ef0ac3d 100644 --- a/.gitignore +++ b/.gitignore @@ -63,8 +63,13 @@ Mac_App_Distribution.provisionprofile *.tsbuildinfo .eslintcache -venv .vite/ # electron out/ + +# python +__pycache__/ +uv.lock +rust/kcl-python-bindings/dist +venv diff --git a/e2e/playwright/snapshot-tests.spec.ts-snapshots/extrude-on-default-planes-should-be-stable--XZ-1-Google-Chrome-linux.png b/e2e/playwright/snapshot-tests.spec.ts-snapshots/extrude-on-default-planes-should-be-stable--XZ-1-Google-Chrome-linux.png index 9e3d0691b..6f6d30275 100644 Binary files a/e2e/playwright/snapshot-tests.spec.ts-snapshots/extrude-on-default-planes-should-be-stable--XZ-1-Google-Chrome-linux.png and b/e2e/playwright/snapshot-tests.spec.ts-snapshots/extrude-on-default-planes-should-be-stable--XZ-1-Google-Chrome-linux.png differ diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 4e4da8656..82424bdbe 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -1744,6 +1744,21 @@ dependencies = [ "zip", ] +[[package]] +name = "kcl-python-bindings" +version = "0.3.45" +dependencies = [ + "anyhow", + "kcl-lib", + "kittycad-modeling-cmds", + "miette", + "pyo3", + "serde", + "serde_json", + "tokio", + "uuid", +] + [[package]] name = "kcl-test-server" version = "0.1.45" @@ -1833,9 +1848,9 @@ dependencies = [ [[package]] name = "kittycad-modeling-cmds" -version = "0.2.99" +version = "0.2.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "828a0c74476533e6258ea7dd70cfc7d63a5df4b37753d30ef198e0689eaac4eb" +checksum = "fb5a824cb9bb4c602962ecbaca5ce71225938aa1abc24103bf46c222f468dd26" dependencies = [ "anyhow", "chrono", @@ -2530,6 +2545,7 @@ dependencies = [ "pyo3-build-config", "pyo3-ffi", "pyo3-macros", + "serde", "unindent", ] diff --git a/rust/Cargo.toml b/rust/Cargo.toml index df4f3454a..cd9ff4035 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -3,6 +3,7 @@ resolver = "2" members = [ "kcl-derive-docs", "kcl-lib", + "kcl-python-bindings", "kcl-test-server", "kcl-to-core", "kcl-wasm-lib" @@ -23,10 +24,14 @@ similar = { opt-level = 3 } debug = "line-tables-only" [workspace.dependencies] +async-trait = "0.1.85" +anyhow = { version = "1" } http = "1" indexmap = "2.7.0" kittycad = { version = "0.3.28", default-features = false, features = ["js", "requests"] } -kittycad-modeling-cmds = { version = "0.2.99", features = ["ts-rs", "websocket"] } +kittycad-modeling-cmds = { version = "0.2.100", features = ["ts-rs", "websocket"] } +miette = "7.5.0" +pyo3 = { version = "0.22.6" } serde = { version = "1", features = ["derive"] } serde_json = { version = "1" } tokio = { version = "1" } diff --git a/rust/justfile b/rust/justfile index 24fa377e7..25920beb6 100644 --- a/rust/justfile +++ b/rust/justfile @@ -46,7 +46,9 @@ test: publish-kcl version: git tag kcl-{{version}} - git push origin kcl-{{version}} cargo publish -p kcl-derive-docs cargo publish -p kcl-lib cargo publish -p kcl-test-server + # We push the tag at the end of publish since pushing the tag + # will trigger CI to release the kcl python bindings. + git push origin kcl-{{version}} diff --git a/rust/kcl-derive-docs/Cargo.toml b/rust/kcl-derive-docs/Cargo.toml index 61a95bb67..522a7d895 100644 --- a/rust/kcl-derive-docs/Cargo.toml +++ b/rust/kcl-derive-docs/Cargo.toml @@ -23,7 +23,7 @@ serde_tokenstream = "0.2" syn = { version = "2.0.96", features = ["full"] } [dev-dependencies] -anyhow = "1.0.95" +anyhow = { workspace = true } expectorate = "1.1.0" pretty_assertions = "1.4.1" rustfmt-wrapper = "0.2.1" diff --git a/rust/kcl-lib/Cargo.toml b/rust/kcl-lib/Cargo.toml index cdf62ecbc..c80edc418 100644 --- a/rust/kcl-lib/Cargo.toml +++ b/rust/kcl-lib/Cargo.toml @@ -11,9 +11,9 @@ keywords = ["kcl", "KittyCAD", "CAD"] # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -anyhow = { version = "1.0.95", features = ["backtrace"] } +anyhow = { workspace = true, features = ["backtrace"] } async-recursion = "1.1.1" -async-trait = "0.1.85" +async-trait = {workspace = true} base64 = "0.22.1" chrono = "0.4.38" clap = { version = "4.5.27", default-features = false, optional = true, features = [ @@ -37,10 +37,10 @@ kittycad = { workspace = true } kittycad-modeling-cmds = { workspace = true } lazy_static = "1.5.0" measurements = "0.11.0" -miette = "7.5.0" +miette = { workspace = true } mime_guess = "2.0.5" parse-display = "0.9.1" -pyo3 = { version = "0.22.6", optional = true } +pyo3 = { workspace = true, optional = true } regex = "1.11.1" reqwest = { version = "0.12", default-features = false, features = [ "stream", diff --git a/rust/kcl-lib/README.md b/rust/kcl-lib/README.md index 0d4e6577b..b5e8cb6eb 100644 --- a/rust/kcl-lib/README.md +++ b/rust/kcl-lib/README.md @@ -1,4 +1,4 @@ -# KCL +# kcl-lib Our language for defining geometry and working with our Geometry Engine efficiently. Short for KittyCAD Language, named after our Design API. diff --git a/rust/kcl-python-bindings/Cargo.toml b/rust/kcl-python-bindings/Cargo.toml new file mode 100644 index 000000000..6c2762b9d --- /dev/null +++ b/rust/kcl-python-bindings/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "kcl-python-bindings" +version = "0.3.45" +edition = "2021" +repository = "https://github.com/kittycad/modeling-app" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html +[lib] +name = "kcl" +crate-type = ["cdylib"] + +[dependencies] +anyhow = { workspace = true } +kcl-lib = { path = "../kcl-lib", features = [ + "pyo3", + "engine", + "disable-println", +] } +#kcl-lib = { path = "../modeling-app/src/wasm-lib/kcl", default-features = false, features = ["pyo3", "engine", "disable-println"] } +kittycad-modeling-cmds = { workspace = true } +miette = { workspace = true, features = ["fancy"] } +pyo3 = { workspace = true, features = ["serde", "experimental-async"] } +serde = { workspace = true } +serde_json = { workspace = true } +tokio = { workspace = true } +uuid = { workspace = true, features = ["v4"] } diff --git a/rust/kcl-python-bindings/README.md b/rust/kcl-python-bindings/README.md new file mode 100644 index 000000000..cfafaae93 --- /dev/null +++ b/rust/kcl-python-bindings/README.md @@ -0,0 +1,39 @@ +# kcl-python-bindings + +Python bindings to the rust kcl-lib crate. + +## Usage + +The [tests.py](tests/tests.py) file contains examples of how to use the library. + +## Development + +We use [maturin](https://github.com/PyO3/maturin) for this project. + +You can either download binaries from the [latest release](https://github.com/PyO3/maturin/releases/latest) or install it with [pipx](https://pypa.github.io/pipx/): + +```shell +pipx install maturin +``` + +> [!NOTE] +> +> `pip install maturin` should also work if you don't want to use pipx. + +There are four main commands: + +- `maturin publish` builds the crate into python packages and publishes them to pypi. +- `maturin build` builds the wheels and stores them in a folder (`target/wheels` by default), but doesn't upload them. It's possible to upload those with [twine](https://github.com/pypa/twine) or `maturin upload`. +- `maturin develop` builds the crate and installs it as a python module directly in the current virtualenv. Note that while `maturin develop` is faster, it doesn't support all the feature that running `pip install` after `maturin build` supports. + +`pyo3` bindings are automatically detected. +`maturin` doesn't need extra configuration files and doesn't clash with an existing setuptools-rust or milksnake configuration. + +### Releasing a new version + +1. Make sure the `Cargo.toml` has the new version you want to release. +2. Run `make tag` this is just an easy command for making a tag formatted + correctly with the version. +3. Push the tag (the result of `make tag` gives instructions for this) +4. Everything else is triggered from the tag push. Just make sure all the tests + pass on the `main` branch before making and pushing a new tag. diff --git a/rust/kcl-python-bindings/files/box_with_linter_errors.kcl b/rust/kcl-python-bindings/files/box_with_linter_errors.kcl new file mode 100644 index 000000000..c9080939b --- /dev/null +++ b/rust/kcl-python-bindings/files/box_with_linter_errors.kcl @@ -0,0 +1,14 @@ +// A 25x25x50 box + +const box_width = 25 +const box_depth = 25 +const box_height = 50 + +const box_sketch = startSketchOn('XY') + |> startProfileAt([0, 0], %) + |> xLine(box_width, %, $line1) + |> yLine(box_depth, %, $line2) + |> xLineTo(profileStartX(%), %, $line3) + |> close(%, $line4) + +const box3D = extrude(box_sketch, length = box_height) \ No newline at end of file diff --git a/rust/kcl-python-bindings/files/parse_file_error/main.kcl b/rust/kcl-python-bindings/files/parse_file_error/main.kcl new file mode 100644 index 000000000..b13fa34ba --- /dev/null +++ b/rust/kcl-python-bindings/files/parse_file_error/main.kcl @@ -0,0 +1 @@ +import thing from 'thing.kcl' diff --git a/rust/kcl-python-bindings/files/parse_file_error/thing.kcl b/rust/kcl-python-bindings/files/parse_file_error/thing.kcl new file mode 100644 index 000000000..47b209720 --- /dev/null +++ b/rust/kcl-python-bindings/files/parse_file_error/thing.kcl @@ -0,0 +1 @@ +lksjndflsskjfnak;jfna## diff --git a/rust/kcl-python-bindings/files/walkie-talkie/antenna.kcl b/rust/kcl-python-bindings/files/walkie-talkie/antenna.kcl new file mode 100644 index 000000000..291c77098 --- /dev/null +++ b/rust/kcl-python-bindings/files/walkie-talkie/antenna.kcl @@ -0,0 +1,50 @@ +// Antenna + + +// Set units +@settings(defaultLengthUnit = in) + + +// import constants +import height, width, antennaBaseWidth, antennaBaseHeight, antennaTopWidth, antennaTopHeight from "globals.kcl" + +// Calculate the origin +origin = [-width / 2 + .45, -0.10] + +// Create the antenna +antennaX = origin[0] +antennaY = origin[1] + +antennaPlane = { + plane = { + origin = { x = 0, y = 0, z = height / 2 }, + xAxis = { x = 1, y = 0, z = 0 }, + yAxis = { x = 0, y = 1, z = 0 }, + zAxis = { x = 0, y = 0, z = 1 } + } +} + +// Create the antenna base sketch +sketch001 = startSketchOn(antennaPlane) + |> startProfileAt([origin[0], origin[1]], %) + |> line(end = [antennaBaseWidth, 0]) + |> line(end = [0, -antennaBaseHeight]) + |> line(end = [-antennaBaseWidth, 0]) + |> close() + +// Create the antenna top sketch +loftPlane = offsetPlane('XY', offset = height / 2 + 3) + +sketch002 = startSketchOn(loftPlane) + |> startProfileAt([ + origin[0] + (antennaBaseWidth - antennaTopWidth) / 2, + origin[1] - ((antennaBaseHeight - antennaTopHeight) / 2) + ], %) + |> xLine(antennaTopWidth, %) + |> yLine(-antennaTopHeight, %) + |> xLine(-antennaTopWidth, %) + |> close() + +// Create the antenna using a loft +loft([sketch001, sketch002]) + |> appearance(color = "#000000") diff --git a/rust/kcl-python-bindings/files/walkie-talkie/body.kcl b/rust/kcl-python-bindings/files/walkie-talkie/body.kcl new file mode 100644 index 000000000..605e0eb12 --- /dev/null +++ b/rust/kcl-python-bindings/files/walkie-talkie/body.kcl @@ -0,0 +1,80 @@ +// Walkie talkie body + + +// Set units +@settings(defaultLengthUnit = in) + + +// Import constants +import height, width, thickness, chamferLength, offset, screenWidth, screenHeight, screenYPosition, screenDepth, speakerBoxWidth, speakerBoxHeight from "globals.kcl" + +bodySketch = startSketchOn('XZ') + |> startProfileAt([-width / 2, height / 2], %) + |> xLine(width, %, $chamfer1) + |> yLine(-height, %, $chamfer2) + |> xLine(-width, %, $chamfer3) + |> close(tag = $chamfer4) +bodyExtrude = extrude(bodySketch, length = thickness) + |> chamfer( + length = chamferLength, + tags = [ + getNextAdjacentEdge(chamfer1), + getNextAdjacentEdge(chamfer2), + getNextAdjacentEdge(chamfer3), + getNextAdjacentEdge(chamfer4) + ] + ) + +// Define the offset for the indentation +sketch002 = startSketchOn(bodyExtrude, 'END') + |> startProfileAt([ + -width / 2 + offset, + height / 2 - (chamferLength + offset / 2 * cos(toRadians(45))) + ], %) + |> angledLineToY({ angle = 45, to = height / 2 - offset }, %) + |> line(endAbsolute = [ + width / 2 - (chamferLength + offset / 2 * cos(toRadians(45))), + height / 2 - offset + ]) + |> angledLineToX({ angle = -45, to = width / 2 - offset }, %) + |> line(endAbsolute = [ + width / 2 - offset, + -(height / 2 - (chamferLength + offset / 2 * cos(toRadians(45)))) + ]) + |> angledLineToY({ + angle = -135, + to = -height / 2 + offset + }, %) + |> line(endAbsolute = [ + -(width / 2 - (chamferLength + offset / 2 * cos(toRadians(45)))), + -height / 2 + offset + ]) + |> angledLineToX({ + angle = -225, + to = -width / 2 + offset + }, %) + |> close() +extrude002 = extrude(sketch002, length = -0.0625) + +// Create the pocket for the screen +sketch003 = startSketchOn(extrude002, 'start') + |> startProfileAt([-screenWidth / 2, screenYPosition], %) + |> xLine(screenWidth, %, $seg01) + |> yLine(-screenHeight, %) + |> xLine(-segLen(seg01), %) + |> line(endAbsolute = [profileStartX(%), profileStartY(%)]) + |> close() +extrude003 = extrude(sketch003, length = screenDepth) + +// Create the speaker box +sketch004 = startSketchOn(extrude002, 'start') + |> startProfileAt([-1.25 / 2, -.125], %) + |> xLine(speakerBoxWidth, %) + |> yLine(-speakerBoxHeight, %) + |> xLine(-speakerBoxWidth, %) + |> close() +extrude(sketch004, length = -.5) + |> appearance( + color = "#277bb0", + ) + diff --git a/rust/kcl-python-bindings/files/walkie-talkie/button.kcl b/rust/kcl-python-bindings/files/walkie-talkie/button.kcl new file mode 100644 index 000000000..7011a77f1 --- /dev/null +++ b/rust/kcl-python-bindings/files/walkie-talkie/button.kcl @@ -0,0 +1,38 @@ +// Walkie Talkie button + +// Set units +@settings(defaultLengthUnit = in) + +// Import constants +import screenHeight, buttonWidth, tolerance, buttonHeight, buttonThickness from 'globals.kcl' + + +// Create a function for the button +export fn button(origin, rotation, plane) { + buttonSketch = startSketchOn(plane) + |> startProfileAt([origin[0], origin[1]], %) + |> angledLine({ + angle = 180 + rotation, + length = buttonWidth + }, %, $tag1) + |> angledLine({ + angle = 270 + rotation, + length = buttonHeight + }, %, $tag2) + |> angledLine({ + angle = 0 + rotation, + length = buttonWidth + }, %) + |> close() + buttonExtrude = extrude(buttonSketch, length = buttonThickness) + |> chamfer( + length = .050, + tags = [ + getNextAdjacentEdge(tag1), + getNextAdjacentEdge(tag2) + ] + ) + |> appearance(color = "#ff0000") + + return buttonExtrude +} diff --git a/rust/kcl-python-bindings/files/walkie-talkie/case.kcl b/rust/kcl-python-bindings/files/walkie-talkie/case.kcl new file mode 100644 index 000000000..69a806cc0 --- /dev/null +++ b/rust/kcl-python-bindings/files/walkie-talkie/case.kcl @@ -0,0 +1,85 @@ +// Walkie talkie case + + +// Set units +@settings(defaultLengthUnit = in) + + +// Import constants and Zoo logo +import width, height, chamferLength, offset, screenWidth, screenHeight, screenYPosition, screenDepth, speakerBoxWidth, speakerBoxHeight, squareHoleSideLength, caseTolerance from "globals.kcl" +import zLogo, oLogo, oLogo2 from "zoo-logo.kcl" + +plane = offsetPlane("XZ", offset = 1) + +fn screenHole(sketchStart) { + sketch006 = startSketchOn(sketchStart) + |> startProfileAt([-screenWidth / 2, screenYPosition], %) + |> xLine(screenWidth, %) + |> yLine(-screenHeight, %) + |> xLine(-screenWidth, %) + |> line(endAbsolute = [profileStartX(%), profileStartY(%)]) + |> close() + return sketch006 +} + +fn squareHolePattern(plane, x, y) { + fn transformX(i) { + return { translate = [.125 * i, 0] } + } + fn transformY(i) { + return { translate = [0, -.125 * i] } + } + squareHolePatternSketch = startSketchOn(plane) + |> startProfileAt([-x, -y], %) + |> line(end = [squareHoleSideLength / 2, 0]) + |> line(end = [0, -squareHoleSideLength / 2]) + |> line(end = [-squareHoleSideLength / 2, 0]) + |> close() + |> patternTransform2d(instances = 13, transform = transformX) + |> patternTransform2d(instances = 11, transform = transformY) + return squareHolePatternSketch +} +sketch005 = startSketchOn(offsetPlane("XZ", offset = 1)) + |> startProfileAt([ + -width / 2 + offset + caseTolerance, + height / 2 - (chamferLength + (offset + caseTolerance) / 2 * cos(toRadians(45))) + ], %) + |> angledLineToY({ + angle = 45, + to = height / 2 - (offset + caseTolerance) + }, %) + |> line(endAbsolute = [ + width / 2 - (chamferLength + (offset + caseTolerance) / 2 * cos(toRadians(45))), + height / 2 - (offset + caseTolerance) + ]) + |> angledLineToX({ + angle = -45, + to = width / 2 - (offset + caseTolerance) + }, %) + |> line(endAbsolute = [ + width / 2 - (offset + caseTolerance), + -(height / 2 - (chamferLength + (offset + caseTolerance) / 2 * cos(toRadians(45)))) + ]) + |> angledLineToY({ + angle = -135, + to = -height / 2 + offset + caseTolerance + }, %) + |> line(endAbsolute = [ + -(width / 2 - (chamferLength + (offset + caseTolerance) / 2 * cos(toRadians(45)))), + -height / 2 + offset + caseTolerance + ]) + |> angledLineToX({ + angle = -225, + to = -width / 2 + offset + caseTolerance + }, %) + |> close() + |> hole(screenHole(plane), %) + |> hole(squareHolePattern(plane, .75, .125), %) + |> hole(zLogo(plane, [-.30, -1.825], .20), %) + |> hole(oLogo(plane, [-.075, -1.825], .20), %) + |> hole(oLogo2(plane, [-.075, -1.825], .20), %) + |> hole(oLogo(plane, [.175, -1.825], .20), %) + |> hole(oLogo2(plane, [.175, -1.825], .20), %) + +extrude(sketch005, length = -0.0625) + |> appearance(color = '#D0FF01', metalness = 0, roughness = 50) diff --git a/rust/kcl-python-bindings/files/walkie-talkie/globals.kcl b/rust/kcl-python-bindings/files/walkie-talkie/globals.kcl new file mode 100644 index 000000000..3fef987c7 --- /dev/null +++ b/rust/kcl-python-bindings/files/walkie-talkie/globals.kcl @@ -0,0 +1,42 @@ +// Global constants for the walkie talkie + +// Set units +@settings(defaultLengthUnit = in) + +// body +export height = 4 +export width = 2.5 +export thickness = 1 +export chamferLength = .325 +export offset = .125 +export screenWidth = 1.75 +export screenHeight = 1 +export screenYPosition = height / 2 - 0.75 +export screenDepth = -.0625 +export speakerBoxWidth = 1.25 +export speakerBoxHeight = 1.25 + +// antenna +export antennaBaseWidth = .5 +export antennaBaseHeight = .25 +export antennaTopWidth = .30 +export antennaTopHeight = .05 + +// button +export buttonWidth = .15 +export tolerance = 0.020 +export buttonHeight = screenHeight / 2 - tolerance +export buttonThickness = .040 + +// case +export squareHoleSideLength = 0.0625 +export caseTolerance = 0.010 + +// knob +export knobDiameter = .5 +export knobHeight = .25 +export knobRadius = 0.050 + +// talk-button +export talkButtonSideLength = 0.5 +export talkButtonHeight = 0.050 \ No newline at end of file diff --git a/rust/kcl-python-bindings/files/walkie-talkie/knob.kcl b/rust/kcl-python-bindings/files/walkie-talkie/knob.kcl new file mode 100644 index 000000000..6ce0ac390 --- /dev/null +++ b/rust/kcl-python-bindings/files/walkie-talkie/knob.kcl @@ -0,0 +1,38 @@ +// Walkie talkie knob + + +// Set units +@settings(defaultLengthUnit = in) + + +// Import constants +import width, thickness, height, knobDiameter, knobHeight, knobRadius from "globals.kcl" + +// Define the plane for the knob +knobPlane = { + plane = { + origin = { + x = width / 2 - 0.70, + y = -thickness / 2, + z = height / 2 + }, + xAxis = { x = 1, y = 0, z = 0 }, + yAxis = { x = 0, y = 0, z = 1 }, + zAxis = { x = 0, y = 1, z = 0 } + } +} + +// Create the knob sketch and revolve +startSketchOn(knobPlane) + |> startProfileAt([0.0001, 0], %) + |> xLine(knobDiameter / 2, %) + |> yLine(knobHeight - 0.05, %) + |> arc({ + angleStart = 0, + angleEnd = 90, + radius = .05 + }, %) + |> xLineTo(0.0001, %) + |> close() + |> revolve({ axis = "Y" }, %) + |> appearance(color = '#D0FF01', metalness = 90, roughness = 50) diff --git a/rust/kcl-python-bindings/files/walkie-talkie/main.kcl b/rust/kcl-python-bindings/files/walkie-talkie/main.kcl new file mode 100644 index 000000000..60aaad8f1 --- /dev/null +++ b/rust/kcl-python-bindings/files/walkie-talkie/main.kcl @@ -0,0 +1,50 @@ +// Walkie Talkie +// A portable, handheld two-way radio device that allows users to communicate wirelessly over short to medium distances. It operates on specific radio frequencies and features a push-to-talk button for transmitting messages, making it ideal for quick and reliable communication in outdoor, work, or emergency settings. + +// Set units +@settings(defaultLengthUnit = in) + +// Import parts and constants +import 'body.kcl' +import 'antenna.kcl' +import 'case.kcl' +import 'talk-button.kcl' as talkButton +import 'knob.kcl' +import button from "button.kcl" +import width, height, thickness, screenWidth, screenHeight, screenYPosition, tolerance from "globals.kcl" + +// Import the body +body + +// Import the case +case + +// Import the antenna +antenna + +// Import the buttons +button([ + -(screenWidth / 2 + tolerance), + screenYPosition +], 0, offsetPlane("XZ", offset = thickness)) +button([ + -(screenWidth / 2 + tolerance), + screenYPosition - (screenHeight / 2) +], 0, offsetPlane("XZ", offset = thickness)) +button([ + screenWidth / 2 + tolerance, + screenYPosition - screenHeight +], 180, offsetPlane("XZ", offset = thickness)) +button([ + screenWidth / 2 + tolerance, + screenYPosition - (screenHeight / 2) +], 180, offsetPlane("XZ", offset = thickness)) + +// Import the talk button +talkButton + +// Import the frequency knob +knob + + + diff --git a/rust/kcl-python-bindings/files/walkie-talkie/talk-button.kcl b/rust/kcl-python-bindings/files/walkie-talkie/talk-button.kcl new file mode 100644 index 000000000..1a8e4cde6 --- /dev/null +++ b/rust/kcl-python-bindings/files/walkie-talkie/talk-button.kcl @@ -0,0 +1,46 @@ +// Walkie talkie talk button + + +// Set units +@settings(defaultLengthUnit = in) + + +// Import constants +import width, thickness, talkButtonSideLength, talkButtonHeight from "globals.kcl" + +talkButtonPlane = { + plane = { + origin = { + x = width / 2, + y = -thickness / 2, + z = .5 + }, + xAxis = { x = 0, y = 1, z = 0 }, + yAxis = { x = 0, y = 0, z = 1 }, + zAxis = { x = 1, y = 0, z = 0 } + } +} + +// Create the talk button sketch +talkButtonSketch = startSketchOn(talkButtonPlane) + |> startProfileAt([ + -talkButtonSideLength / 2, + talkButtonSideLength / 2 + ], %) + |> xLine(talkButtonSideLength, %, $tag1) + |> yLine(-talkButtonSideLength, %, $tag2) + |> xLine(-talkButtonSideLength, %, $tag3) + |> close(tag = $tag4) + +// Create the talk button and apply fillets +extrude(talkButtonSketch, length = talkButtonHeight) + |> fillet( + radius = 0.050, + tags = [ + getNextAdjacentEdge(tag1), + getNextAdjacentEdge(tag2), + getNextAdjacentEdge(tag3), + getNextAdjacentEdge(tag4) + ] + ) + |> appearance(color = '#D0FF01', metalness = 90, roughness = 90) diff --git a/rust/kcl-python-bindings/files/walkie-talkie/zoo-logo.kcl b/rust/kcl-python-bindings/files/walkie-talkie/zoo-logo.kcl new file mode 100644 index 000000000..03e71643b --- /dev/null +++ b/rust/kcl-python-bindings/files/walkie-talkie/zoo-logo.kcl @@ -0,0 +1,83 @@ +// Zoo logo + +// Define a function to draw the ZOO "Z" +export fn zLogo(surface, origin, scale) { + zSketch = surface + |> startProfileAt([ + 0 + origin[0], + 0.15 * scale + origin[1] + ], %) + |> yLine(-0.15 * scale, %) + |> xLine(0.15 * scale, %) + |> angledLineToX({ + angle = 47.15, + to = 0.3 * scale + origin[0] + }, %, $seg1) + |> yLineTo(0 + origin[1], %, $seg3) + |> xLine(0.63 * scale, %) + |> yLine(0.225 * scale, %) + |> xLine(-0.57 * scale, %) + |> angledLineToX({ + angle = 47.15, + to = 0.93 * scale + origin[0] + }, %) + |> yLine(0.15 * scale, %) + |> xLine(-0.15 * scale, %) + |> angledLine({ + angle = 47.15, + length = -segLen(seg1) + }, %, $seg2) + |> yLine(segLen(seg3), %) + |> xLineTo(0 + origin[0], %) + |> yLine(-0.225 * scale, %) + |> angledLineThatIntersects({ + angle = 0, + intersectTag = seg2, + offset = 0 + }, %) + |> close() + return zSketch +} + +// Define a function to draw the ZOO "O" +export fn oLogo(surface, origin, scale) { + oSketch001 = surface + |> startProfileAt([ + .788 * scale + origin[0], + .921 * scale + origin[1] + ], %) + |> arc({ + angleStart = 47.15 + 6, + angleEnd = 47.15 - 6 + 180, + radius = .525 * scale + }, %) + |> angledLine({ angle = 47.15, length = .24 * scale }, %) + |> arc({ + angleStart = 47.15 - 11 + 180, + angleEnd = 47.15 + 11, + radius = .288 * scale + }, %) + |> close() + return oSketch001 +} + +export fn oLogo2(surface, origin, scale) { + oSketch002 = surface + |> startProfileAt([ + .16 * scale + origin[0], + .079 * scale + origin[1] + ], %) + |> arc({ + angleStart = 47.15 + 6 - 180, + angleEnd = 47.15 - 6, + radius = .525 * scale + }, %) + |> angledLine({ angle = 47.15, length = -.24 * scale }, %) + |> arc({ + angleStart = 47.15 - 11, + angleEnd = 47.15 + 11 - 180, + radius = .288 * scale + }, %) + |> close() + return oSketch002 +} \ No newline at end of file diff --git a/rust/kcl-python-bindings/justfile b/rust/kcl-python-bindings/justfile new file mode 100644 index 000000000..dcc9daf10 --- /dev/null +++ b/rust/kcl-python-bindings/justfile @@ -0,0 +1,8 @@ +test: + uv pip install .[test] + uv run pytest tests/tests.py +setup-uv: + uv python install + uv venv .venv + echo "VIRTUAL_ENV=.venv" >> $GITHUB_ENV + echo "$PWD/.venv/bin" >> $GITHUB_PATH diff --git a/rust/kcl-python-bindings/pyproject.toml b/rust/kcl-python-bindings/pyproject.toml new file mode 100644 index 000000000..a206d035f --- /dev/null +++ b/rust/kcl-python-bindings/pyproject.toml @@ -0,0 +1,22 @@ +[build-system] +requires = ["maturin>=1.6,<2.0"] +build-backend = "maturin" + +[project] +name = "zoo-kcl" +requires-python = ">=3.8" +classifiers = [ + "Programming Language :: Rust", + "Programming Language :: Python :: Implementation :: CPython", + "Programming Language :: Python :: Implementation :: PyPy", +] +dynamic = ["version"] + +[project.optional-dependencies] +test = [ + "pytest", + "pytest-asyncio", +] + +[tool.maturin] +features = ["pyo3/extension-module"] diff --git a/rust/kcl-python-bindings/src/lib.rs b/rust/kcl-python-bindings/src/lib.rs new file mode 100644 index 000000000..4de7b5eb8 --- /dev/null +++ b/rust/kcl-python-bindings/src/lib.rs @@ -0,0 +1,525 @@ +#![allow(clippy::useless_conversion)] +use anyhow::Result; +use kcl_lib::{ + lint::{checks, Discovered}, + ExecutorContext, UnitLength, +}; +use pyo3::{ + prelude::PyModuleMethods, pyclass, pyfunction, pymethods, pymodule, types::PyModule, wrap_pyfunction, Bound, PyErr, + PyResult, +}; +use serde::{Deserialize, Serialize}; + +fn tokio() -> &'static tokio::runtime::Runtime { + use std::sync::OnceLock; + static RT: OnceLock = OnceLock::new(); + RT.get_or_init(|| tokio::runtime::Runtime::new().unwrap()) +} + +fn into_miette(error: kcl_lib::KclErrorWithOutputs) -> PyErr { + let report = error.clone().into_miette_report_with_outputs().unwrap(); + let report = miette::Report::new(report); + pyo3::exceptions::PyException::new_err(format!("{:?}", report)) +} + +fn into_miette_for_parse(filename: &str, input: &str, error: kcl_lib::KclError) -> PyErr { + let report = kcl_lib::Report { + kcl_source: input.to_string(), + error: error.clone(), + filename: filename.to_string(), + }; + let report = miette::Report::new(report); + pyo3::exceptions::PyException::new_err(format!("{:?}", report)) +} + +/// The variety of image formats snapshots may be exported to. +#[derive(Serialize, Deserialize, PartialEq, Hash, Debug, Clone, Copy)] +#[pyclass(eq, eq_int)] +#[serde(rename_all = "lowercase")] +pub enum ImageFormat { + /// .png format + Png, + /// .jpeg format + Jpeg, +} + +impl From for kittycad_modeling_cmds::ImageFormat { + fn from(format: ImageFormat) -> Self { + match format { + ImageFormat::Png => kittycad_modeling_cmds::ImageFormat::Png, + ImageFormat::Jpeg => kittycad_modeling_cmds::ImageFormat::Jpeg, + } + } +} + +/// A file that was exported from the engine. +#[derive(Serialize, Deserialize, PartialEq, Debug, Clone)] +#[pyclass] +pub struct ExportFile { + /// Binary contents of the file. + pub contents: Vec, + /// Name of the file. + pub name: String, +} + +impl From for ExportFile { + fn from(file: kittycad_modeling_cmds::shared::ExportFile) -> Self { + ExportFile { + contents: file.contents.0, + name: file.name, + } + } +} + +impl From for ExportFile { + fn from(file: kittycad_modeling_cmds::websocket::RawFile) -> Self { + ExportFile { + contents: file.contents, + name: file.name, + } + } +} + +#[pymethods] +impl ExportFile { + #[getter] + fn contents(&self) -> Vec { + self.contents.clone() + } + + #[getter] + fn name(&self) -> String { + self.name.clone() + } +} + +/// The valid types of output file formats. +#[derive(Serialize, Deserialize, PartialEq, Hash, Debug, Clone)] +#[pyclass(eq, eq_int)] +#[serde(rename_all = "lowercase")] +pub enum FileExportFormat { + /// Autodesk Filmbox (FBX) format. + Fbx, + /// Binary glTF 2.0. + /// + /// This is a single binary with .glb extension. + /// + /// This is better if you want a compressed format as opposed to the human readable glTF that lacks + /// compression. + Glb, + /// glTF 2.0. Embedded glTF 2.0 (pretty printed). + /// + /// Single JSON file with .gltf extension binary data encoded as base64 data URIs. + /// + /// The JSON contents are pretty printed. + /// + /// It is human readable, single file, and you can view the diff easily in a + /// git commit. + Gltf, + /// The OBJ file format. It may or + /// may not have an an attached material (mtl // mtllib) within the file, but we + /// interact with it as if it does not. + Obj, + /// The PLY file format. + Ply, + /// The STEP file format. + Step, + /// The STL file format. + Stl, +} + +fn get_output_format( + format: &FileExportFormat, + src_unit: kittycad_modeling_cmds::units::UnitLength, +) -> kittycad_modeling_cmds::format::OutputFormat3d { + // Zoo co-ordinate system. + // + // * Forward: -Y + // * Up: +Z + // * Handedness: Right + let coords = kittycad_modeling_cmds::coord::System { + forward: kittycad_modeling_cmds::coord::AxisDirectionPair { + axis: kittycad_modeling_cmds::coord::Axis::Y, + direction: kittycad_modeling_cmds::coord::Direction::Negative, + }, + up: kittycad_modeling_cmds::coord::AxisDirectionPair { + axis: kittycad_modeling_cmds::coord::Axis::Z, + direction: kittycad_modeling_cmds::coord::Direction::Positive, + }, + }; + + match format { + FileExportFormat::Fbx => { + kittycad_modeling_cmds::format::OutputFormat3d::Fbx(kittycad_modeling_cmds::format::fbx::export::Options { + storage: kittycad_modeling_cmds::format::fbx::export::Storage::Binary, + created: None, + }) + } + FileExportFormat::Glb => kittycad_modeling_cmds::format::OutputFormat3d::Gltf( + kittycad_modeling_cmds::format::gltf::export::Options { + storage: kittycad_modeling_cmds::format::gltf::export::Storage::Binary, + presentation: kittycad_modeling_cmds::format::gltf::export::Presentation::Compact, + }, + ), + FileExportFormat::Gltf => kittycad_modeling_cmds::format::OutputFormat3d::Gltf( + kittycad_modeling_cmds::format::gltf::export::Options { + storage: kittycad_modeling_cmds::format::gltf::export::Storage::Embedded, + presentation: kittycad_modeling_cmds::format::gltf::export::Presentation::Pretty, + }, + ), + FileExportFormat::Obj => { + kittycad_modeling_cmds::format::OutputFormat3d::Obj(kittycad_modeling_cmds::format::obj::export::Options { + coords, + units: src_unit, + }) + } + FileExportFormat::Ply => { + kittycad_modeling_cmds::format::OutputFormat3d::Ply(kittycad_modeling_cmds::format::ply::export::Options { + storage: kittycad_modeling_cmds::format::ply::export::Storage::Ascii, + coords, + selection: kittycad_modeling_cmds::format::Selection::DefaultScene, + units: src_unit, + }) + } + FileExportFormat::Step => kittycad_modeling_cmds::format::OutputFormat3d::Step( + kittycad_modeling_cmds::format::step::export::Options { coords, created: None }, + ), + FileExportFormat::Stl => { + kittycad_modeling_cmds::format::OutputFormat3d::Stl(kittycad_modeling_cmds::format::stl::export::Options { + storage: kittycad_modeling_cmds::format::stl::export::Storage::Ascii, + coords, + units: src_unit, + selection: kittycad_modeling_cmds::format::Selection::DefaultScene, + }) + } + } +} + +/// Get the path to the current file from the path given, and read the code. +async fn get_code_and_file_path(path: &str) -> Result<(String, std::path::PathBuf)> { + let mut path = std::path::PathBuf::from(path); + // Check if the path is a directory, if so we want to look for a main.kcl inside. + if path.is_dir() { + path = path.join("main.kcl"); + if !path.exists() { + return Err(anyhow::anyhow!("Directory must contain a main.kcl file")); + } + } else { + // Otherwise be sure we have a kcl file. + if let Some(ext) = path.extension() { + if ext != "kcl" { + return Err(anyhow::anyhow!("File must have a .kcl extension")); + } + } + } + + let code = tokio::fs::read_to_string(&path).await?; + Ok((code, path)) +} + +async fn new_context_state(current_file: Option) -> Result<(ExecutorContext, kcl_lib::ExecState)> { + let mut settings: kcl_lib::ExecutorSettings = Default::default(); + if let Some(current_file) = current_file { + settings.with_current_file(current_file); + } + let state = kcl_lib::ExecState::new(&settings); + let ctx = ExecutorContext::new_with_client(settings, None, None).await?; + Ok((ctx, state)) +} + +/// Execute the kcl code from a file path. +#[pyfunction] +async fn execute(path: String) -> PyResult<()> { + tokio() + .spawn(async move { + let (code, path) = get_code_and_file_path(&path) + .await + .map_err(|err| pyo3::exceptions::PyException::new_err(err.to_string()))?; + let program = kcl_lib::Program::parse_no_errs(&code) + .map_err(|err| into_miette_for_parse(&path.display().to_string(), &code, err))?; + + let (ctx, mut state) = new_context_state(Some(path)) + .await + .map_err(|err| pyo3::exceptions::PyException::new_err(err.to_string()))?; + // Execute the program. + ctx.run_with_ui_outputs(&program, &mut state) + .await + .map_err(into_miette)?; + + Ok(()) + }) + .await + .map_err(|err| pyo3::exceptions::PyException::new_err(err.to_string()))? +} + +/// Execute the kcl code. +#[pyfunction] +async fn execute_code(code: String) -> PyResult<()> { + tokio() + .spawn(async move { + let program = + kcl_lib::Program::parse_no_errs(&code).map_err(|err| into_miette_for_parse("", &code, err))?; + + let (ctx, mut state) = new_context_state(None) + .await + .map_err(|err| pyo3::exceptions::PyException::new_err(err.to_string()))?; + // Execute the program. + ctx.run_with_ui_outputs(&program, &mut state) + .await + .map_err(into_miette)?; + + Ok(()) + }) + .await + .map_err(|err| pyo3::exceptions::PyException::new_err(err.to_string()))? +} + +/// Execute a kcl file and snapshot it in a specific format. +#[pyfunction] +async fn execute_and_snapshot(path: String, image_format: ImageFormat) -> PyResult> { + tokio() + .spawn(async move { + let (code, path) = get_code_and_file_path(&path) + .await + .map_err(|err| pyo3::exceptions::PyException::new_err(err.to_string()))?; + let program = kcl_lib::Program::parse_no_errs(&code) + .map_err(|err| into_miette_for_parse(&path.display().to_string(), &code, err))?; + + let (ctx, mut state) = new_context_state(Some(path)) + .await + .map_err(|err| pyo3::exceptions::PyException::new_err(err.to_string()))?; + // Execute the program. + ctx.run_with_ui_outputs(&program, &mut state) + .await + .map_err(into_miette)?; + + // Zoom to fit. + ctx.engine + .send_modeling_cmd( + uuid::Uuid::new_v4(), + kcl_lib::SourceRange::default(), + &kittycad_modeling_cmds::ModelingCmd::ZoomToFit(kittycad_modeling_cmds::ZoomToFit { + object_ids: Default::default(), + padding: 0.1, + animated: false, + }), + ) + .await?; + + // Send a snapshot request to the engine. + let resp = ctx + .engine + .send_modeling_cmd( + uuid::Uuid::new_v4(), + kcl_lib::SourceRange::default(), + &kittycad_modeling_cmds::ModelingCmd::TakeSnapshot(kittycad_modeling_cmds::TakeSnapshot { + format: image_format.into(), + }), + ) + .await?; + + let kittycad_modeling_cmds::websocket::OkWebSocketResponseData::Modeling { + modeling_response: kittycad_modeling_cmds::ok_response::OkModelingCmdResponse::TakeSnapshot(data), + } = resp + else { + return Err(pyo3::exceptions::PyException::new_err(format!( + "Unexpected response from engine: {:?}", + resp + ))); + }; + + Ok(data.contents.0) + }) + .await + .map_err(|err| pyo3::exceptions::PyException::new_err(err.to_string()))? +} + +/// Execute the kcl code and snapshot it in a specific format. +#[pyfunction] +async fn execute_code_and_snapshot(code: String, image_format: ImageFormat) -> PyResult> { + tokio() + .spawn(async move { + let program = + kcl_lib::Program::parse_no_errs(&code).map_err(|err| into_miette_for_parse("", &code, err))?; + + let (ctx, mut state) = new_context_state(None) + .await + .map_err(|err| pyo3::exceptions::PyException::new_err(err.to_string()))?; + // Execute the program. + ctx.run_with_ui_outputs(&program, &mut state) + .await + .map_err(into_miette)?; + + // Zoom to fit. + ctx.engine + .send_modeling_cmd( + uuid::Uuid::new_v4(), + kcl_lib::SourceRange::default(), + &kittycad_modeling_cmds::ModelingCmd::ZoomToFit(kittycad_modeling_cmds::ZoomToFit { + object_ids: Default::default(), + padding: 0.1, + animated: false, + }), + ) + .await?; + + // Send a snapshot request to the engine. + let resp = ctx + .engine + .send_modeling_cmd( + uuid::Uuid::new_v4(), + kcl_lib::SourceRange::default(), + &kittycad_modeling_cmds::ModelingCmd::TakeSnapshot(kittycad_modeling_cmds::TakeSnapshot { + format: image_format.into(), + }), + ) + .await?; + + let kittycad_modeling_cmds::websocket::OkWebSocketResponseData::Modeling { + modeling_response: kittycad_modeling_cmds::ok_response::OkModelingCmdResponse::TakeSnapshot(data), + } = resp + else { + return Err(pyo3::exceptions::PyException::new_err(format!( + "Unexpected response from engine: {:?}", + resp + ))); + }; + + Ok(data.contents.0) + }) + .await + .map_err(|err| pyo3::exceptions::PyException::new_err(err.to_string()))? +} + +/// Execute a kcl file and export it to a specific file format. +#[pyfunction] +async fn execute_and_export(path: String, export_format: FileExportFormat) -> PyResult> { + tokio() + .spawn(async move { + let (code, path) = get_code_and_file_path(&path) + .await + .map_err(|err| pyo3::exceptions::PyException::new_err(err.to_string()))?; + let program = kcl_lib::Program::parse_no_errs(&code) + .map_err(|err| into_miette_for_parse(&path.display().to_string(), &code, err))?; + let settings = program.meta_settings()?.unwrap_or_default(); + let units: UnitLength = settings.default_length_units.into(); + + let (ctx, mut state) = new_context_state(Some(path)) + .await + .map_err(|err| pyo3::exceptions::PyException::new_err(err.to_string()))?; + // Execute the program. + ctx.run_with_ui_outputs(&program, &mut state) + .await + .map_err(into_miette)?; + + // This will not return until there are files. + let resp = ctx + .engine + .send_modeling_cmd( + uuid::Uuid::new_v4(), + kcl_lib::SourceRange::default(), + &kittycad_modeling_cmds::ModelingCmd::Export(kittycad_modeling_cmds::Export { + entity_ids: vec![], + format: get_output_format(&export_format, units.into()), + }), + ) + .await?; + + let kittycad_modeling_cmds::websocket::OkWebSocketResponseData::Export { files } = resp else { + return Err(pyo3::exceptions::PyException::new_err(format!( + "Unexpected response from engine: {:?}", + resp + ))); + }; + + Ok(files.into_iter().map(ExportFile::from).collect()) + }) + .await + .map_err(|err| pyo3::exceptions::PyException::new_err(err.to_string()))? +} + +/// Execute the kcl code and export it to a specific file format. +#[pyfunction] +async fn execute_code_and_export(code: String, export_format: FileExportFormat) -> PyResult> { + tokio() + .spawn(async move { + let program = + kcl_lib::Program::parse_no_errs(&code).map_err(|err| into_miette_for_parse("", &code, err))?; + let settings = program.meta_settings()?.unwrap_or_default(); + let units: UnitLength = settings.default_length_units.into(); + + let (ctx, mut state) = new_context_state(None) + .await + .map_err(|err| pyo3::exceptions::PyException::new_err(err.to_string()))?; + // Execute the program. + ctx.run_with_ui_outputs(&program, &mut state) + .await + .map_err(into_miette)?; + + // This will not return until there are files. + let resp = ctx + .engine + .send_modeling_cmd( + uuid::Uuid::new_v4(), + kcl_lib::SourceRange::default(), + &kittycad_modeling_cmds::ModelingCmd::Export(kittycad_modeling_cmds::Export { + entity_ids: vec![], + format: get_output_format(&export_format, units.into()), + }), + ) + .await?; + + let kittycad_modeling_cmds::websocket::OkWebSocketResponseData::Export { files } = resp else { + return Err(pyo3::exceptions::PyException::new_err(format!( + "Unexpected response from engine: {:?}", + resp + ))); + }; + + Ok(files.into_iter().map(ExportFile::from).collect()) + }) + .await + .map_err(|err| pyo3::exceptions::PyException::new_err(err.to_string()))? +} + +/// Format the kcl code. +#[pyfunction] +fn format(code: String) -> PyResult { + let program = kcl_lib::Program::parse_no_errs(&code).map_err(|err| into_miette_for_parse("", &code, err))?; + let recasted = program.recast(); + + Ok(recasted) +} + +/// Lint the kcl code. +#[pyfunction] +fn lint(code: String) -> PyResult> { + let program = kcl_lib::Program::parse_no_errs(&code).map_err(|err| into_miette_for_parse("", &code, err))?; + let lints = program + .lint(checks::lint_variables) + .map_err(|err| pyo3::exceptions::PyException::new_err(err.to_string()))?; + + Ok(lints) +} + +/// The kcl python module. +#[pymodule] +fn kcl(m: &Bound<'_, PyModule>) -> PyResult<()> { + // Add our types to the module. + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + + // Add our functions to the module. + m.add_function(wrap_pyfunction!(execute, m)?)?; + m.add_function(wrap_pyfunction!(execute_code, m)?)?; + m.add_function(wrap_pyfunction!(execute_and_snapshot, m)?)?; + m.add_function(wrap_pyfunction!(execute_code_and_snapshot, m)?)?; + m.add_function(wrap_pyfunction!(execute_and_export, m)?)?; + m.add_function(wrap_pyfunction!(execute_code_and_export, m)?)?; + m.add_function(wrap_pyfunction!(format, m)?)?; + m.add_function(wrap_pyfunction!(lint, m)?)?; + Ok(()) +} diff --git a/rust/kcl-python-bindings/tests/tests.py b/rust/kcl-python-bindings/tests/tests.py new file mode 100755 index 000000000..13b7ce890 --- /dev/null +++ b/rust/kcl-python-bindings/tests/tests.py @@ -0,0 +1,140 @@ +#!/usr/bin/env python3 +import os + +import kcl +import pytest + +# Get the path to this script's parent directory. +files_dir = os.path.join(os.path.dirname(os.path.realpath(__file__)), "..", "files") +kcl_dir = os.path.join( + os.path.dirname(os.path.realpath(__file__)), "..", "..", "kcl-lib" +) +lego_file = os.path.join(kcl_dir, "e2e", "executor", "inputs", "lego.kcl") +walkie_talkie_dir = os.path.join(files_dir, "walkie-talkie") + + +@pytest.mark.asyncio +async def test_kcl_execute_with_exception(): + # Read from a file. + try: + await kcl.execute(os.path.join(files_dir, "parse_file_error")) + except Exception as e: + assert e is not None + assert len(str(e)) > 0 + assert "lksjndflsskjfnak;jfna##" in str(e) + + +@pytest.mark.asyncio +async def test_kcl_execute(): + # Read from a file. + await kcl.execute(lego_file) + + +@pytest.mark.asyncio +async def test_kcl_execute_code(): + # Read from a file. + with open(lego_file, "r") as f: + code = str(f.read()) + assert code is not None + assert len(code) > 0 + await kcl.execute_code(code) + + +@pytest.mark.asyncio +async def test_kcl_execute_code_and_snapshot(): + # Read from a file. + with open(lego_file, "r") as f: + code = str(f.read()) + assert code is not None + assert len(code) > 0 + image_bytes = await kcl.execute_code_and_snapshot(code, kcl.ImageFormat.Jpeg) + assert image_bytes is not None + assert len(image_bytes) > 0 + + +@pytest.mark.asyncio +async def test_kcl_execute_code_and_export(): + # Read from a file. + with open(lego_file, "r") as f: + code = str(f.read()) + assert code is not None + assert len(code) > 0 + files = await kcl.execute_code_and_export(code, kcl.FileExportFormat.Step) + assert files is not None + assert len(files) > 0 + assert files[0] is not None + name = files[0].name + contents = files[0].contents + assert name is not None + assert len(name) > 0 + assert contents is not None + assert len(contents) > 0 + + +@pytest.mark.asyncio +async def test_kcl_execute_dir_assembly(): + # Read from a file. + await kcl.execute(walkie_talkie_dir) + + +@pytest.mark.asyncio +async def test_kcl_execute_and_snapshot(): + # Read from a file. + image_bytes = await kcl.execute_and_snapshot(lego_file, kcl.ImageFormat.Jpeg) + assert image_bytes is not None + assert len(image_bytes) > 0 + + +@pytest.mark.asyncio +async def test_kcl_execute_and_snapshot_dir(): + # Read from a file. + image_bytes = await kcl.execute_and_snapshot( + walkie_talkie_dir, kcl.ImageFormat.Jpeg + ) + assert image_bytes is not None + assert len(image_bytes) > 0 + + +@pytest.mark.asyncio +async def test_kcl_execute_and_export(): + # Read from a file. + files = await kcl.execute_and_export(lego_file, kcl.FileExportFormat.Step) + assert files is not None + assert len(files) > 0 + assert files[0] is not None + name = files[0].name + contents = files[0].contents + assert name is not None + assert len(name) > 0 + assert contents is not None + assert len(contents) > 0 + + +def test_kcl_format(): + # Read from a file. + with open(lego_file, "r") as f: + code = str(f.read()) + assert code is not None + assert len(code) > 0 + formatted_code = kcl.format(code) + assert formatted_code is not None + assert len(formatted_code) > 0 + + +def test_kcl_lint(): + # Read from a file. + with open(os.path.join(files_dir, "box_with_linter_errors.kcl"), "r") as f: + code = str(f.read()) + assert code is not None + assert len(code) > 0 + lints = kcl.lint(code) + assert lints is not None + assert len(lints) > 0 + description = lints[0].description + assert description is not None + assert len(description) > 0 + finding = lints[0].finding + assert finding is not None + finding_title = finding.title + assert finding_title is not None + assert len(finding_title) > 0 diff --git a/rust/kcl-to-core/Cargo.toml b/rust/kcl-to-core/Cargo.toml index 5a3bd047d..3576e0c5f 100644 --- a/rust/kcl-to-core/Cargo.toml +++ b/rust/kcl-to-core/Cargo.toml @@ -13,8 +13,8 @@ name = "kcl-to-core" path = "src/tool.rs" [dependencies] -anyhow = "1" -async-trait = "0.1.85" +anyhow = { workspace = true } +async-trait = { workspace = true } indexmap = { workspace = true } kcl-lib = { path = "../kcl-lib" } kittycad = { workspace = true, features = ["clap"] }