Skip to content

Commit 7ce6320

Browse files
authored
KCL python: add required classes to module (#7928)
- The function I added required a parameter of type SnapshotOptions but I didn't make that type available through our module - Ensure that you can set SnapshotOptions in both the "execute this KCL string" function and the "execute this KCL file path" function. - Unit test
1 parent a3dcd1c commit 7ce6320

File tree

4 files changed

+136
-73
lines changed

4 files changed

+136
-73
lines changed

rust/kcl-python-bindings/justfile

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,19 @@
11
test:
22
uv pip install .[test]
33
uv run pytest tests/tests.py
4+
5+
test-filter name:
6+
uv pip install .[test]
7+
uv run pytest tests/tests.py::{{name}}
8+
49
setup-uv:
510
uv python install
611
uv venv .venv
712
echo "VIRTUAL_ENV=.venv" >> $GITHUB_ENV
813
echo "$PWD/.venv/bin" >> $GITHUB_PATH
14+
15+
lint:
16+
uv run ruff check
17+
18+
fmt:
19+
uv run ruff format

rust/kcl-python-bindings/src/bridge.rs

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,40 @@
11
//! For creating equivalents to the core kcmc types that support Python.
22
use kittycad_modeling_cmds as kcmc;
3-
use pyo3::pyclass;
3+
use pyo3::{pyclass, pymethods};
44
use serde::{Deserialize, Serialize};
55

6-
#[derive(Serialize, Deserialize, PartialEq, Debug, Clone)]
6+
#[derive(Serialize, Deserialize, PartialEq, Debug, Clone, Copy)]
77
#[pyclass]
88
pub struct Point3d {
9-
x: f32,
10-
y: f32,
11-
z: f32,
9+
pub x: f32,
10+
pub y: f32,
11+
pub z: f32,
1212
}
1313

14-
#[derive(Serialize, Deserialize, PartialEq, Debug, Clone)]
14+
#[pymethods]
15+
impl Point3d {
16+
#[new]
17+
/// Create a new point from its 3 components x, y and z
18+
/// All of them take floating point values.
19+
fn new(x: f32, y: f32, z: f32) -> Self {
20+
Self { x, y, z }
21+
}
22+
}
23+
24+
#[derive(Serialize, Deserialize, PartialEq, Debug, Clone, Copy)]
1525
#[pyclass]
1626
pub struct CameraLookAt {
17-
vantage: Point3d,
18-
center: Point3d,
19-
up: Point3d,
27+
pub vantage: Point3d,
28+
pub center: Point3d,
29+
pub up: Point3d,
30+
}
31+
32+
#[pymethods]
33+
impl CameraLookAt {
34+
#[new]
35+
fn new(vantage: Point3d, center: Point3d, up: Point3d) -> Self {
36+
Self { vantage, center, up }
37+
}
2038
}
2139

2240
impl From<CameraLookAt> for kcmc::DefaultCameraLookAt {

rust/kcl-python-bindings/src/lib.rs

Lines changed: 72 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -359,6 +359,16 @@ async fn mock_execute(path: String) -> PyResult<bool> {
359359
/// Execute a kcl file and snapshot it in a specific format.
360360
#[pyfunction]
361361
async fn execute_and_snapshot(path: String, image_format: ImageFormat) -> PyResult<Vec<u8>> {
362+
let img = execute_and_snapshot_views(path, image_format, Vec::new()).await?.pop();
363+
Ok(img.unwrap())
364+
}
365+
366+
#[pyfunction]
367+
async fn execute_and_snapshot_views(
368+
path: String,
369+
image_format: ImageFormat,
370+
snapshot_options: Vec<SnapshotOptions>,
371+
) -> PyResult<Vec<Vec<u8>>> {
362372
tokio()
363373
.spawn(async move {
364374
let (code, path) = get_code_and_file_path(&path)
@@ -375,41 +385,7 @@ async fn execute_and_snapshot(path: String, image_format: ImageFormat) -> PyResu
375385
.await
376386
.map_err(|err| into_miette(err, &code))?;
377387

378-
// Zoom to fit.
379-
ctx.engine
380-
.send_modeling_cmd(
381-
uuid::Uuid::new_v4(),
382-
kcl_lib::SourceRange::default(),
383-
&kittycad_modeling_cmds::ModelingCmd::ZoomToFit(kittycad_modeling_cmds::ZoomToFit {
384-
object_ids: Default::default(),
385-
padding: 0.1,
386-
animated: false,
387-
}),
388-
)
389-
.await?;
390-
391-
// Send a snapshot request to the engine.
392-
let resp = ctx
393-
.engine
394-
.send_modeling_cmd(
395-
uuid::Uuid::new_v4(),
396-
kcl_lib::SourceRange::default(),
397-
&kittycad_modeling_cmds::ModelingCmd::TakeSnapshot(kittycad_modeling_cmds::TakeSnapshot {
398-
format: image_format.into(),
399-
}),
400-
)
401-
.await?;
402-
403-
let kittycad_modeling_cmds::websocket::OkWebSocketResponseData::Modeling {
404-
modeling_response: kittycad_modeling_cmds::ok_response::OkModelingCmdResponse::TakeSnapshot(data),
405-
} = resp
406-
else {
407-
return Err(pyo3::exceptions::PyException::new_err(format!(
408-
"Unexpected response from engine: {resp:?}"
409-
)));
410-
};
411-
412-
Ok(data.contents.0)
388+
take_snaps(&ctx, image_format, snapshot_options).await
413389
})
414390
.await
415391
.map_err(|err| pyo3::exceptions::PyException::new_err(err.to_string()))?
@@ -418,23 +394,43 @@ async fn execute_and_snapshot(path: String, image_format: ImageFormat) -> PyResu
418394
/// Execute the kcl code and snapshot it in a specific format.
419395
#[pyfunction]
420396
async fn execute_code_and_snapshot(code: String, image_format: ImageFormat) -> PyResult<Vec<u8>> {
421-
let mut snaps = execute_code_and_snapshot_at_views(code, image_format, Vec::new()).await?;
397+
let mut snaps = execute_code_and_snapshot_views(code, image_format, Vec::new()).await?;
422398
Ok(snaps.pop().unwrap())
423399
}
424400

401+
/// Customize a snapshot.
425402
#[derive(Serialize, Deserialize, PartialEq, Debug, Clone)]
426403
#[pyclass]
427-
struct SnapshotOptions {
404+
pub struct SnapshotOptions {
428405
/// If none, will use isometric view.
429-
camera: Option<bridge::CameraLookAt>,
430-
padding: f32,
406+
pub camera: Option<bridge::CameraLookAt>,
407+
/// How much to pad the view frame by, as a fraction of the object(s) bounding box size.
408+
/// Negative padding will crop the view of the object proportionally.
409+
/// e.g. padding = 0.2 means the view will span 120% of the object(s) bounding box,
410+
/// and padding = -0.2 means the view will span 80% of the object(s) bounding box.
411+
pub padding: f32,
412+
}
413+
414+
#[pymethods]
415+
impl SnapshotOptions {
416+
#[new]
417+
/// Takes a kcl.CameraLookAt, and a padding number.
418+
fn new(camera: Option<bridge::CameraLookAt>, padding: f32) -> Self {
419+
Self { camera, padding }
420+
}
421+
422+
#[staticmethod]
423+
/// Takes a padding number.
424+
fn isometric_view(padding: f32) -> Self {
425+
Self::new(None, padding)
426+
}
431427
}
432428

433429
/// Execute the kcl code and snapshot it in a specific format.
434430
/// Returns one image for each camera angle you provide.
435431
/// If you don't provide any camera angles, a default head-on camera angle will be used.
436432
#[pyfunction]
437-
async fn execute_code_and_snapshot_at_views(
433+
async fn execute_code_and_snapshot_views(
438434
code: String,
439435
image_format: ImageFormat,
440436
snapshot_options: Vec<SnapshotOptions>,
@@ -452,34 +448,42 @@ async fn execute_code_and_snapshot_at_views(
452448
.await
453449
.map_err(|err| into_miette(err, &code))?;
454450

455-
if snapshot_options.is_empty() {
456-
let data_bytes = snapshot(&ctx, image_format, 0.1).await?;
457-
return Ok(vec![data_bytes]);
458-
}
459-
460-
let mut snaps = Vec::with_capacity(snapshot_options.len());
461-
for pre_snap in snapshot_options {
462-
if let Some(camera) = pre_snap.camera {
463-
let view_cmd = kcmc::DefaultCameraLookAt::from(camera);
464-
let view_cmd = kcmc::ModelingCmd::DefaultCameraLookAt(view_cmd);
465-
ctx.engine
466-
.send_modeling_cmd(uuid::Uuid::new_v4(), Default::default(), &view_cmd)
467-
.await?;
468-
} else {
469-
let view_cmd = kcmc::ModelingCmd::ViewIsometric(kcmc::ViewIsometric { padding: 0.0 });
470-
ctx.engine
471-
.send_modeling_cmd(uuid::Uuid::new_v4(), Default::default(), &view_cmd)
472-
.await?;
473-
}
474-
let data_bytes = snapshot(&ctx, image_format, pre_snap.padding).await?;
475-
snaps.push(data_bytes);
476-
}
477-
Ok(snaps)
451+
take_snaps(&ctx, image_format, snapshot_options).await
478452
})
479453
.await
480454
.map_err(|err| pyo3::exceptions::PyException::new_err(err.to_string()))?
481455
}
482456

457+
async fn take_snaps(
458+
ctx: &ExecutorContext,
459+
image_format: ImageFormat,
460+
snapshot_options: Vec<SnapshotOptions>,
461+
) -> PyResult<Vec<Vec<u8>>> {
462+
if snapshot_options.is_empty() {
463+
let data_bytes = snapshot(ctx, image_format, 0.1).await?;
464+
return Ok(vec![data_bytes]);
465+
}
466+
467+
let mut snaps = Vec::with_capacity(snapshot_options.len());
468+
for pre_snap in snapshot_options {
469+
if let Some(camera) = pre_snap.camera {
470+
let view_cmd = kcmc::DefaultCameraLookAt::from(camera);
471+
let view_cmd = kcmc::ModelingCmd::DefaultCameraLookAt(view_cmd);
472+
ctx.engine
473+
.send_modeling_cmd(uuid::Uuid::new_v4(), Default::default(), &view_cmd)
474+
.await?;
475+
} else {
476+
let view_cmd = kcmc::ModelingCmd::ViewIsometric(kcmc::ViewIsometric { padding: 0.0 });
477+
ctx.engine
478+
.send_modeling_cmd(uuid::Uuid::new_v4(), Default::default(), &view_cmd)
479+
.await?;
480+
}
481+
let data_bytes = snapshot(ctx, image_format, pre_snap.padding).await?;
482+
snaps.push(data_bytes);
483+
}
484+
Ok(snaps)
485+
}
486+
483487
async fn snapshot(ctx: &ExecutorContext, image_format: ImageFormat, padding: f32) -> PyResult<Vec<u8>> {
484488
// Zoom to fit.
485489
ctx.engine
@@ -657,6 +661,9 @@ fn kcl(m: &Bound<'_, PyModule>) -> PyResult<()> {
657661
m.add_class::<FileExportFormat>()?;
658662
m.add_class::<UnitLength>()?;
659663
m.add_class::<Discovered>()?;
664+
m.add_class::<SnapshotOptions>()?;
665+
m.add_class::<bridge::Point3d>()?;
666+
m.add_class::<bridge::CameraLookAt>()?;
660667

661668
// Add our functions to the module.
662669
m.add_function(wrap_pyfunction!(parse, m)?)?;
@@ -666,8 +673,9 @@ fn kcl(m: &Bound<'_, PyModule>) -> PyResult<()> {
666673
m.add_function(wrap_pyfunction!(mock_execute, m)?)?;
667674
m.add_function(wrap_pyfunction!(mock_execute_code, m)?)?;
668675
m.add_function(wrap_pyfunction!(execute_and_snapshot, m)?)?;
676+
m.add_function(wrap_pyfunction!(execute_and_snapshot_views, m)?)?;
669677
m.add_function(wrap_pyfunction!(execute_code_and_snapshot, m)?)?;
670-
m.add_function(wrap_pyfunction!(execute_code_and_snapshot_at_views, m)?)?;
678+
m.add_function(wrap_pyfunction!(execute_code_and_snapshot_views, m)?)?;
671679
m.add_function(wrap_pyfunction!(execute_and_export, m)?)?;
672680
m.add_function(wrap_pyfunction!(execute_code_and_export, m)?)?;
673681
m.add_function(wrap_pyfunction!(format, m)?)?;

rust/kcl-python-bindings/tests/tests.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import os
33

44
import kcl
5+
from kcl import Point3d
56
import pytest
67

78
# Get the path to this script's parent directory.
@@ -174,6 +175,31 @@ async def test_kcl_execute_and_snapshot():
174175
assert len(image_bytes) > 0
175176

176177

178+
@pytest.mark.asyncio
179+
async def test_kcl_execute_and_snapshot_options():
180+
camera = kcl.CameraLookAt(
181+
# Test both constructors, with unnamed fields and named fields.
182+
up=Point3d(0, 0, 1),
183+
vantage=Point3d(x=0, y=-1, z=0),
184+
center=Point3d(x=0, y=0, z=0),
185+
)
186+
views = [
187+
# Specific camera perspective
188+
kcl.SnapshotOptions(camera=camera, padding=0.5),
189+
# Camera=None means isometric view.
190+
kcl.SnapshotOptions(camera=None, padding=0),
191+
]
192+
# Read from a file.
193+
images = await kcl.execute_and_snapshot_views(
194+
lego_file, kcl.ImageFormat.Jpeg, views
195+
)
196+
assert images is not None
197+
assert len(images) == len(views)
198+
image_bytes = images[0]
199+
assert image_bytes is not None
200+
assert len(image_bytes) > 0
201+
202+
177203
@pytest.mark.asyncio
178204
async def test_kcl_execute_and_snapshot_dir():
179205
# Read from a file.

0 commit comments

Comments
 (0)