Files
OneCAD-Draft/plugins/opencad-python/src/ocs_module.rs
T
Felix RiestraandClaude Sonnet 5 9cd6653a58 Complete Surface: plane surfaces, extrusions, isolines and moves
Adds SurfaceFromProfile and Extrude kernel operations (doc.solids.surface and
doc.solids.extrude), accepts surfaces in Transform, and maps Surface with
editable isolines. Fixes a real bug found by the test: the surface kind field
overwrote the entity-type kind key in Python dicts, so it is exposed as
surface_kind, with a catalog test guarding against name collisions.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-20 17:47:22 +02:00

5152 lines
215 KiB
Rust

//! The `ocs` Python module: read-only entity access (Phase 1.2) plus 2D write
//! access (Phase 1.3) of `docs/python-scripting-roadmap.md`.
pub(crate) use ocs::module_def;
#[rustpython_vm::pymodule]
mod ocs {
use std::io::Write;
#[cfg(feature = "experimental-host-settings")]
use ocs_plugin_api::host::HostSettingValue;
#[cfg(feature = "experimental-host-settings")]
use rustpython_vm::convert::TryFromObject;
use rustpython_vm::{function::ArgIntoFloat, PyObjectRef, PyResult, VirtualMachine};
use acadrust::entities::{
Arc, LwPolyline, LwVertex, MText, TableBuilder, Text, TextHorizontalAlignment,
TextVerticalAlignment,
};
use acadrust::objects::ObjectType;
use acadrust::types::{Transform, Vector2, Vector3};
use ocs_plugin_api::host::{
acadrust, EntityType, ExtendedDataRecord, Handle, ReaderEntityKind, XDataValue,
};
use crate::host_ctx;
/// Write one new UTF-8 report file. The caller must provide an explicit
/// path; existing files are never overwritten and parent directories are
/// not created.
#[pyfunction]
fn write_new_text_file(path: String, content: String, vm: &VirtualMachine) -> PyResult<()> {
if host_ctx::with_host(|_| ()).is_none() {
return Err(vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()));
}
if path.is_empty() {
return Err(vm.new_value_error("report path must not be empty".to_owned()));
}
let mut file = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&path)
.map_err(|error| {
vm.new_runtime_error(format!("cannot create report {path}: {error}"))
})?;
if let Err(error) = file.write_all(content.as_bytes()) {
return Err(vm.new_runtime_error(format!(
"cannot finish report {path}: {error}; a partial file may remain"
)));
}
Ok(())
}
fn top_level_blocks(document: &acadrust::CadDocument) -> std::collections::HashSet<Handle> {
let mut blocks: std::collections::HashSet<Handle> = document
.objects
.values()
.filter_map(|object| match object {
ObjectType::Layout(layout) if !layout.block_record.is_null() => {
Some(layout.block_record)
}
_ => None,
})
.collect();
if !document.header.model_space_block_handle.is_null() {
blocks.insert(document.header.model_space_block_handle);
}
if let Some(model) = document.block_records.get("*Model_Space") {
blocks.insert(model.handle);
}
blocks
}
/// Handles of the entities selected as of the last `SelectionChangedV4`
/// notification (see `selection_cache.rs` — a best-effort cache, not a
/// live query).
#[pyfunction]
fn selection(vm: &VirtualMachine) -> PyResult<PyObjectRef> {
#[cfg(feature = "experimental-host-model")]
let handles = host_ctx::with_host(|host| host.selection()).ok_or_else(|| {
vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned())
})?;
#[cfg(not(feature = "experimental-host-model"))]
let handles = crate::selection_cache::get();
let items = handles
.into_iter()
.map(|h| vm.new_pyobj(h.value()))
.collect();
Ok(vm.ctx.new_list(items).into())
}
/// Drain best-effort drawing, selection, and command-state notifications.
/// Events accumulated since the previous poll, up to a bounded queue.
#[cfg(feature = "experimental-host-model")]
#[pyfunction]
fn poll_events(tab_id: u64, vm: &VirtualMachine) -> PyResult<PyObjectRef> {
use crate::event_cache::Event;
let current_tab = host_ctx::with_host(|host| host.tab_id()).ok_or_else(|| {
vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned())
})?;
if tab_id != current_tab {
return Err(
vm.new_value_error("ocs.poll_events: tab_id is not the active document".to_owned())
);
}
let mut items = Vec::new();
for event in crate::event_cache::drain_for(tab_id) {
let row = vm.ctx.new_dict();
match event {
Event::Drawing { tab_id, version } => {
row.set_item("type", vm.new_pyobj("drawing"), vm)?;
row.set_item("tab_id", vm.new_pyobj(tab_id), vm)?;
row.set_item("version", vm.new_pyobj(version), vm)?;
}
Event::Selection { tab_id, handles } => {
row.set_item("type", vm.new_pyobj("selection"), vm)?;
row.set_item("tab_id", vm.new_pyobj(tab_id), vm)?;
row.set_item(
"handles",
vm.ctx
.new_list(handles.into_iter().map(|h| vm.new_pyobj(h)).collect())
.into(),
vm,
)?;
}
Event::Command { tab_id, command } => {
row.set_item("type", vm.new_pyobj("command"), vm)?;
row.set_item("tab_id", vm.new_pyobj(tab_id), vm)?;
row.set_item("command", vm.new_pyobj(command), vm)?;
}
Event::Overflow { tab_id, dropped } => {
row.set_item("type", vm.new_pyobj("overflow"), vm)?;
row.set_item("tab_id", vm.new_pyobj(tab_id), vm)?;
row.set_item("dropped", vm.new_pyobj(dropped), vm)?;
}
}
items.push(row.into());
}
Ok(vm.ctx.new_list(items).into())
}
#[cfg(feature = "experimental-host-model")]
#[pyfunction]
fn tab_id(vm: &VirtualMachine) -> PyResult<u64> {
host_ctx::with_host(|host| host.tab_id())
.ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))
}
/// Start a host-driven point or entity pick. Read the result in a later
/// script invocation with `poll_input(token)`.
#[cfg(feature = "experimental-host-model")]
#[pyfunction]
fn request_input(prompt: String, entity: bool, vm: &VirtualMachine) -> PyResult<u64> {
if prompt.trim().is_empty() {
return Err(vm.new_value_error("prompt must not be empty".to_owned()));
}
host_ctx::with_host(|host| {
let (token, command) = crate::input_cache::begin(host.tab_id(), prompt, entity);
host.start_interactive(Box::new(command));
token
})
.ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))
}
#[cfg(feature = "experimental-host-model")]
#[pyfunction]
fn poll_input(token: u64, vm: &VirtualMachine) -> PyResult<PyObjectRef> {
use crate::input_cache::PickResult;
let tab_id = host_ctx::with_host(|host| host.tab_id()).ok_or_else(|| {
vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned())
})?;
let Some(result) = crate::input_cache::take(tab_id, token) else {
return Ok(vm.ctx.none());
};
let row = vm.ctx.new_dict();
match result {
PickResult::Pending => row.set_item("status", vm.new_pyobj("pending"), vm)?,
PickResult::Cancelled => row.set_item("status", vm.new_pyobj("cancelled"), vm)?,
PickResult::Point(point) => {
row.set_item("status", vm.new_pyobj("point"), vm)?;
row.set_item(
"point",
vm.ctx
.new_list(point.into_iter().map(|v| vm.new_pyobj(v)).collect())
.into(),
vm,
)?;
}
PickResult::Entity { handle, point } => {
row.set_item("status", vm.new_pyobj("entity"), vm)?;
row.set_item("handle", vm.new_pyobj(handle), vm)?;
row.set_item(
"point",
vm.ctx
.new_list(point.into_iter().map(|v| vm.new_pyobj(v)).collect())
.into(),
vm,
)?;
}
}
Ok(row.into())
}
/// Read-only geometry/properties for one entity handle, or `None` if it
/// doesn't exist. Backed by `HostApi::document_reader()` (zero-copy read).
#[pyfunction]
fn get(handle: u64, vm: &VirtualMachine) -> PyResult<PyObjectRef> {
let target = Handle::new(handle);
let found = host_ctx::with_host(|host| {
let reader = host.document_reader();
let mut found = None;
reader.for_each_entity(&mut |entity| {
if found.is_none() && entity.handle == target {
found = Some((entity.kind, entity.layer_name.to_string(), entity.point));
}
});
found
})
.ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?;
let Some((kind, layer, point)) = found else {
return Ok(vm.ctx.none());
};
let dict = vm.ctx.new_dict();
dict.set_item("handle", vm.new_pyobj(handle), vm)?;
dict.set_item("kind", vm.new_pyobj(kind_name(kind)), vm)?;
dict.set_item("layer", vm.new_pyobj(layer), vm)?;
let point_obj = match point {
Some(p) => {
let pd = vm.ctx.new_dict();
pd.set_item("x", vm.new_pyobj(p.x), vm)?;
pd.set_item("y", vm.new_pyobj(p.y), vm)?;
pd.set_item("z", vm.new_pyobj(p.z), vm)?;
pd.into()
}
None => vm.ctx.none(),
};
dict.set_item("point", point_obj, vm)?;
Ok(dict.into())
}
/// Full single-line TEXT geometry from the host's document snapshot.
/// `point` is the active DXF alignment point (10 for left/baseline,
/// otherwise 11), in the entity's OCS coordinate system.
#[pyfunction]
fn get_text(handle: u64, vm: &VirtualMachine) -> PyResult<PyObjectRef> {
let found =
host_ctx::with_host(
|host| match host.document().get_entity(Handle::new(handle)) {
Some(EntityType::Text(text)) => Some(text.clone()),
_ => None,
},
)
.ok_or_else(|| {
vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned())
})?;
let Some(text) = found else {
return Ok(vm.ctx.none());
};
let point = active_text_point(&text);
let dict = vm.ctx.new_dict();
dict.set_item("handle", vm.new_pyobj(handle), vm)?;
dict.set_item(
"point",
vm.ctx
.new_list(vec![
vm.new_pyobj(point.x),
vm.new_pyobj(point.y),
vm.new_pyobj(point.z),
])
.into(),
vm,
)?;
dict.set_item(
"normal",
vm.ctx
.new_list(vec![
vm.new_pyobj(text.normal.x),
vm.new_pyobj(text.normal.y),
vm.new_pyobj(text.normal.z),
])
.into(),
vm,
)?;
dict.set_item("rotation", vm.new_pyobj(text.rotation), vm)?;
dict.set_item(
"direction",
vm.ctx
.new_list(vec![
vm.new_pyobj(text.rotation.cos()),
vm.new_pyobj(text.rotation.sin()),
])
.into(),
vm,
)?;
dict.set_item("height", vm.new_pyobj(text.height), vm)?;
dict.set_item("value", vm.new_pyobj(text.value), vm)?;
dict.set_item("style", vm.new_pyobj(text.style), vm)?;
Ok(dict.into())
}
/// Approximate XY box of left/baseline TEXT, using height and character count.
#[pyfunction]
fn text_box_vertices(
handle: u64,
padding: ArgIntoFloat,
vm: &VirtualMachine,
) -> PyResult<PyObjectRef> {
let padding = padding.into_float();
if !padding.is_finite() || padding < 0.0 {
return Err(vm.new_value_error(
"ocs.text_box_vertices: padding must be finite and nonnegative".to_owned(),
));
}
let found = host_ctx::with_host(|host| {
let Some(EntityType::Text(text)) = host.document().get_entity(Handle::new(handle))
else {
return None;
};
if text.normal != Vector3::UNIT_Z
|| text.horizontal_alignment != TextHorizontalAlignment::Left
|| text.vertical_alignment != TextVerticalAlignment::Baseline
|| text.value.is_empty()
|| ![
text.height,
text.width_factor,
text.rotation,
text.insertion_point.x,
text.insertion_point.y,
text.insertion_point.z,
]
.iter()
.all(|value| value.is_finite())
|| text.height <= 0.0
|| text.width_factor <= 0.0
{
return None;
}
let width = text.value.chars().count() as f64 * text.height * 0.6 * text.width_factor;
let local = [
(-padding, -padding),
(width + padding, -padding),
(width + padding, text.height + padding),
(-padding, text.height + padding),
];
let (sin, cos) = text.rotation.sin_cos();
let vertices = local.map(|(x, y)| {
[
text.insertion_point.x + x * cos - y * sin,
text.insertion_point.y + x * sin + y * cos,
]
});
Some((vertices, text.insertion_point.z))
})
.ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?;
let Some((vertices, elevation)) = found else {
return Ok(vm.ctx.none());
};
let dict = vm.ctx.new_dict();
let points = vertices
.into_iter()
.map(|point| {
vm.ctx
.new_list(vec![vm.new_pyobj(point[0]), vm.new_pyobj(point[1])])
.into()
})
.collect();
dict.set_item("vertices", vm.ctx.new_list(points).into(), vm)?;
dict.set_item("elevation", vm.new_pyobj(elevation), vm)?;
Ok(dict.into())
}
/// Add a top-left anchored MTEXT entity in the world XY plane.
#[pyfunction]
fn add_mtext(
value: rustpython_vm::builtins::PyStrRef,
point: Vec<f64>,
height: ArgIntoFloat,
width: ArgIntoFloat,
rotation: ArgIntoFloat,
vm: &VirtualMachine,
) -> PyResult<u64> {
let (height, width, rotation) = (
height.into_float(),
width.into_float(),
rotation.into_float(),
);
if point.len() != 3
|| point.iter().any(|v| !v.is_finite())
|| ![height, width, rotation].iter().all(|v| v.is_finite())
|| height <= 0.0
|| width <= 0.0
{
return Err(vm.new_value_error(
"ocs.add_mtext: coordinates must be finite; height and width must be positive"
.to_owned(),
));
}
let mut text = MText::with_value(
value.to_string(),
Vector3::new(point[0], point[1], point[2]),
)
.with_height(height)
.with_width(width);
text.rotation = rotation;
host_ctx::with_host(|host| {
host_ctx::ensure_undo_started(host);
host.add_entity(EntityType::MText(text)).value()
})
.ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))
}
/// Build a kernel solid. `values` are the primitive's numbers in a fixed
/// order (see `document_model.py`); the host validates and builds it.
#[pyfunction]
fn solid_create(
primitive: String,
values: Vec<f64>,
layer: Option<String>,
vm: &VirtualMachine,
) -> PyResult<u64> {
use ocs_plugin_api::host::{SolidOperation, SolidPrimitive};
let expect = |count: usize| -> PyResult<()> {
if values.len() == count {
Ok(())
} else {
Err(vm.new_value_error(format!(
"ocs.solid_create: {primitive} takes {count} numbers, got {}",
values.len()
)))
}
};
let v3 = |i: usize| [values[i], values[i + 1], values[i + 2]];
let primitive = match primitive.as_str() {
"box" => { expect(6)?; SolidPrimitive::Box { center: v3(0), size: v3(3) } }
"wedge" => { expect(6)?; SolidPrimitive::Wedge { origin: v3(0), size: v3(3) } }
"cylinder" => { expect(5)?; SolidPrimitive::Cylinder { center: v3(0), radius: values[3], height: values[4] } }
"sphere" => { expect(4)?; SolidPrimitive::Sphere { center: v3(0), radius: values[3] } }
"torus" => { expect(5)?; SolidPrimitive::Torus { center: v3(0), major: values[3], minor: values[4] } }
"pyramid" => {
expect(6)?;
if values[5] < 0.0 || values[5].fract() != 0.0 || values[5] > f64::from(u32::MAX) {
return Err(vm.new_value_error("ocs.solid_create: sides must be a whole number".to_owned()));
}
SolidPrimitive::Pyramid { center: v3(0), radius: values[3], height: values[4], sides: values[5] as u32 }
}
other => return Err(vm.new_value_error(format!("ocs.solid_create: unknown primitive {other:?}"))),
};
// The host records its own undo step once the operation validates, like
// an entity transaction, so a refused operation leaves no empty step.
let result = host_ctx::with_host(|host| {
host.solid_operation(SolidOperation::Create { primitive, layer })
})
.ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?;
result
.map(|handle| handle.value())
.map_err(|error| vm.new_runtime_error(format!("ocs.solid_create: {error}")))
}
/// Build a planar region from a closed planar profile entity.
#[pyfunction]
fn solid_region(source: u64, layer: Option<String>, delete_source: bool, vm: &VirtualMachine) -> PyResult<u64> {
use ocs_plugin_api::host::SolidOperation;
let result = host_ctx::with_host(|host| {
host.solid_operation(SolidOperation::RegionFromProfile {
source: Handle::new(source),
layer,
delete_source,
})
})
.ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?;
result
.map(|handle| handle.value())
.map_err(|error| vm.new_runtime_error(format!("ocs.solid_region: {error}")))
}
/// Build a plane surface from a closed planar profile entity.
#[pyfunction]
fn solid_surface(source: u64, layer: Option<String>, delete_source: bool, vm: &VirtualMachine) -> PyResult<u64> {
use ocs_plugin_api::host::SolidOperation;
let result = host_ctx::with_host(|host| {
host.solid_operation(SolidOperation::SurfaceFromProfile {
source: Handle::new(source),
layer,
delete_source,
})
})
.ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?;
result
.map(|handle| handle.value())
.map_err(|error| vm.new_runtime_error(format!("ocs.solid_surface: {error}")))
}
/// Extrude a planar profile: a solid if closed, a surface if open.
#[pyfunction]
fn solid_extrude(
source: u64,
direction: Vec<f64>,
layer: Option<String>,
delete_source: bool,
vm: &VirtualMachine,
) -> PyResult<u64> {
use ocs_plugin_api::host::SolidOperation;
let direction: [f64; 3] = direction.try_into().map_err(|_| {
vm.new_value_error("ocs.solid_extrude: the direction needs 3 numbers".to_owned())
})?;
let result = host_ctx::with_host(|host| {
host.solid_operation(SolidOperation::Extrude {
source: Handle::new(source),
direction,
layer,
delete_source,
})
})
.ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?;
result
.map(|handle| handle.value())
.map_err(|error| vm.new_runtime_error(format!("ocs.solid_extrude: {error}")))
}
/// Apply a column-major 4x4 rigid transform to a solid, body or region.
#[pyfunction]
fn solid_transform(handle: u64, matrix: Vec<f64>, vm: &VirtualMachine) -> PyResult<u64> {
use ocs_plugin_api::host::SolidOperation;
let matrix: [f64; 16] = matrix.try_into().map_err(|_| {
vm.new_value_error("ocs.solid_transform: the matrix needs 16 numbers".to_owned())
})?;
let result = host_ctx::with_host(|host| {
host.solid_operation(SolidOperation::Transform { handle: Handle::new(handle), matrix })
})
.ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?;
result
.map(|handle| handle.value())
.map_err(|error| vm.new_runtime_error(format!("ocs.solid_transform: {error}")))
}
/// Column-major matrix that turns about a world axis (0=X, 1=Y, 2=Z)
/// through the point `about`. The sandbox has no `math` module.
#[pyfunction]
fn rotation_matrix(axis: usize, angle: f64, about: Vec<f64>, vm: &VirtualMachine) -> PyResult<PyObjectRef> {
if axis > 2 || about.len() != 3 || !angle.is_finite() || about.iter().any(|v| !v.is_finite()) {
return Err(vm.new_value_error("ocs.rotation_matrix: axis 0-2, a finite angle and a 3-point".to_owned()));
}
let (sin, cos) = angle.sin_cos();
let (x, y, z) = match axis {
0 => ([1.0, 0.0, 0.0], [0.0, cos, sin], [0.0, -sin, cos]),
1 => ([cos, 0.0, -sin], [0.0, 1.0, 0.0], [sin, 0.0, cos]),
_ => ([cos, sin, 0.0], [-sin, cos, 0.0], [0.0, 0.0, 1.0]),
};
Ok(matrix_to_py(vm, frame_matrix(x, y, z, &about)))
}
/// Column-major matrix that reflects across the plane normal to a world
/// axis through the point `about`.
#[pyfunction]
fn mirror_matrix(axis: usize, about: Vec<f64>, vm: &VirtualMachine) -> PyResult<PyObjectRef> {
if axis > 2 || about.len() != 3 || about.iter().any(|v| !v.is_finite()) {
return Err(vm.new_value_error("ocs.mirror_matrix: axis 0-2 and a finite 3-point".to_owned()));
}
let mut columns = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]];
columns[axis][axis] = -1.0;
let [x, y, z] = columns;
Ok(matrix_to_py(vm, frame_matrix(x, y, z, &about)))
}
fn matrix_to_py(vm: &VirtualMachine, matrix: Vec<f64>) -> PyObjectRef {
vm.ctx.new_list(matrix.into_iter().map(|value| vm.new_pyobj(value)).collect()).into()
}
/// A transform about `about` has origin `about - M * about`.
fn frame_matrix(x: [f64; 3], y: [f64; 3], z: [f64; 3], about: &[f64]) -> Vec<f64> {
let mut origin = [about[0], about[1], about[2]];
for axis in 0..3 {
origin[axis] -= x[axis] * about[0] + y[axis] * about[1] + z[axis] * about[2];
}
vec![
x[0], x[1], x[2], 0.0, y[0], y[1], y[2], 0.0, z[0], z[1], z[2], 0.0,
origin[0], origin[1], origin[2], 1.0,
]
}
/// Delete one entity in the script's shared undo group.
#[pyfunction]
fn remove_entity(handle: u64, vm: &VirtualMachine) -> PyResult<()> {
let removed = host_ctx::with_host(|host| {
if host.document().get_entity(Handle::new(handle)).is_none() {
return false;
}
host_ctx::ensure_undo_started(host);
host.remove_entity(Handle::new(handle))
})
.ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?;
if !removed {
return Err(vm.new_runtime_error(format!(
"ocs.remove_entity: entity {handle} was not deleted"
)));
}
Ok(())
}
/// Read the XDATA record for `app_name` on entity `handle`, or `None` if
/// the entity doesn't exist or carries no such record. Each value is a
/// `{"kind": ..., "value": ...}` dict — see `write_record` for the
/// supported kinds.
///
/// The `{app_name, values}`/`{kind, value}` shape mirrors
/// `schoeller/ocs_python_repl`'s `ocs.doc.read_record(...)` (its `_ocs`
/// PyO3 extension) so a script's XDATA handling ports between the two
/// plugins unchanged.
#[pyfunction]
fn read_record(handle: u64, app_name: String, vm: &VirtualMachine) -> PyResult<PyObjectRef> {
let record =
host_ctx::with_host(|host| host.read_record(Handle::new(handle), &app_name).cloned())
.ok_or_else(|| {
vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned())
})?;
let Some(record) = record else {
return Ok(vm.ctx.none());
};
xdata_record_to_py(vm, &record)
}
/// Attach an XDATA record to `handle` for `app_name`, replacing any
/// existing record for the same application. `values` is a list of
/// `{"kind": ..., "value": ...}` dicts; supported kinds: `String`,
/// `ControlString`, `LayerName`, `BinaryData` (Python `bytes`), `Handle`
/// (`int`), `Point3D`/`Position3D`/`Displacement3D`/`Direction3D`
/// (`[x, y, z]`), `Real`/`Distance`/`ScaleFactor` (`float`), and
/// `Integer16`/`Integer32` (`int`). Raises if `handle` does not exist.
#[pyfunction]
fn write_record(
handle: u64,
app_name: String,
values: Vec<PyObjectRef>,
vm: &VirtualMachine,
) -> PyResult<()> {
let mut record = ExtendedDataRecord::new(app_name);
for value in values {
let dict = value.try_into_value::<rustpython_vm::builtins::PyDictRef>(vm)?;
record.add_value(py_to_xdata_value(&dict, vm)?);
}
let written = host_ctx::with_host(|host| {
if host.document().get_entity(Handle::new(handle)).is_none() {
return false;
}
host_ctx::ensure_undo_started(host);
host.write_record(Handle::new(handle), record)
})
.ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?;
if !written {
return Err(
vm.new_runtime_error(format!("ocs.write_record: entity {handle} does not exist"))
);
}
Ok(())
}
/// Remove the XDATA record for `app_name` from `handle`, if any. Returns
/// `True` if a record was actually removed.
#[pyfunction]
fn remove_record(handle: u64, app_name: String, vm: &VirtualMachine) -> PyResult<bool> {
host_ctx::with_host(|host| {
if host.read_record(Handle::new(handle), &app_name).is_none() {
return false;
}
host_ctx::ensure_undo_started(host);
host.remove_record(Handle::new(handle), &app_name)
})
.ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))
}
fn xdata_record_to_py(
vm: &VirtualMachine,
record: &ExtendedDataRecord,
) -> PyResult<PyObjectRef> {
let dict = vm.ctx.new_dict();
dict.set_item(
"app_name",
vm.new_pyobj(record.application_name.clone()),
vm,
)?;
let mut values = Vec::with_capacity(record.values.len());
for value in &record.values {
values.push(xdata_value_to_py(vm, value)?);
}
dict.set_item("values", vm.ctx.new_list(values).into(), vm)?;
Ok(dict.into())
}
fn xdata_value_to_py(vm: &VirtualMachine, value: &XDataValue) -> PyResult<PyObjectRef> {
let dict = vm.ctx.new_dict();
let (kind, py_value): (&str, PyObjectRef) = match value {
XDataValue::String(s) => ("String", vm.new_pyobj(s.clone())),
XDataValue::ControlString(s) => ("ControlString", vm.new_pyobj(s.clone())),
XDataValue::LayerName(s) => ("LayerName", vm.new_pyobj(s.clone())),
XDataValue::BinaryData(b) => ("BinaryData", vm.ctx.new_bytes(b.clone()).into()),
XDataValue::Handle(h) => ("Handle", vm.new_pyobj(h.value())),
XDataValue::Point3D(v) => ("Point3D", vector3_to_py(vm, v)),
XDataValue::Position3D(v) => ("Position3D", vector3_to_py(vm, v)),
XDataValue::Displacement3D(v) => ("Displacement3D", vector3_to_py(vm, v)),
XDataValue::Direction3D(v) => ("Direction3D", vector3_to_py(vm, v)),
XDataValue::Real(r) => ("Real", vm.new_pyobj(*r)),
XDataValue::Distance(d) => ("Distance", vm.new_pyobj(*d)),
XDataValue::ScaleFactor(s) => ("ScaleFactor", vm.new_pyobj(*s)),
XDataValue::Integer16(i) => ("Integer16", vm.new_pyobj(*i)),
XDataValue::Integer32(i) => ("Integer32", vm.new_pyobj(*i)),
};
dict.set_item("kind", vm.new_pyobj(kind), vm)?;
dict.set_item("value", py_value, vm)?;
Ok(dict.into())
}
fn vector3_to_py(vm: &VirtualMachine, v: &Vector3) -> PyObjectRef {
vm.ctx
.new_list(vec![
vm.new_pyobj(v.x),
vm.new_pyobj(v.y),
vm.new_pyobj(v.z),
])
.into()
}
fn py_to_xdata_value(
dict: &rustpython_vm::builtins::PyDictRef,
vm: &VirtualMachine,
) -> PyResult<XDataValue> {
let kind = dict.get_item("kind", vm)?.try_into_value::<String>(vm)?;
let value = dict.get_item("value", vm)?;
match kind.as_str() {
"String" => Ok(XDataValue::String(value.try_into_value(vm)?)),
"ControlString" => Ok(XDataValue::ControlString(value.try_into_value(vm)?)),
"LayerName" => Ok(XDataValue::LayerName(value.try_into_value(vm)?)),
"BinaryData" => Ok(XDataValue::BinaryData(value.try_into_value(vm)?)),
"Handle" => Ok(XDataValue::Handle(Handle::new(value.try_into_value(vm)?))),
"Point3D" => Ok(XDataValue::Point3D(py_to_vector3(value, vm)?)),
"Position3D" => Ok(XDataValue::Position3D(py_to_vector3(value, vm)?)),
"Displacement3D" => Ok(XDataValue::Displacement3D(py_to_vector3(value, vm)?)),
"Direction3D" => Ok(XDataValue::Direction3D(py_to_vector3(value, vm)?)),
"Real" => Ok(XDataValue::Real(value.try_into_value(vm)?)),
"Distance" => Ok(XDataValue::Distance(value.try_into_value(vm)?)),
"ScaleFactor" => Ok(XDataValue::ScaleFactor(value.try_into_value(vm)?)),
"Integer16" => Ok(XDataValue::Integer16(value.try_into_value(vm)?)),
"Integer32" => Ok(XDataValue::Integer32(value.try_into_value(vm)?)),
other => Err(vm.new_value_error(format!(
"unsupported XDATA value kind: {other}; supported: String, ControlString, \
LayerName, BinaryData, Handle, Point3D, Position3D, Displacement3D, \
Direction3D, Real, Distance, ScaleFactor, Integer16, Integer32"
))),
}
}
fn py_to_vector3(value: PyObjectRef, vm: &VirtualMachine) -> PyResult<Vector3> {
let coords: Vec<f64> = value.try_into_value(vm)?;
if coords.len() != 3 || coords.iter().any(|v| !v.is_finite()) {
return Err(vm.new_value_error(
"XDATA Point3D/Position3D/Displacement3D/Direction3D value must be \
[finite x, y, z]"
.to_owned(),
));
}
Ok(Vector3::new(coords[0], coords[1], coords[2]))
}
/// All top-level single- and multiline text in the current document.
#[pyfunction]
fn text_entities(vm: &VirtualMachine) -> PyResult<PyObjectRef> {
let items = host_ctx::with_host(|host| {
host.document()
.entities()
.filter_map(|entity| match entity {
EntityType::Text(text) => {
Some((text.common.handle.value(), "TEXT", text.value.clone()))
}
EntityType::MText(text) => {
Some((text.common.handle.value(), "MTEXT", text.value.clone()))
}
_ => None,
})
.collect::<Vec<_>>()
})
.ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?;
let mut output = Vec::with_capacity(items.len());
for (handle, kind, value) in items {
let dict = vm.ctx.new_dict();
dict.set_item("handle", vm.new_pyobj(handle), vm)?;
dict.set_item("kind", vm.new_pyobj(kind), vm)?;
dict.set_item("value", vm.new_pyobj(value), vm)?;
output.push(dict.into());
}
Ok(vm.ctx.new_list(output).into())
}
/// All top-level block references in the active document.
#[pyfunction]
fn block_references(vm: &VirtualMachine) -> PyResult<PyObjectRef> {
let items = host_ctx::with_host(|host| {
let document = host.document();
document
.entities()
.filter_map(|entity| match entity {
EntityType::Insert(insert) => {
let raw_name = insert.block_name.clone();
let record = document
.block_records
.iter()
.find(|block| block.name == raw_name);
let is_xref = record.is_some_and(|block| block.flags.is_xref);
let dynamic_definition = document
.dynamic_definition_for_insert(insert.common.handle)
.and_then(|handle| {
document
.block_records
.iter()
.find(|block| block.handle == handle)
});
let effective_name = dynamic_definition
.map_or_else(|| raw_name.clone(), |block| block.name.clone());
let is_dynamic = effective_name != raw_name
|| document
.dynamic_visibility_for_insert(insert.common.handle)
.is_some();
let visibility = document
.dynamic_visibility_for_insert(insert.common.handle)
.and_then(|(definition, parameter)| {
let def_record = document
.block_records
.iter()
.find(|block| block.handle == definition)?;
let anon_record = record?;
let current: std::collections::HashSet<usize> = anon_record
.entity_handles
.iter()
.enumerate()
.filter(|(_, handle)| {
document
.get_entity(**handle)
.is_some_and(|entity| !entity.common().invisible)
})
.map(|(index, _)| index)
.collect();
parameter
.states
.iter()
.find(|state| {
let visible: std::collections::HashSet<usize> = def_record
.entity_handles
.iter()
.enumerate()
.filter(|(_, handle)| {
state.visible_blocks.contains(handle)
})
.map(|(index, _)| index)
.collect();
visible == current
})
.map(|state| state.name.clone())
});
Some((
insert.common.handle.value(),
raw_name,
effective_name,
insert.row_count,
insert.column_count,
is_xref,
is_dynamic,
visibility,
))
}
_ => None,
})
.collect::<Vec<_>>()
})
.ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?;
let mut output = Vec::with_capacity(items.len());
for (handle, name, effective_name, rows, columns, is_xref, is_dynamic, visibility) in items
{
let dict = vm.ctx.new_dict();
dict.set_item("handle", vm.new_pyobj(handle), vm)?;
dict.set_item("name", vm.new_pyobj(name), vm)?;
dict.set_item("effective_name", vm.new_pyobj(effective_name), vm)?;
dict.set_item("is_xref", vm.new_pyobj(is_xref), vm)?;
dict.set_item("is_dynamic", vm.new_pyobj(is_dynamic), vm)?;
dict.set_item(
"visibility",
visibility.map_or_else(|| vm.ctx.none(), |state| vm.new_pyobj(state)),
vm,
)?;
dict.set_item(
"count",
vm.new_pyobj(u64::from(rows) * u64::from(columns)),
vm,
)?;
output.push(dict.into());
}
Ok(vm.ctx.new_list(output).into())
}
/// Layer names, visibility flags, and top-level entity counts.
#[pyfunction]
fn layers(vm: &VirtualMachine) -> PyResult<PyObjectRef> {
let rows = host_ctx::with_host(|host| {
let document = host.document();
let layout_blocks = top_level_blocks(document);
let mut counts = std::collections::HashMap::<String, usize>::new();
for entity in document.entities() {
if layout_blocks.contains(&entity.common().owner_handle) {
*counts.entry(entity.common().layer.clone()).or_default() += 1;
}
}
document
.layers
.iter()
.map(|layer| {
(
layer.name.clone(),
layer.flags.off,
layer.flags.frozen,
layer.flags.locked,
counts.get(&layer.name).copied().unwrap_or(0),
)
})
.collect::<Vec<_>>()
})
.ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?;
let mut output = Vec::with_capacity(rows.len());
for (name, off, frozen, locked, count) in rows {
let dict = vm.ctx.new_dict();
dict.set_item("name", vm.new_pyobj(name), vm)?;
dict.set_item("off", vm.new_pyobj(off), vm)?;
dict.set_item("frozen", vm.new_pyobj(frozen), vm)?;
dict.set_item("locked", vm.new_pyobj(locked), vm)?;
dict.set_item("entity_count", vm.new_pyobj(count), vm)?;
output.push(dict.into());
}
Ok(vm.ctx.new_list(output).into())
}
/// Move a top-level entity to an existing layer without changing its owner.
#[pyfunction]
fn set_entity_layer(handle: u64, layer_name: String, vm: &VirtualMachine) -> PyResult<()> {
let changed = host_ctx::with_host(|host| {
let document = host.document();
if document.layers.get(&layer_name).is_none() {
return false;
}
let Some(mut entity) = document.get_entity(Handle::new(handle)).cloned() else {
return false;
};
if !top_level_blocks(document).contains(&entity.common().owner_handle) {
return false;
}
if entity.common().layer == layer_name {
return true;
}
entity.common_mut().layer = layer_name;
host_ctx::ensure_undo_started(host);
host.update_entity(entity)
})
.ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?;
if !changed {
return Err(vm.new_value_error(format!(
"ocs.set_entity_layer: top-level entity {handle} or target layer not found"
)));
}
Ok(())
}
/// Export one layer's top-level entities as a new DWG, never overwriting.
#[pyfunction]
fn export_layer_dwg(
layer_name: String,
output_path: String,
vm: &VirtualMachine,
) -> PyResult<usize> {
let path = std::path::Path::new(&output_path);
if path
.extension()
.is_none_or(|extension| !extension.eq_ignore_ascii_case("dwg"))
|| path.file_name().is_none()
|| path.parent().is_none_or(|parent| !parent.is_dir())
{
return Err(vm.new_value_error(
"ocs.export_layer_dwg: output must be a .dwg file in an existing directory"
.to_owned(),
));
}
let prepared = host_ctx::with_host(|host| {
let source = host.document();
source.layers.get(&layer_name)?;
let layout_blocks = top_level_blocks(source);
let mut snapshot = source.clone();
let removed: Vec<Handle> = source
.entities()
.filter(|entity| {
layout_blocks.contains(&entity.common().owner_handle)
&& entity.common().layer != layer_name
})
.map(|entity| entity.common().handle)
.collect();
for handle in removed {
snapshot.remove_entity(handle);
}
let kept: std::collections::HashSet<Handle> = snapshot
.entities()
.map(|entity| entity.common().handle)
.collect();
for record in snapshot.block_records.iter_mut() {
record.entity_handles.retain(|handle| kept.contains(handle));
}
let count = snapshot
.entities()
.filter(|entity| {
layout_blocks.contains(&entity.common().owner_handle)
&& entity.common().layer == layer_name
})
.count();
Some((snapshot, count))
})
.ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?
.ok_or_else(|| {
vm.new_value_error(format!(
"ocs.export_layer_dwg: layer {layer_name:?} does not exist"
))
})?;
let (snapshot, count) = prepared;
if count == 0 {
return Err(vm.new_value_error(format!(
"ocs.export_layer_dwg: layer {layer_name:?} has no top-level entities"
)));
}
let mut file = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(path)
.map_err(|error| {
vm.new_runtime_error(format!(
"ocs.export_layer_dwg: cannot create {output_path:?}: {error}"
))
})?;
if let Err(error) = acadrust::DwgWriter::write_to_writer(&mut file, &snapshot) {
drop(file);
let _ = std::fs::remove_file(path);
return Err(
vm.new_runtime_error(format!("ocs.export_layer_dwg: DWG write failed: {error}"))
);
}
Ok(count)
}
/// Export a planned set of layers as new DWGs. If any export fails,
/// remove only files created by this call, leaving pre-existing files
/// untouched. No source drawing changes are made.
#[pyfunction]
fn export_layer_dwgs(exports: Vec<Vec<String>>, vm: &VirtualMachine) -> PyResult<PyObjectRef> {
let mut paths = std::collections::HashSet::new();
if exports
.iter()
.any(|row| row.len() != 2 || !paths.insert(row[1].clone()))
{
return Err(vm.new_value_error(
"ocs.export_layer_dwgs: expected unique [layer, path] pairs".to_owned(),
));
}
let mut created = Vec::with_capacity(exports.len());
let mut counts = Vec::with_capacity(exports.len());
for row in exports {
match export_layer_dwg(row[0].clone(), row[1].clone(), vm) {
Ok(count) => {
created.push(row[1].clone());
counts.push(count);
}
Err(error) => {
let failed_cleanup: Vec<String> = created
.into_iter()
.filter(|path| std::fs::remove_file(path).is_err())
.collect();
if !failed_cleanup.is_empty() {
return Err(vm.new_runtime_error(format!(
"ocs.export_layer_dwgs: export failed ({error:?}); cleanup failed for {failed_cleanup:?}"
)));
}
return Err(error);
}
}
}
Ok(vm
.ctx
.new_list(
counts
.into_iter()
.map(|count| vm.new_pyobj(count))
.collect(),
)
.into())
}
/// Name of the active document's current layer (CLAYER).
#[pyfunction]
fn current_layer(vm: &VirtualMachine) -> PyResult<String> {
host_ctx::with_host(|host| host.document().header.current_layer_name.clone())
.ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))
}
/// Read a host-managed system variable without invoking a CAD command.
#[cfg(feature = "experimental-host-settings")]
#[pyfunction]
fn system_variable(name: String, vm: &VirtualMachine) -> PyResult<PyObjectRef> {
let value = host_ctx::with_host(|host| host.system_variable(&name)).ok_or_else(|| {
vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned())
})?;
Ok(match value {
Some(HostSettingValue::Text(text)) => vm.new_pyobj(text),
Some(HostSettingValue::Number(number)) => vm.new_pyobj(number),
None => vm.ctx.none(),
})
}
/// Set a host-managed system variable without nested command dispatch.
#[cfg(feature = "experimental-host-settings")]
#[pyfunction]
fn set_system_variable(
name: String,
value: PyObjectRef,
vm: &VirtualMachine,
) -> PyResult<PyObjectRef> {
let setting = match value.clone().try_into_value::<String>(vm) {
Ok(text) => HostSettingValue::Text(text),
Err(_) => {
HostSettingValue::Number(ArgIntoFloat::try_from_object(vm, value)?.into_float())
}
};
let changed = host_ctx::with_host(|host| host.set_system_variable(&name, setting))
.ok_or_else(|| {
vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned())
})?
.map_err(|error| vm.new_value_error(error))?;
Ok(match changed {
HostSettingValue::Text(text) => vm.new_pyobj(text),
HostSettingValue::Number(number) => vm.new_pyobj(number),
})
}
/// Owning layout of a top-level entity, including sheet order/count.
#[pyfunction]
fn layout_for_entity(handle: u64, vm: &VirtualMachine) -> PyResult<PyObjectRef> {
let found = host_ctx::with_host(|host| {
let document = host.document();
let entity = document.get_entity(Handle::new(handle))?;
let block = document
.block_records
.iter()
.find(|record| record.handle == entity.common().owner_handle)?;
let ObjectType::Layout(layout) = document.objects.get(&block.layout)? else {
return None;
};
let sheet_count = document
.objects
.values()
.filter(|object| matches!(object, ObjectType::Layout(item) if item.name != "Model"))
.count();
Some((layout.name.clone(), layout.tab_order, sheet_count))
})
.ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?;
let Some((name, order, sheet_count)) = found else {
return Ok(vm.ctx.none());
};
let dict = vm.ctx.new_dict();
dict.set_item("name", vm.new_pyobj(name), vm)?;
dict.set_item("order", vm.new_pyobj(order), vm)?;
dict.set_item("sheet_count", vm.new_pyobj(sheet_count), vm)?;
Ok(dict.into())
}
/// Tag/value pairs attached to one INSERT, or None for other entities.
#[pyfunction]
fn block_attributes(handle: u64, vm: &VirtualMachine) -> PyResult<PyObjectRef> {
let found =
host_ctx::with_host(
|host| match host.document().get_entity(Handle::new(handle)) {
Some(EntityType::Insert(insert)) => Some(
insert
.attributes
.iter()
.map(|attribute| (attribute.tag.clone(), attribute.value.clone()))
.collect::<Vec<_>>(),
),
_ => None,
},
)
.ok_or_else(|| {
vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned())
})?;
let Some(attributes) = found else {
return Ok(vm.ctx.none());
};
let mut output = Vec::with_capacity(attributes.len());
for (tag, value) in attributes {
let dict = vm.ctx.new_dict();
dict.set_item("tag", vm.new_pyobj(tag), vm)?;
dict.set_item("value", vm.new_pyobj(value), vm)?;
output.push(dict.into());
}
Ok(vm.ctx.new_list(output).into())
}
/// Replace one INSERT attribute's value by tag, preserving other data.
#[pyfunction]
fn set_block_attribute(
handle: u64,
tag: rustpython_vm::builtins::PyStrRef,
value: rustpython_vm::builtins::PyStrRef,
vm: &VirtualMachine,
) -> PyResult<()> {
let tag = tag.to_string();
let value = value.to_string();
let changed = host_ctx::with_host(|host| {
let Some(EntityType::Insert(mut insert)) =
host.document().get_entity(Handle::new(handle)).cloned()
else {
return false;
};
let Some(attribute) = insert
.attributes
.iter_mut()
.find(|attribute| attribute.tag.eq_ignore_ascii_case(&tag))
else {
return false;
};
attribute.value = value;
host_ctx::ensure_undo_started(host);
host.update_entity(EntityType::Insert(insert))
})
.ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?;
if !changed {
return Err(vm.new_runtime_error(format!(
"ocs.set_block_attribute: INSERT {handle} or tag {tag} was not updated"
)));
}
Ok(())
}
/// Replace the content of one top-level TEXT or MTEXT entity in place.
#[pyfunction]
fn set_text_value(
handle: u64,
value: rustpython_vm::builtins::PyStrRef,
vm: &VirtualMachine,
) -> PyResult<()> {
let value = value.to_string();
let changed = host_ctx::with_host(|host| {
let Some(entity) = host.document().get_entity(Handle::new(handle)).cloned() else {
return false;
};
let updated = match entity {
EntityType::Text(mut text) => {
text.value = value;
EntityType::Text(text)
}
EntityType::MText(mut text) => {
text.value = value;
EntityType::MText(text)
}
_ => return false,
};
host_ctx::ensure_undo_started(host);
host.update_entity(updated)
})
.ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?;
if !changed {
return Err(vm.new_runtime_error(format!(
"ocs.set_text_value: TEXT/MTEXT entity {handle} was not updated"
)));
}
Ok(())
}
/// Set an MTEXT background mode (off, mask, or fill) and border scale.
#[pyfunction]
fn set_mtext_mask(
handle: u64,
mode: rustpython_vm::builtins::PyStrRef,
scale: ArgIntoFloat,
vm: &VirtualMachine,
) -> PyResult<()> {
let scale = scale.into_float();
let mode = mode.to_string();
if !scale.is_finite() || scale < 1.0 || !["off", "mask", "fill"].contains(&mode.as_str()) {
return Err(vm.new_value_error(
"ocs.set_mtext_mask: mode must be off/mask/fill and scale at least 1".to_owned(),
));
}
let changed = host_ctx::with_host(|host| {
let Some(EntityType::MText(mut text)) =
host.document().get_entity(Handle::new(handle)).cloned()
else {
return false;
};
text.background_fill_flags &= !0x03;
text.background_fill_flags |= match mode.as_str() {
"mask" => 0x02,
"fill" => 0x01,
_ => 0,
};
text.background_scale = scale;
host_ctx::ensure_undo_started(host);
host.update_entity(EntityType::MText(text))
})
.ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?;
if !changed {
return Err(vm.new_runtime_error(format!(
"ocs.set_mtext_mask: MTEXT entity {handle} was not updated"
)));
}
Ok(())
}
/// Change only the active insertion/alignment point of a TEXT entity,
/// preserving its style, rotation, content and other properties.
#[pyfunction]
fn move_text(
handle: u64,
x: ArgIntoFloat,
y: ArgIntoFloat,
z: ArgIntoFloat,
vm: &VirtualMachine,
) -> PyResult<()> {
let (x, y, z) = (x.into_float(), y.into_float(), z.into_float());
if ![x, y, z].iter().all(|value| value.is_finite()) {
return Err(vm.new_value_error("ocs.move_text: coordinates must be finite".to_owned()));
}
let changed = host_ctx::with_host(|host| {
let Some(EntityType::Text(mut text)) =
host.document().get_entity(Handle::new(handle)).cloned()
else {
return false;
};
let point = Vector3::new(x, y, z);
if uses_text_alignment_point(&text) {
text.alignment_point = Some(point);
} else {
text.insertion_point = point;
}
host_ctx::ensure_undo_started(host);
host.update_entity(EntityType::Text(text))
})
.ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?;
if !changed {
return Err(vm.new_runtime_error(format!(
"ocs.move_text: TEXT entity {handle} was not updated"
)));
}
Ok(())
}
/// Move and rotate one XY TEXT entity, preserving its other properties.
#[pyfunction]
fn set_text_pose(
handle: u64,
x: ArgIntoFloat,
y: ArgIntoFloat,
z: ArgIntoFloat,
rotation: ArgIntoFloat,
vm: &VirtualMachine,
) -> PyResult<()> {
let (x, y, z, rotation) = (
x.into_float(),
y.into_float(),
z.into_float(),
rotation.into_float(),
);
if ![x, y, z, rotation].iter().all(|v| v.is_finite()) {
return Err(vm.new_value_error(
"ocs.set_text_pose: coordinates and rotation must be finite".to_owned(),
));
}
let changed = host_ctx::with_host(|host| {
let Some(EntityType::Text(mut text)) =
host.document().get_entity(Handle::new(handle)).cloned()
else {
return false;
};
if text.normal != Vector3::UNIT_Z {
return false;
}
let point = Vector3::new(x, y, z);
if uses_text_alignment_point(&text) {
text.alignment_point = Some(point);
} else {
text.insertion_point = point;
}
text.rotation = rotation;
host_ctx::ensure_undo_started(host);
host.update_entity(EntityType::Text(text))
})
.ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?;
if !changed {
return Err(vm.new_runtime_error(format!(
"ocs.set_text_pose: XY TEXT entity {handle} was not updated"
)));
}
Ok(())
}
fn uses_text_alignment_point(text: &Text) -> bool {
!(text.horizontal_alignment == TextHorizontalAlignment::Left
&& text.vertical_alignment == TextVerticalAlignment::Baseline)
}
fn active_text_point(text: &Text) -> Vector3 {
if uses_text_alignment_point(text) {
text.alignment_point.unwrap_or(text.insertion_point)
} else {
text.insertion_point
}
}
/// Area and label point for a circle or a straight, closed lightweight
/// polyline in the world XY plane. Unsupported geometry returns None.
#[pyfunction]
fn get_area(handle: u64, vm: &VirtualMachine) -> PyResult<PyObjectRef> {
let found = host_ctx::with_host(|host| {
let document = host.document();
match document.get_entity(Handle::new(handle)) {
Some(EntityType::Circle(circle))
if circle.normal == Vector3::UNIT_Z && circle.radius > 0.0 =>
{
Some((circle.area(), circle.center))
}
Some(EntityType::LwPolyline(polyline))
if polyline.is_closed
&& polyline.normal == Vector3::UNIT_Z
&& polyline.vertices.len() >= 3
&& polyline.vertices.iter().all(|v| v.bulge == 0.0) =>
{
let mut double_area = 0.0;
let mut cx = 0.0;
let mut cy = 0.0;
for index in 0..polyline.vertices.len() {
let a = polyline.vertices[index].location;
let b = polyline.vertices[(index + 1) % polyline.vertices.len()].location;
let cross = a.x * b.y - b.x * a.y;
double_area += cross;
cx += (a.x + b.x) * cross;
cy += (a.y + b.y) * cross;
}
if double_area.abs() <= f64::EPSILON {
None
} else {
Some((
double_area.abs() / 2.0,
Vector3::new(
cx / (3.0 * double_area),
cy / (3.0 * double_area),
polyline.elevation,
),
))
}
}
_ => None,
}
})
.ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?;
let Some((area, point)) = found else {
return Ok(vm.ctx.none());
};
let dict = vm.ctx.new_dict();
dict.set_item("area", vm.new_pyobj(area), vm)?;
dict.set_item(
"point",
vm.ctx
.new_list(vec![
vm.new_pyobj(point.x),
vm.new_pyobj(point.y),
vm.new_pyobj(point.z),
])
.into(),
vm,
)?;
Ok(dict.into())
}
/// Length, halfway point, and local tangent for an XY line, circle, arc,
/// or straight-segment lightweight polyline.
#[pyfunction]
fn get_curve_measure(handle: u64, vm: &VirtualMachine) -> PyResult<PyObjectRef> {
let found = host_ctx::with_host(|host| {
let document = host.document();
match document.get_entity(Handle::new(handle)) {
Some(EntityType::Line(line))
if line.normal == Vector3::UNIT_Z
&& (line.start.z - line.end.z).abs() <= 1e-9
&& line.length() > 0.0 =>
{
Some((
line.length(),
line.midpoint(),
(line.end.y - line.start.y).atan2(line.end.x - line.start.x),
))
}
Some(EntityType::Circle(circle))
if circle.normal == Vector3::UNIT_Z && circle.radius > 0.0 =>
{
Some((
2.0 * std::f64::consts::PI * circle.radius,
Vector3::new(
circle.center.x - circle.radius,
circle.center.y,
circle.center.z,
),
std::f64::consts::FRAC_PI_2,
))
}
Some(EntityType::Arc(arc))
if arc.normal == Vector3::UNIT_Z
&& arc.radius > 0.0
&& arc.arc_length() > 0.0 =>
{
let angle = arc.start_angle + arc.sweep_angle() / 2.0;
Some((
arc.arc_length(),
arc.midpoint(),
angle + std::f64::consts::FRAC_PI_2,
))
}
Some(EntityType::LwPolyline(polyline))
if polyline.normal == Vector3::UNIT_Z
&& polyline.vertices.len() >= 2
&& polyline.vertices.iter().all(|v| v.bulge == 0.0) =>
{
let segment_count = if polyline.is_closed {
polyline.vertices.len()
} else {
polyline.vertices.len() - 1
};
let mut segments = Vec::with_capacity(segment_count);
let mut total = 0.0;
for index in 0..segment_count {
let a = polyline.vertices[index].location;
let b = polyline.vertices[(index + 1) % polyline.vertices.len()].location;
let length = ((b.x - a.x).powi(2) + (b.y - a.y).powi(2)).sqrt();
segments.push((a, b, length));
total += length;
}
if total <= 0.0 {
return None;
}
let mut remaining = total / 2.0;
for (a, b, length) in segments {
if length == 0.0 {
continue;
}
if remaining <= length {
let fraction = remaining / length;
let point = Vector3::new(
a.x + (b.x - a.x) * fraction,
a.y + (b.y - a.y) * fraction,
polyline.elevation,
);
return Some((total, point, (b.y - a.y).atan2(b.x - a.x)));
}
remaining -= length;
}
None
}
_ => None,
}
})
.ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?;
let Some((length, point, rotation)) = found else {
return Ok(vm.ctx.none());
};
let dict = vm.ctx.new_dict();
dict.set_item("length", vm.new_pyobj(length), vm)?;
dict.set_item(
"point",
vm.ctx
.new_list(vec![
vm.new_pyobj(point.x),
vm.new_pyobj(point.y),
vm.new_pyobj(point.z),
])
.into(),
vm,
)?;
dict.set_item("rotation", vm.new_pyobj(rotation), vm)?;
dict.set_item(
"tangent",
vm.ctx
.new_list(vec![
vm.new_pyobj(rotation.cos()),
vm.new_pyobj(rotation.sin()),
])
.into(),
vm,
)?;
Ok(dict.into())
}
/// Start and end points of a nonzero XY line, or None.
#[pyfunction]
fn line_endpoints(handle: u64, vm: &VirtualMachine) -> PyResult<PyObjectRef> {
let found =
host_ctx::with_host(
|host| match host.document().get_entity(Handle::new(handle)) {
Some(EntityType::Line(line))
if line.normal == Vector3::UNIT_Z
&& (line.start.z - line.end.z).abs() <= 1e-9
&& line.length() > 0.0 =>
{
Some((line.start, line.end))
}
_ => None,
},
)
.ok_or_else(|| {
vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned())
})?;
let Some((start, end)) = found else {
return Ok(vm.ctx.none());
};
let output = vm.ctx.new_list(vec![
vm.ctx
.new_list(vec![
vm.new_pyobj(start.x),
vm.new_pyobj(start.y),
vm.new_pyobj(start.z),
])
.into(),
vm.ctx
.new_list(vec![
vm.new_pyobj(end.x),
vm.new_pyobj(end.y),
vm.new_pyobj(end.z),
])
.into(),
]);
Ok(output.into())
}
/// Insertion point of one top-level block reference, or None.
#[pyfunction]
fn block_insert_point(handle: u64, vm: &VirtualMachine) -> PyResult<PyObjectRef> {
let found =
host_ctx::with_host(
|host| match host.document().get_entity(Handle::new(handle)) {
Some(EntityType::Insert(insert)) => Some(insert.insert_point),
_ => None,
},
)
.ok_or_else(|| {
vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned())
})?;
let Some(point) = found else {
return Ok(vm.ctx.none());
};
Ok(vm
.ctx
.new_list(vec![
vm.new_pyobj(point.x),
vm.new_pyobj(point.y),
vm.new_pyobj(point.z),
])
.into())
}
/// Axis-aligned XY footprint of a simple unrotated block reference.
/// Rejects nested/unsupported definition entities rather than guessing.
#[pyfunction]
fn block_bounds_xy(handle: u64, vm: &VirtualMachine) -> PyResult<PyObjectRef> {
let found = host_ctx::with_host(|host| {
let document = host.document();
let Some(EntityType::Insert(insert)) = document.get_entity(Handle::new(handle)) else {
return None;
};
if insert.normal != Vector3::UNIT_Z
|| insert.rotation.abs() > 1e-9
|| insert.row_count != 1
|| insert.column_count != 1
{
return None;
}
let record = document.block_records.get(&insert.block_name)?;
if record.entity_handles.is_empty() {
return None;
}
let base = record.base_point;
let transform = Transform::from_translation(Vector3::new(-base.x, -base.y, -base.z))
.then(&Transform::from_scaling(Vector3::new(
insert.x_scale(),
insert.y_scale(),
insert.z_scale(),
)))
.then(&Transform::from_translation(insert.insert_point));
let mut xmin = f64::INFINITY;
let mut ymin = f64::INFINITY;
let mut xmax = f64::NEG_INFINITY;
let mut ymax = f64::NEG_INFINITY;
for entity_handle in &record.entity_handles {
let mut entity = document.get_entity(*entity_handle)?.clone();
if !matches!(
entity,
EntityType::Line(_)
| EntityType::Arc(_)
| EntityType::Circle(_)
| EntityType::LwPolyline(_)
) {
return None;
}
entity.apply_transform(&transform);
let bounds = entity.as_entity().bounding_box();
if ![bounds.min.x, bounds.min.y, bounds.max.x, bounds.max.y]
.iter()
.all(|value| value.is_finite())
{
return None;
}
xmin = xmin.min(bounds.min.x);
ymin = ymin.min(bounds.min.y);
xmax = xmax.max(bounds.max.x);
ymax = ymax.max(bounds.max.y);
}
if xmax - xmin <= 1e-9 || ymax - ymin <= 1e-9 {
return None;
}
Some([xmin, ymin, xmax, ymax])
})
.ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?;
match found {
Some(bounds) => Ok(vm
.ctx
.new_list(
bounds
.into_iter()
.map(|value| vm.new_pyobj(value))
.collect(),
)
.into()),
None => Ok(vm.ctx.none()),
}
}
/// Segment metrics for one XY lightweight polyline, including bulge arcs.
#[pyfunction]
fn polyline_segments(handle: u64, vm: &VirtualMachine) -> PyResult<PyObjectRef> {
let found = host_ctx::with_host(|host| {
let document = host.document();
let Some(EntityType::LwPolyline(polyline)) = document.get_entity(Handle::new(handle))
else {
return None;
};
if polyline.normal != Vector3::UNIT_Z || polyline.vertices.len() < 2 {
return None;
}
let count = if polyline.is_closed {
polyline.vertices.len()
} else {
polyline.vertices.len() - 1
};
let mut segments = Vec::with_capacity(count);
for index in 0..count {
let a = polyline.vertices[index];
let b = polyline.vertices[(index + 1) % polyline.vertices.len()];
let chord = ((b.location.x - a.location.x).powi(2)
+ (b.location.y - a.location.y).powi(2))
.sqrt();
let radius = if a.bulge.abs() > f64::EPSILON && chord > 0.0 {
Some(chord * (1.0 + a.bulge * a.bulge) / (4.0 * a.bulge.abs()))
} else {
None
};
let center = radius.map(|_| {
let offset = (1.0 - a.bulge * a.bulge) / (4.0 * a.bulge);
(
(a.location.x + b.location.x) / 2.0
- (b.location.y - a.location.y) * offset,
(a.location.y + b.location.y) / 2.0
+ (b.location.x - a.location.x) * offset,
)
});
let length = radius.map_or(chord, |r| r * (4.0 * a.bulge.atan()).abs());
segments.push((
a.location,
b.location,
a.start_width,
a.end_width,
length,
center,
radius,
));
}
Some(segments)
})
.ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?;
let Some(segments) = found else {
return Ok(vm.ctx.none());
};
let mut output = Vec::with_capacity(segments.len());
for (start, end, start_width, end_width, length, center, radius) in segments {
let dict = vm.ctx.new_dict();
dict.set_item(
"start",
vm.ctx
.new_list(vec![vm.new_pyobj(start.x), vm.new_pyobj(start.y)])
.into(),
vm,
)?;
dict.set_item(
"end",
vm.ctx
.new_list(vec![vm.new_pyobj(end.x), vm.new_pyobj(end.y)])
.into(),
vm,
)?;
dict.set_item("start_width", vm.new_pyobj(start_width), vm)?;
dict.set_item("end_width", vm.new_pyobj(end_width), vm)?;
dict.set_item("length", vm.new_pyobj(length), vm)?;
dict.set_item(
"center",
center.map_or_else(
|| vm.ctx.none(),
|(x, y)| {
vm.ctx
.new_list(vec![vm.new_pyobj(x), vm.new_pyobj(y)])
.into()
},
),
vm,
)?;
dict.set_item(
"radius",
radius.map_or_else(|| vm.ctx.none(), |r| vm.new_pyobj(r)),
vm,
)?;
output.push(dict.into());
}
Ok(vm.ctx.new_list(output).into())
}
/// Equal-distance samples for an XY line, arc, or circle.
#[pyfunction]
fn curve_samples(handle: u64, segments: usize, vm: &VirtualMachine) -> PyResult<PyObjectRef> {
if !(2..=4096).contains(&segments) {
return Err(vm.new_value_error(
"ocs.curve_samples: segments must be between 2 and 4096".to_owned(),
));
}
let found =
host_ctx::with_host(
|host| match host.document().get_entity(Handle::new(handle)) {
Some(EntityType::Line(line))
if line.normal == Vector3::UNIT_Z
&& (line.start.z - line.end.z).abs() <= 1e-9
&& line.length() > 0.0 =>
{
let points = (0..=segments)
.map(|i| {
let t = i as f64 / segments as f64;
Vector2::new(
line.start.x + (line.end.x - line.start.x) * t,
line.start.y + (line.end.y - line.start.y) * t,
)
})
.collect::<Vec<_>>();
Some((points, line.start.z, false))
}
Some(EntityType::Circle(circle))
if circle.normal == Vector3::UNIT_Z && circle.radius > 0.0 =>
{
let points = (0..segments)
.map(|i| {
let angle = 2.0 * std::f64::consts::PI * i as f64 / segments as f64;
Vector2::new(
circle.center.x + circle.radius * angle.cos(),
circle.center.y + circle.radius * angle.sin(),
)
})
.collect::<Vec<_>>();
Some((points, circle.center.z, true))
}
Some(EntityType::Arc(arc))
if arc.normal == Vector3::UNIT_Z
&& arc.radius > 0.0
&& arc.arc_length() > 0.0 =>
{
let points = (0..=segments)
.map(|i| {
let angle = arc.start_angle
+ arc.sweep_angle() * i as f64 / segments as f64;
Vector2::new(
arc.center.x + arc.radius * angle.cos(),
arc.center.y + arc.radius * angle.sin(),
)
})
.collect::<Vec<_>>();
Some((points, arc.center.z, false))
}
_ => None,
},
)
.ok_or_else(|| {
vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned())
})?;
let Some((points, elevation, closed)) = found else {
return Ok(vm.ctx.none());
};
let vertices = points
.into_iter()
.map(|point| {
vm.ctx
.new_list(vec![vm.new_pyobj(point.x), vm.new_pyobj(point.y)])
.into()
})
.collect();
let dict = vm.ctx.new_dict();
dict.set_item("vertices", vm.ctx.new_list(vertices).into(), vm)?;
dict.set_item("elevation", vm.new_pyobj(elevation), vm)?;
dict.set_item("closed", vm.new_pyobj(closed), vm)?;
Ok(dict.into())
}
/// Modelspace XY outline of a rectangular, unclipped +Z paperspace viewport.
#[pyfunction]
fn viewport_model_outline(handle: u64, vm: &VirtualMachine) -> PyResult<PyObjectRef> {
let found = host_ctx::with_host(|host| {
let Some(EntityType::Viewport(viewport)) =
host.document().get_entity(Handle::new(handle))
else {
return None;
};
if viewport.id == 1
|| !viewport.clip_boundary_handle.is_null()
|| viewport.view_direction != Vector3::UNIT_Z
|| ![
viewport.width,
viewport.height,
viewport.view_height,
viewport.twist_angle,
viewport.view_center.x,
viewport.view_center.y,
viewport.view_target.x,
viewport.view_target.y,
viewport.view_target.z,
]
.iter()
.all(|value| value.is_finite())
|| viewport.width <= 0.0
|| viewport.height <= 0.0
|| viewport.view_height <= 0.0
{
return None;
}
let scale = viewport.view_height / viewport.height;
let (sin, cos) = (-viewport.twist_angle).sin_cos();
let corners = [
(-viewport.width / 2.0, -viewport.height / 2.0),
(viewport.width / 2.0, -viewport.height / 2.0),
(viewport.width / 2.0, viewport.height / 2.0),
(-viewport.width / 2.0, viewport.height / 2.0),
];
let points = corners.map(|(x, y)| {
let dx = x * scale + viewport.view_center.x;
let dy = y * scale + viewport.view_center.y;
[
viewport.view_target.x + dx * cos - dy * sin,
viewport.view_target.y + dx * sin + dy * cos,
]
});
Some((points, viewport.view_target.z))
})
.ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?;
let Some((points, elevation)) = found else {
return Ok(vm.ctx.none());
};
let dict = vm.ctx.new_dict();
let vertices = points
.into_iter()
.map(|point| {
vm.ctx
.new_list(vec![vm.new_pyobj(point[0]), vm.new_pyobj(point[1])])
.into()
})
.collect();
dict.set_item("vertices", vm.ctx.new_list(vertices).into(), vm)?;
dict.set_item("elevation", vm.new_pyobj(elevation), vm)?;
Ok(dict.into())
}
/// Copy supported modelspace XY curves through a rectangular +Z viewport.
#[pyfunction]
fn copy_model_to_paper(
handles: Vec<u64>,
viewport_handle: u64,
vm: &VirtualMachine,
) -> PyResult<PyObjectRef> {
if handles.is_empty() {
return Ok(vm.ctx.new_list(Vec::<PyObjectRef>::new()).into());
}
let copies = host_ctx::with_host(|host| {
let document = host.document();
let Some(EntityType::Viewport(viewport)) =
document.get_entity(Handle::new(viewport_handle))
else {
return None;
};
if viewport.id == 1
|| !viewport.clip_boundary_handle.is_null()
|| viewport.view_direction != Vector3::UNIT_Z
|| ![
viewport.width,
viewport.height,
viewport.view_height,
viewport.twist_angle,
viewport.center.x,
viewport.center.y,
viewport.view_center.x,
viewport.view_center.y,
viewport.view_target.x,
viewport.view_target.y,
viewport.view_target.z,
]
.iter()
.all(|value| value.is_finite())
|| viewport.width <= 0.0
|| viewport.height <= 0.0
|| viewport.view_height <= 0.0
{
return None;
}
let paper_block = viewport.common.owner_handle;
let block = document
.block_records
.iter()
.find(|record| record.handle == paper_block)?;
let ObjectType::Layout(layout) = document.objects.get(&block.layout)? else {
return None;
};
if layout.name == "Model" {
return None;
}
let scale = viewport.height / viewport.view_height;
let transform = Transform::from_translation(Vector3::new(
-viewport.view_target.x,
-viewport.view_target.y,
-viewport.view_target.z,
))
.then(&Transform::from_rotation(
Vector3::UNIT_Z,
viewport.twist_angle,
))
.then(&Transform::from_scaling(Vector3::new(scale, scale, 1.0)))
.then(&Transform::from_translation(Vector3::new(
viewport.center.x - viewport.view_center.x * scale,
viewport.center.y - viewport.view_center.y * scale,
0.0,
)));
let mut copies = Vec::with_capacity(handles.len());
for handle in handles {
let mut entity = document.get_entity(Handle::new(handle))?.clone();
if entity.common().owner_handle != document.header.model_space_block_handle {
return None;
}
let planar = match &entity {
EntityType::Line(item) => {
item.normal == Vector3::UNIT_Z
&& (item.start.z - viewport.view_target.z).abs() <= 1e-9
&& (item.end.z - viewport.view_target.z).abs() <= 1e-9
}
EntityType::Arc(item) => {
item.normal == Vector3::UNIT_Z
&& (item.center.z - viewport.view_target.z).abs() <= 1e-9
}
EntityType::Circle(item) => {
item.normal == Vector3::UNIT_Z
&& (item.center.z - viewport.view_target.z).abs() <= 1e-9
}
EntityType::LwPolyline(item) => {
item.normal == Vector3::UNIT_Z
&& (item.elevation - viewport.view_target.z).abs() <= 1e-9
}
_ => false,
};
if !planar {
return None;
}
entity.as_entity_mut().apply_transform(&transform);
entity.common_mut().handle = Handle::NULL;
entity.common_mut().owner_handle = paper_block;
copies.push(entity);
}
Some(copies)
})
.ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?
.ok_or_else(|| {
vm.new_value_error(
"ocs.copy_model_to_paper: unsupported viewport or source geometry".to_owned(),
)
})?;
let new_handles = host_ctx::with_host(|host| {
host_ctx::ensure_undo_started(host);
host.add_entities(copies)
})
.ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?;
if new_handles.iter().any(|handle| handle.is_null()) {
return Err(vm.new_runtime_error(
"ocs.copy_model_to_paper: host did not add every entity".to_owned(),
));
}
Ok(vm
.ctx
.new_list(
new_handles
.into_iter()
.map(|handle| vm.new_pyobj(handle.value()))
.collect(),
)
.into())
}
/// Mirror supported planar curves across an existing XY LINE, optionally
/// removing the originals. The axis itself is never mirrored or removed.
#[pyfunction]
fn mirror_xy_entities(
handles: Vec<u64>,
axis_handle: u64,
erase_source: bool,
vm: &VirtualMachine,
) -> PyResult<PyObjectRef> {
if handles.is_empty() {
return Ok(vm.ctx.new_list(Vec::<PyObjectRef>::new()).into());
}
let unique: std::collections::HashSet<u64> = handles.iter().copied().collect();
if unique.len() != handles.len() || unique.contains(&axis_handle) {
return Err(vm.new_value_error(
"ocs.mirror_xy_entities: handles must be unique and exclude the axis".to_owned(),
));
}
let prepared = host_ctx::with_host(|host| {
let document = host.document();
let Some(EntityType::Line(axis)) = document.get_entity(Handle::new(axis_handle)) else {
return None;
};
if axis.normal != Vector3::UNIT_Z
|| (axis.start.z - axis.end.z).abs() > 1e-9
|| axis.length() <= 0.0
{
return None;
}
let transform = Transform::from_mirror_line(axis.start, axis.end);
let mut copies = Vec::with_capacity(handles.len());
for handle in &handles {
let mut entity = document.get_entity(Handle::new(*handle))?.clone();
let planar = match &entity {
EntityType::Line(item) => {
item.normal == Vector3::UNIT_Z
&& (item.start.z - axis.start.z).abs() <= 1e-9
&& (item.end.z - axis.start.z).abs() <= 1e-9
}
EntityType::Arc(item) => {
item.normal == Vector3::UNIT_Z
&& (item.center.z - axis.start.z).abs() <= 1e-9
}
EntityType::Circle(item) => {
item.normal == Vector3::UNIT_Z
&& (item.center.z - axis.start.z).abs() <= 1e-9
}
EntityType::LwPolyline(item) => {
item.normal == Vector3::UNIT_Z
&& (item.elevation - axis.start.z).abs() <= 1e-9
}
_ => false,
};
if !planar {
return None;
}
entity.apply_mirror(&transform);
entity.common_mut().handle = Handle::NULL;
copies.push(entity);
}
Some(copies)
})
.ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?
.ok_or_else(|| {
vm.new_value_error(
"ocs.mirror_xy_entities: axis or source must be supported planar geometry"
.to_owned(),
)
})?;
let (added, removed_all) = host_ctx::with_host(|host| {
host_ctx::ensure_undo_started(host);
let added = host.add_entities(prepared);
let mut removed_all = true;
if erase_source && added.iter().all(|handle| !handle.is_null()) {
for handle in &handles {
removed_all &= host.remove_entity(Handle::new(*handle));
}
}
(added, removed_all)
})
.ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?;
if added.iter().any(|handle| handle.is_null()) {
return Err(vm.new_runtime_error(
"ocs.mirror_xy_entities: host did not add every entity".to_owned(),
));
}
if !removed_all {
return Err(vm.new_runtime_error(
"ocs.mirror_xy_entities: a source could not be removed; inspect the drawing"
.to_owned(),
));
}
Ok(vm
.ctx
.new_list(
added
.into_iter()
.map(|handle| vm.new_pyobj(handle.value()))
.collect(),
)
.into())
}
/// Mirror planar curves across the XY line through `point` in `tangent`
/// direction. The calling script can derive these from a curve midpoint.
#[pyfunction]
fn mirror_xy_about_line(
handles: Vec<u64>,
point: Vec<f64>,
tangent: Vec<f64>,
erase_source: bool,
vm: &VirtualMachine,
) -> PyResult<PyObjectRef> {
if point.len() != 3
|| tangent.len() != 2
|| point
.iter()
.chain(tangent.iter())
.any(|value| !value.is_finite())
|| tangent[0].hypot(tangent[1]) <= 1e-9
|| handles.len()
!= handles
.iter()
.copied()
.collect::<std::collections::HashSet<_>>()
.len()
{
return Err(vm.new_value_error(
"ocs.mirror_xy_about_line: invalid point, tangent, or duplicate handles".to_owned(),
));
}
if handles.is_empty() {
return Ok(vm.ctx.new_list(Vec::<PyObjectRef>::new()).into());
}
let start = Vector3::new(point[0], point[1], point[2]);
let end = Vector3::new(point[0] + tangent[0], point[1] + tangent[1], point[2]);
let transform = Transform::from_mirror_line(start, end);
let prepared = host_ctx::with_host(|host| {
let mut copies = Vec::with_capacity(handles.len());
for handle in &handles {
let mut entity = host.document().get_entity(Handle::new(*handle))?.clone();
let planar = match &entity {
EntityType::Line(item) => item.normal == Vector3::UNIT_Z
&& (item.start.z - start.z).abs() <= 1e-9
&& (item.end.z - start.z).abs() <= 1e-9,
EntityType::Arc(item) => item.normal == Vector3::UNIT_Z
&& (item.center.z - start.z).abs() <= 1e-9,
EntityType::Circle(item) => item.normal == Vector3::UNIT_Z
&& (item.center.z - start.z).abs() <= 1e-9,
EntityType::LwPolyline(item) => item.normal == Vector3::UNIT_Z
&& (item.elevation - start.z).abs() <= 1e-9,
_ => false,
};
if !planar {
return None;
}
entity.apply_mirror(&transform);
entity.common_mut().handle = Handle::NULL;
copies.push(entity);
}
Some(copies)
})
.ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?
.ok_or_else(|| vm.new_value_error(
"ocs.mirror_xy_about_line: only planar LINE/ARC/CIRCLE/LWPolyline sources are supported".to_owned(),
))?;
let (added, removed_all) = host_ctx::with_host(|host| {
host_ctx::ensure_undo_started(host);
let added = host.add_entities(prepared);
let mut removed_all = true;
if erase_source && added.iter().all(|handle| !handle.is_null()) {
for handle in &handles {
removed_all &= host.remove_entity(Handle::new(*handle));
}
}
(added, removed_all)
})
.ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?;
if added.iter().any(|handle| handle.is_null()) || !removed_all {
return Err(vm.new_runtime_error(
"ocs.mirror_xy_about_line: host could not mirror every entity; inspect the drawing"
.to_owned(),
));
}
Ok(vm
.ctx
.new_list(
added
.into_iter()
.map(|handle| vm.new_pyobj(handle.value()))
.collect(),
)
.into())
}
/// Remove the span between two points strictly inside an XY LINE, keeping
/// its first fragment under the original handle and adding the second.
#[pyfunction]
fn break_xy_line(
handle: u64,
first: Vec<f64>,
second: Vec<f64>,
vm: &VirtualMachine,
) -> PyResult<u64> {
if first.len() != 2
|| second.len() != 2
|| first
.iter()
.chain(second.iter())
.any(|value| !value.is_finite())
{
return Err(vm.new_value_error(
"ocs.break_xy_line: break points must be finite XY pairs".to_owned(),
));
}
let prepared = host_ctx::with_host(|host| {
let Some(EntityType::Line(line)) = host.document().get_entity(Handle::new(handle))
else {
return None;
};
if line.normal != Vector3::UNIT_Z || (line.start.z - line.end.z).abs() > 1e-9 {
return None;
}
let dx = line.end.x - line.start.x;
let dy = line.end.y - line.start.y;
let length_sq = dx * dx + dy * dy;
if length_sq <= 1e-18 {
return None;
}
let parameter = |point: &[f64]| -> Option<f64> {
let px = point[0] - line.start.x;
let py = point[1] - line.start.y;
let t = (px * dx + py * dy) / length_sq;
let deviation = (px * dy - py * dx).abs() / length_sq.sqrt();
if t <= 1e-9 || t >= 1.0 - 1e-9 || deviation > 1e-6 {
None
} else {
Some(t)
}
};
let a = parameter(&first)?;
let b = parameter(&second)?;
let before = a.min(b);
let after = a.max(b);
let at =
|t: f64| Vector3::new(line.start.x + t * dx, line.start.y + t * dy, line.start.z);
let mut initial = line.clone();
let mut final_part = line.clone();
initial.end = at(before);
final_part.start = at(after);
final_part.common.handle = Handle::NULL;
Some((EntityType::Line(initial), EntityType::Line(final_part)))
})
.ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?
.ok_or_else(|| {
vm.new_value_error(
"ocs.break_xy_line: target must be an XY LINE and both points strictly on it"
.to_owned(),
)
})?;
let result = host_ctx::with_host(|host| {
host_ctx::ensure_undo_started(host);
let added = host.add_entity(prepared.1);
if added.is_null() {
return None;
}
if !host.update_entity(prepared.0) {
host.remove_entity(added);
return None;
}
Some(added.value())
})
.ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?
.ok_or_else(|| {
vm.new_runtime_error("ocs.break_xy_line: host could not edit the line".to_owned())
})?;
Ok(result)
}
/// Remove the angular span between two interior XY points on an ARC,
/// retaining the first fragment's original handle.
#[pyfunction]
fn break_xy_arc(
handle: u64,
first: Vec<f64>,
second: Vec<f64>,
vm: &VirtualMachine,
) -> PyResult<u64> {
if first.len() != 2
|| second.len() != 2
|| first
.iter()
.chain(second.iter())
.any(|value| !value.is_finite())
{
return Err(
vm.new_value_error("ocs.break_xy_arc: points must be finite XY pairs".to_owned())
);
}
let prepared = host_ctx::with_host(|host| {
let Some(EntityType::Arc(arc)) = host.document().get_entity(Handle::new(handle)) else {
return None;
};
if arc.normal != Vector3::UNIT_Z
|| arc.radius <= 0.0
|| arc.sweep_angle() <= 1e-9
|| arc.sweep_angle() >= std::f64::consts::TAU - 1e-9
{
return None;
}
let parameter = |point: &[f64]| -> Option<f64> {
let dx = point[0] - arc.center.x;
let dy = point[1] - arc.center.y;
if (dx.hypot(dy) - arc.radius).abs() > 1e-6 {
return None;
}
let angle = dy.atan2(dx);
let t = (angle - arc.start_angle).rem_euclid(std::f64::consts::TAU);
if t <= 1e-9 || t >= arc.sweep_angle() - 1e-9 {
None
} else {
Some(t)
}
};
let a = parameter(&first)?;
let b = parameter(&second)?;
let mut initial = arc.clone();
let mut final_part = arc.clone();
initial.end_angle = arc.start_angle + a.min(b);
final_part.start_angle = arc.start_angle + a.max(b);
final_part.common.handle = Handle::NULL;
Some((EntityType::Arc(initial), EntityType::Arc(final_part)))
})
.ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?
.ok_or_else(|| {
vm.new_value_error(
"ocs.break_xy_arc: target must be a nonfull XY ARC with interior points on it"
.to_owned(),
)
})?;
let result = host_ctx::with_host(|host| {
host_ctx::ensure_undo_started(host);
let added = host.add_entity(prepared.1);
if added.is_null() {
return None;
}
if !host.update_entity(prepared.0) {
host.remove_entity(added);
return None;
}
Some(added.value())
})
.ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?
.ok_or_else(|| {
vm.new_runtime_error("ocs.break_xy_arc: host could not edit the arc".to_owned())
})?;
Ok(result)
}
/// Replace an XY CIRCLE with the complementary ARC after removing the
/// counterclockwise span from `first` to `second`.
#[pyfunction]
fn break_xy_circle(
handle: u64,
first: Vec<f64>,
second: Vec<f64>,
vm: &VirtualMachine,
) -> PyResult<()> {
if first.len() != 2
|| second.len() != 2
|| first
.iter()
.chain(second.iter())
.any(|value| !value.is_finite())
{
return Err(vm.new_value_error(
"ocs.break_xy_circle: points must be finite XY pairs".to_owned(),
));
}
let prepared = host_ctx::with_host(|host| {
let Some(EntityType::Circle(circle)) = host.document().get_entity(Handle::new(handle))
else {
return None;
};
if circle.normal != Vector3::UNIT_Z || circle.radius <= 0.0 {
return None;
}
let angle = |point: &[f64]| -> Option<f64> {
let dx = point[0] - circle.center.x;
let dy = point[1] - circle.center.y;
if (dx.hypot(dy) - circle.radius).abs() > 1e-6 {
None
} else {
Some(dy.atan2(dx).rem_euclid(std::f64::consts::TAU))
}
};
let start = angle(&first)?;
let end = angle(&second)?;
if (end - start).rem_euclid(std::f64::consts::TAU) <= 1e-9 {
return None;
}
let mut arc = Arc::from_center_radius_angles(circle.center, circle.radius, end, start);
arc.common = circle.common.clone();
arc.thickness = circle.thickness;
arc.normal = circle.normal;
Some(EntityType::Arc(arc))
})
.ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?
.ok_or_else(|| {
vm.new_value_error(
"ocs.break_xy_circle: target must be an XY CIRCLE with distinct points on it"
.to_owned(),
)
})?;
let updated = host_ctx::with_host(|host| {
host_ctx::ensure_undo_started(host);
host.update_entity(prepared)
})
.ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?;
if !updated {
return Err(vm.new_runtime_error(
"ocs.break_xy_circle: host could not edit the circle".to_owned(),
));
}
Ok(())
}
/// Remove the path interval between two interior points on a straight,
/// zero-width XY lightweight polyline. A closed polyline becomes one open
/// complementary path, under its original handle.
#[pyfunction]
fn break_xy_polyline(
handle: u64,
first: Vec<f64>,
second: Vec<f64>,
vm: &VirtualMachine,
) -> PyResult<u64> {
if first.len() != 2
|| second.len() != 2
|| first
.iter()
.chain(second.iter())
.any(|value| !value.is_finite())
{
return Err(vm.new_value_error(
"ocs.break_xy_polyline: points must be finite XY pairs".to_owned(),
));
}
let prepared = host_ctx::with_host(|host| {
let Some(EntityType::LwPolyline(polyline)) = host.document().get_entity(Handle::new(handle)) else {
return None;
};
if polyline.normal != Vector3::UNIT_Z
|| polyline.vertices.len() < (if polyline.is_closed { 3 } else { 2 })
|| polyline.vertices.iter().any(|vertex| vertex.bulge != 0.0
|| vertex.start_width != 0.0 || vertex.end_width != 0.0) {
return None;
}
let segment_count = polyline.vertices.len() - if polyline.is_closed { 0 } else { 1 };
let mut lengths = Vec::with_capacity(segment_count);
let mut total = 0.0;
for index in 0..segment_count {
let a = polyline.vertices[index].location;
let b = polyline.vertices[(index + 1) % polyline.vertices.len()].location;
let length = (b.x - a.x).hypot(b.y - a.y);
lengths.push(length);
total += length;
}
if total <= 1e-9 {
return None;
}
let locate = |point: &[f64]| -> Option<(usize, f64)> {
let mut walked = 0.0;
for (index, length) in lengths.iter().copied().enumerate() {
if length <= 1e-9 {
continue;
}
let a = polyline.vertices[index].location;
let b = polyline.vertices[(index + 1) % polyline.vertices.len()].location;
let dx = b.x - a.x;
let dy = b.y - a.y;
let px = point[0] - a.x;
let py = point[1] - a.y;
let fraction = (px * dx + py * dy) / (length * length);
let deviation = (px * dy - py * dx).abs() / length;
let position = walked + fraction * length;
if fraction > 1e-9 && fraction < 1.0 - 1e-9
&& deviation <= 1e-6 && position > 1e-9
&& position < total - 1e-9 {
return Some((index, position));
}
walked += length;
}
None
};
let a = locate(&first)?;
let b = locate(&second)?;
if (first[0] - second[0]).hypot(first[1] - second[1]) <= 1e-9 {
return None;
}
if polyline.is_closed {
let mut complement = polyline.clone();
let mut vertices = vec![LwVertex::new(Vector2::new(second[0], second[1]))];
if b.0 != a.0 || b.1 > a.1 {
let mut index = (b.0 + 1) % polyline.vertices.len();
loop {
vertices.push(polyline.vertices[index]);
if index == a.0 {
break;
}
index = (index + 1) % polyline.vertices.len();
}
}
vertices.push(LwVertex::new(Vector2::new(first[0], first[1])));
complement.vertices = vertices;
complement.is_closed = false;
return Some((EntityType::LwPolyline(complement), None));
}
let ((before_index, before_point), (after_index, after_point)) =
if a.1 <= b.1 { ((a.0, &first), (b.0, &second)) }
else { ((b.0, &second), (a.0, &first)) };
let mut initial = polyline.clone();
initial.vertices.truncate(before_index + 1);
initial.vertices.push(LwVertex::new(Vector2::new(before_point[0], before_point[1])));
let mut final_part = polyline.clone();
final_part.vertices = vec![LwVertex::new(Vector2::new(after_point[0], after_point[1]))];
final_part.vertices.extend_from_slice(&polyline.vertices[after_index + 1..]);
final_part.common.handle = Handle::NULL;
Some((EntityType::LwPolyline(initial), Some(EntityType::LwPolyline(final_part))))
})
.ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?
.ok_or_else(|| vm.new_value_error(
"ocs.break_xy_polyline: target must be a straight zero-width XY polyline with distinct interior points".to_owned(),
))?;
let result = host_ctx::with_host(|host| {
host_ctx::ensure_undo_started(host);
if let Some(final_part) = prepared.1 {
let added = host.add_entity(final_part);
if added.is_null() {
return None;
}
if !host.update_entity(prepared.0) {
host.remove_entity(added);
return None;
}
Some(added.value())
} else if host.update_entity(prepared.0) {
Some(handle)
} else {
None
}
})
.ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?
.ok_or_else(|| {
vm.new_runtime_error(
"ocs.break_xy_polyline: host could not edit the polyline".to_owned(),
)
})?;
Ok(result)
}
/// Rigidly move and rotate planar curves, mapping one source XY point to
/// one destination point. Optionally add transformed copies instead of
/// changing the originals. This intentionally does not scale geometry.
#[pyfunction]
fn align_xy_entities(
handles: Vec<u64>,
source: Vec<f64>,
destination: Vec<f64>,
source_direction: Vec<f64>,
target_direction: Vec<f64>,
copy_mode: bool,
vm: &VirtualMachine,
) -> PyResult<usize> {
if source.len() != 3
|| destination.len() != 3
|| source_direction.len() != 2
|| target_direction.len() != 2
|| source
.iter()
.chain(destination.iter())
.chain(source_direction.iter())
.chain(target_direction.iter())
.any(|value| !value.is_finite())
|| handles.len()
!= handles
.iter()
.copied()
.collect::<std::collections::HashSet<_>>()
.len()
{
return Err(vm.new_value_error(
"ocs.align_xy_entities: invalid points, directions, or duplicate handles"
.to_owned(),
));
}
let source_length = source_direction[0].hypot(source_direction[1]);
let target_length = target_direction[0].hypot(target_direction[1]);
if source_length <= 0.0 || target_length <= 0.0 {
return Err(
vm.new_value_error("ocs.align_xy_entities: directions must be nonzero".to_owned())
);
}
if handles.is_empty() {
return Ok(0);
}
let rotation = target_direction[1].atan2(target_direction[0])
- source_direction[1].atan2(source_direction[0]);
let source = Vector3::new(source[0], source[1], source[2]);
let destination = Vector3::new(destination[0], destination[1], destination[2]);
let transform = Transform::from_translation(Vector3::new(-source.x, -source.y, -source.z))
.then(&Transform::from_rotation(Vector3::UNIT_Z, rotation))
.then(&Transform::from_translation(destination));
let mut prepared = host_ctx::with_host(|host| {
let mut entities = Vec::with_capacity(handles.len());
for handle in &handles {
let mut entity = host.document().get_entity(Handle::new(*handle))?.clone();
let planar = match &entity {
EntityType::Line(item) => item.normal == Vector3::UNIT_Z
&& (item.start.z - item.end.z).abs() <= 1e-9,
EntityType::Arc(item) => item.normal == Vector3::UNIT_Z,
EntityType::Circle(item) => item.normal == Vector3::UNIT_Z,
EntityType::LwPolyline(item) => item.normal == Vector3::UNIT_Z,
_ => false,
};
if !planar {
return None;
}
entity.apply_transform(&transform);
entities.push(entity);
}
Some(entities)
})
.ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?
.ok_or_else(|| vm.new_value_error(
"ocs.align_xy_entities: only planar LINE/ARC/CIRCLE/LWPolyline sources are supported".to_owned(),
))?;
if copy_mode {
for entity in &mut prepared {
entity.common_mut().handle = Handle::NULL;
}
}
let updated = host_ctx::with_host(|host| {
host_ctx::ensure_undo_started(host);
if copy_mode {
host.add_entities(prepared)
.iter()
.all(|handle| !handle.is_null())
} else {
prepared
.into_iter()
.all(|entity| host.update_entity(entity))
}
})
.ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?;
if !updated {
return Err(vm.new_runtime_error(
"ocs.align_xy_entities: host could not write every entity; inspect the drawing"
.to_owned(),
));
}
Ok(handles.len())
}
/// Replace an XY line, arc, or circle with a lightweight polyline,
/// retaining its handle, layer, and common drawing properties.
#[pyfunction]
fn replace_with_polyline(
handle: u64,
vertices: Vec<Vec<f64>>,
elevation: ArgIntoFloat,
closed: bool,
vm: &VirtualMachine,
) -> PyResult<()> {
let elevation = elevation.into_float();
if vertices.len() < (if closed { 3 } else { 2 })
|| vertices
.iter()
.any(|point| point.len() != 2 || point.iter().any(|value| !value.is_finite()))
|| !elevation.is_finite()
{
return Err(vm.new_value_error(
"ocs.replace_with_polyline: invalid vertices or elevation".to_owned(),
));
}
let changed = host_ctx::with_host(|host| {
let Some(entity) = host.document().get_entity(Handle::new(handle)).cloned() else {
return false;
};
let supported = match &entity {
EntityType::Line(line) => {
line.normal == Vector3::UNIT_Z && (line.start.z - line.end.z).abs() <= 1e-9
}
EntityType::Arc(arc) => arc.normal == Vector3::UNIT_Z,
EntityType::Circle(circle) => circle.normal == Vector3::UNIT_Z,
_ => false,
};
if !supported {
return false;
}
let mut polyline = LwPolyline::new();
polyline.common = entity.common().clone();
polyline.elevation = elevation;
polyline.is_closed = closed;
for point in vertices {
polyline
.vertices
.push(LwVertex::new(Vector2::new(point[0], point[1])));
}
host_ctx::ensure_undo_started(host);
host.update_entity(EntityType::LwPolyline(polyline))
})
.ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?;
if !changed {
return Err(vm.new_runtime_error(format!(
"ocs.replace_with_polyline: entity {handle} was not updated"
)));
}
Ok(())
}
/// Create a native OCS table from a rectangular grid of cell strings.
#[pyfunction]
fn add_table(
rows: Vec<Vec<String>>,
x: ArgIntoFloat,
y: ArgIntoFloat,
z: ArgIntoFloat,
row_height: ArgIntoFloat,
column_width: ArgIntoFloat,
vm: &VirtualMachine,
) -> PyResult<u64> {
let (x, y, z, row_height, column_width) = (
x.into_float(),
y.into_float(),
z.into_float(),
row_height.into_float(),
column_width.into_float(),
);
let columns = rows.first().map_or(0, Vec::len);
if rows.is_empty()
|| columns == 0
|| rows.iter().any(|row| row.len() != columns)
|| ![x, y, z, row_height, column_width]
.iter()
.all(|v| v.is_finite())
|| row_height <= 0.0
|| column_width <= 0.0
{
return Err(vm.new_value_error("ocs.add_table: rows must be rectangular and nonempty; dimensions must be positive and finite".to_owned()));
}
let mut builder = TableBuilder::new(rows.len(), columns)
.at(Vector3::new(x, y, z))
.row_height(row_height)
.column_width(column_width);
for (row_index, row) in rows.iter().enumerate() {
for (column_index, value) in row.iter().enumerate() {
builder = builder.cell_text(row_index, column_index, value);
}
}
host_ctx::with_host(|host| {
host_ctx::ensure_undo_started(host);
host.add_entity(EntityType::Table(builder.build())).value()
})
.ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))
}
/// Add a 2D line from `(x1, y1)` to `(x2, y2)` on layer `"0"`, returning
/// its handle. Starts the script's shared undo group (see
/// `host_ctx::ensure_undo_started`) if this is the first write this call.
///
/// Coordinates take `ArgIntoFloat` rather than a bare `f64` so plain
/// Python ints work too (`ocs.add_line(0, 0, 10, 10)`) — a bare `f64`
/// parameter in RustPython, unlike CPython's C-function argument parsing,
/// does *not* implicitly coerce an `int`, and rejects it with a `TypeError`.
#[pyfunction]
fn add_line(
x1: ArgIntoFloat,
y1: ArgIntoFloat,
x2: ArgIntoFloat,
y2: ArgIntoFloat,
vm: &VirtualMachine,
) -> PyResult<u64> {
let line = acadrust::Line::from_coords(
x1.into_float(),
y1.into_float(),
0.0,
x2.into_float(),
y2.into_float(),
0.0,
);
host_ctx::with_host(|host| {
host_ctx::ensure_undo_started(host);
host.add_entity(EntityType::Line(line)).value()
})
.ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))
}
/// Add a 2D circle centered at `(x, y)` with the given `radius`, on layer
/// `"0"`, returning its handle. Same undo-grouping and int/float argument
/// handling as `add_line`.
#[pyfunction]
fn add_circle(
x: ArgIntoFloat,
y: ArgIntoFloat,
radius: ArgIntoFloat,
vm: &VirtualMachine,
) -> PyResult<u64> {
let circle =
acadrust::Circle::from_coords(x.into_float(), y.into_float(), 0.0, radius.into_float());
host_ctx::with_host(|host| {
host_ctx::ensure_undo_started(host);
host.add_entity(EntityType::Circle(circle)).value()
})
.ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))
}
/// Add a 2D arc centered at `(x, y)`, from `start_deg` to `end_deg`
/// (degrees, counterclockwise from the +X axis — matching how OCS's own
/// `ARC`/angle fields are conventionally read), on layer `"0"`. Returns
/// its handle. Same undo-grouping and int/float argument handling as
/// `add_line`.
#[pyfunction]
fn add_arc(
x: ArgIntoFloat,
y: ArgIntoFloat,
radius: ArgIntoFloat,
start_deg: ArgIntoFloat,
end_deg: ArgIntoFloat,
vm: &VirtualMachine,
) -> PyResult<u64> {
let center = acadrust::Vector3::new(x.into_float(), y.into_float(), 0.0);
let arc = acadrust::Arc::from_center_radius_angles(
center,
radius.into_float(),
start_deg.into_float().to_radians(),
end_deg.into_float().to_radians(),
);
host_ctx::with_host(|host| {
host_ctx::ensure_undo_started(host);
host.add_entity(EntityType::Arc(arc)).value()
})
.ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))
}
/// Add an open 2D lightweight polyline. Each vertex is
/// [x, y, start_width, end_width]; widths belong to the segment starting
/// at that vertex. The polyline uses a single explicit elevation.
#[pyfunction]
fn add_polyline(
vertices: Vec<Vec<f64>>,
elevation: ArgIntoFloat,
vm: &VirtualMachine,
) -> PyResult<u64> {
let elevation = elevation.into_float();
if vertices.len() < 2 || !elevation.is_finite() {
return Err(vm.new_value_error(
"ocs.add_polyline: need at least two vertices and a finite elevation".to_owned(),
));
}
let mut polyline = LwPolyline::new();
polyline.elevation = elevation;
for vertex in vertices {
if vertex.len() != 4
|| vertex.iter().any(|value| !value.is_finite())
|| vertex[2] < 0.0
|| vertex[3] < 0.0
{
return Err(vm.new_value_error("ocs.add_polyline: each vertex must be [finite x, y, nonnegative start_width, end_width]".to_owned()));
}
let mut item = LwVertex::new(Vector2::new(vertex[0], vertex[1]));
item.start_width = vertex[2];
item.end_width = vertex[3];
polyline.vertices.push(item);
}
host_ctx::with_host(|host| {
host_ctx::ensure_undo_started(host);
host.add_entity(EntityType::LwPolyline(polyline)).value()
})
.ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))
}
/// Add a closed XY lightweight polyline with vertices `[x, y]`.
#[pyfunction]
fn add_closed_polyline(
vertices: Vec<Vec<f64>>,
elevation: ArgIntoFloat,
vm: &VirtualMachine,
) -> PyResult<u64> {
let elevation = elevation.into_float();
if vertices.len() < 3
|| !elevation.is_finite()
|| vertices
.iter()
.any(|point| point.len() != 2 || point.iter().any(|v| !v.is_finite()))
{
return Err(vm.new_value_error("ocs.add_closed_polyline: need at least three finite [x, y] vertices and finite elevation".to_owned()));
}
let mut polyline = LwPolyline::new();
polyline.elevation = elevation;
polyline.is_closed = true;
for point in vertices {
polyline
.vertices
.push(LwVertex::new(Vector2::new(point[0], point[1])));
}
host_ctx::with_host(|host| {
host_ctx::ensure_undo_started(host);
host.add_entity(EntityType::LwPolyline(polyline)).value()
})
.ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))
}
/// Replace a closed lightweight polyline's shape, preserving handle/properties/XDATA.
#[pyfunction]
fn update_closed_polyline(
handle: u64,
vertices: Vec<Vec<f64>>,
elevation: ArgIntoFloat,
vm: &VirtualMachine,
) -> PyResult<()> {
let elevation = elevation.into_float();
if vertices.len() < 3
|| !elevation.is_finite()
|| vertices
.iter()
.any(|point| point.len() != 2 || point.iter().any(|v| !v.is_finite()))
{
return Err(vm.new_value_error("ocs.update_closed_polyline: need at least three finite [x, y] vertices and finite elevation".to_owned()));
}
let changed = host_ctx::with_host(|host| {
let Some(EntityType::LwPolyline(mut polyline)) =
host.document().get_entity(Handle::new(handle)).cloned()
else {
return false;
};
if !polyline.is_closed {
return false;
}
polyline.elevation = elevation;
polyline.vertices = vertices
.into_iter()
.map(|point| LwVertex::new(Vector2::new(point[0], point[1])))
.collect();
host_ctx::ensure_undo_started(host);
host.update_entity(EntityType::LwPolyline(polyline))
})
.ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?;
if !changed {
return Err(vm.new_runtime_error(format!("ocs.update_closed_polyline: entity {handle} is not a writable closed lightweight polyline")));
}
Ok(())
}
/// Add many 3D points in one host round trip, each `[x, y, z]`. Returns
/// the new handles in the same order as `points`.
///
/// Ported from `schoeller/ocs_python_repl`'s `ocs.doc.add_many(...)`,
/// which batches thousands of points into a single request instead of
/// one per point. That plugin is out-of-process (real round-trip cost
/// per call); this one is in-process, so the win here is different but
/// still real: `HostApi::add_entities` lets the host coalesce the
/// undo/dirty/geometry-rebuild bookkeeping across the whole batch
/// instead of repeating it once per point (its default implementation
/// merely loops `add_entity`, but hosts are expected to override it for
/// batch efficiency — see its doc comment in `ocs_plugin_api`).
#[pyfunction]
fn add_points(points: Vec<Vec<f64>>, vm: &VirtualMachine) -> PyResult<PyObjectRef> {
if points.is_empty() {
return Err(vm.new_value_error("ocs.add_points: need at least one point".to_owned()));
}
let mut entities = Vec::with_capacity(points.len());
for point in &points {
if point.len() != 3 || point.iter().any(|value| !value.is_finite()) {
return Err(vm.new_value_error(
"ocs.add_points: each point must be [finite x, y, z]".to_owned(),
));
}
entities.push(EntityType::Point(acadrust::Point::from_coords(
point[0], point[1], point[2],
)));
}
let handles = host_ctx::with_host(|host| {
host_ctx::ensure_undo_started(host);
host.add_entities(entities)
})
.ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?;
let items = handles
.into_iter()
.map(|h| vm.new_pyobj(h.value()))
.collect();
Ok(vm.ctx.new_list(items).into())
}
/// Add one left/baseline-aligned single-line TEXT entity.
/// Its insertion point is the supplied point, which also makes it
/// eligible for `text_box_vertices` and the associated-textbox port.
#[pyfunction]
fn add_text_left(
value: rustpython_vm::builtins::PyStrRef,
x: ArgIntoFloat,
y: ArgIntoFloat,
z: ArgIntoFloat,
height: ArgIntoFloat,
rotation_radians: ArgIntoFloat,
vm: &VirtualMachine,
) -> PyResult<u64> {
let (x, y, z, height, rotation_radians) = (
x.into_float(),
y.into_float(),
z.into_float(),
height.into_float(),
rotation_radians.into_float(),
);
if ![x, y, z, height, rotation_radians]
.iter()
.all(|v| v.is_finite())
|| height <= 0.0
{
return Err(vm.new_value_error("ocs.add_text_left: coordinates and rotation must be finite; height must be positive".to_owned()));
}
let mut text = Text::with_value(value.to_string(), Vector3::new(x, y, z))
.with_height(height)
.with_rotation(rotation_radians);
text.horizontal_alignment = TextHorizontalAlignment::Left;
text.vertical_alignment = TextVerticalAlignment::Baseline;
text.alignment_point = None;
host_ctx::with_host(|host| {
host_ctx::ensure_undo_started(host);
host.add_entity(EntityType::Text(text)).value()
})
.ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))
}
/// Add one centered, middle-aligned single-line TEXT entity.
#[pyfunction]
fn add_text_centered(
value: rustpython_vm::builtins::PyStrRef,
x: ArgIntoFloat,
y: ArgIntoFloat,
z: ArgIntoFloat,
height: ArgIntoFloat,
rotation_radians: ArgIntoFloat,
vm: &VirtualMachine,
) -> PyResult<u64> {
let (x, y, z, height, rotation_radians) = (
x.into_float(),
y.into_float(),
z.into_float(),
height.into_float(),
rotation_radians.into_float(),
);
if ![x, y, z, height, rotation_radians]
.iter()
.all(|v| v.is_finite())
|| height <= 0.0
{
return Err(vm.new_value_error("ocs.add_text_centered: coordinates and rotation must be finite; height must be positive".to_owned()));
}
let point = Vector3::new(x, y, z);
let mut text = Text::with_value(value.to_string(), point)
.with_height(height)
.with_rotation(rotation_radians);
text.alignment_point = Some(point);
text.horizontal_alignment = TextHorizontalAlignment::Center;
text.vertical_alignment = TextVerticalAlignment::Middle;
host_ctx::with_host(|host| {
host_ctx::ensure_undo_started(host);
host.add_entity(EntityType::Text(text)).value()
})
.ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))
}
/// Run a full command line (built-in or plugin) to completion, exactly
/// as if typed — including any inline point/keyword/handle tokens an
/// interactive command needs (e.g. `ocs.command("LINE 0,0 10,10")`, or
/// `ocs.command(f"PCONSTRAINT {h1:X} {h2:X}")` with two entity handles
/// for a constraint command's object picks). Raises a Python exception
/// on failure — including on hosts that predate `HostApi::run_command`
/// (Phase 1, `ocs.command()` — see `docs/python-scripting-roadmap.md`
/// in the OpenCADStudio repo for what this can and can't do).
#[cfg(feature = "experimental-command-replay")]
#[pyfunction]
fn command(cmd: rustpython_vm::builtins::PyStrRef, vm: &VirtualMachine) -> PyResult<()> {
let cmd = cmd.to_string();
let result = host_ctx::with_host(|host| host.run_command(&cmd)).ok_or_else(|| {
vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned())
})?;
result.map_err(|e| vm.new_runtime_error(format!("ocs.command({cmd:?}): {e}")))
}
/// Set the document's selection to exactly these entity handles
/// (clearing any existing selection first). The missing piece for
/// driving a constraint command via `ocs.command()`: those read a prior
/// selection rather than picks fed as command-line tokens. Errors,
/// without partially applying, if any handle doesn't exist.
#[cfg(any(
feature = "experimental-command-replay",
feature = "experimental-host-model"
))]
#[pyfunction]
fn select(handles: Vec<u64>, vm: &VirtualMachine) -> PyResult<()> {
let handles: Vec<Handle> = handles.into_iter().map(Handle::new).collect();
let result = host_ctx::with_host(|host| host.set_selection(&handles)).ok_or_else(|| {
vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned())
})?;
result.map_err(|e| vm.new_runtime_error(format!("ocs.select(...): {e}")))
}
fn kind_name(kind: ReaderEntityKind) -> &'static str {
match kind {
ReaderEntityKind::Point => "point",
ReaderEntityKind::Line => "line",
ReaderEntityKind::Circle => "circle",
ReaderEntityKind::Arc => "arc",
ReaderEntityKind::Polyline => "polyline",
ReaderEntityKind::Text => "text",
ReaderEntityKind::Other => "other",
}
}
// ════════════════════════════════════════════════════════════════════
// Generic entity CRUD (Phase 0 — see this session's
// `project_opencad_python_generic_entity_crud_plan.md` memory).
//
// `dict_to_entity`/`entity_to_dict` below are generated at build time
// (`build.rs` + `build/generate.rs`, driven by `entity_manifest.json`
// plus the type registry `ocs_plugin_api` embeds) from the registry's
// 12 currently-traced entity kinds, wired into `ocs.add` / `ocs.update`
// and the feature-gated document model. Round trips are exercised by
// `entity_crud_tests` below.
//
// A handful of leaf conversions the generated code calls into are
// hand-written here rather than generated, matching how `vector3_to_py`
// /`py_to_vector3` above are already hand-written for XDATA: `Vector3`/
// `Vector2`/`Handle` have no registry-driven struct shape worth
// generating (they're represented as `{"x":..,"y":..,"z":..}` /
// `{"x":..,"y":..}` dicts, matching `get()`'s existing `point` dict —
// not the `[x, y, z]` list XDATA uses), and `get_opt_*` are tiny,
// reused by every generated setter.
fn vector2_to_py(vm: &VirtualMachine, v: &Vector2) -> PyResult<PyObjectRef> {
let dict = vm.ctx.new_dict();
dict.set_item("x", vm.new_pyobj(v.x), vm)?;
dict.set_item("y", vm.new_pyobj(v.y), vm)?;
Ok(dict.into())
}
fn py_to_vector2(value: PyObjectRef, vm: &VirtualMachine) -> PyResult<Vector2> {
let dict = value.try_into_value::<rustpython_vm::builtins::PyDictRef>(vm)?;
let x = get_opt_f64(&dict, "x", vm)?;
let y = get_opt_f64(&dict, "y", vm)?;
Ok(Vector2::new(x, y))
}
fn vector3_to_py_dict(vm: &VirtualMachine, v: &Vector3) -> PyResult<PyObjectRef> {
let dict = vm.ctx.new_dict();
dict.set_item("x", vm.new_pyobj(v.x), vm)?;
dict.set_item("y", vm.new_pyobj(v.y), vm)?;
dict.set_item("z", vm.new_pyobj(v.z), vm)?;
Ok(dict.into())
}
fn py_to_vector3_dict(value: PyObjectRef, vm: &VirtualMachine) -> PyResult<Vector3> {
let dict = value.try_into_value::<rustpython_vm::builtins::PyDictRef>(vm)?;
let x = get_opt_f64(&dict, "x", vm)?;
let y = get_opt_f64(&dict, "y", vm)?;
let z = get_opt_f64(&dict, "z", vm)?;
Ok(Vector3::new(x, y, z))
}
fn get_opt_f64(
dict: &rustpython_vm::builtins::PyDictRef,
key: &str,
vm: &VirtualMachine,
) -> PyResult<f64> {
match dict.get_item_opt(key, vm)? {
None => Ok(0.0),
Some(v) if vm.is_none(&v) => Ok(0.0),
Some(v) => py_number_to_f64(v, vm),
}
}
/// `Color` as `{"kind": "ByLayer" | "None" | "ByBlock"}`,
/// `{"kind": "Index", "value": n}` or
/// `{"kind": "Rgb", "value": {"r": .., "g": .., "b": ..}}`.
fn color_to_py_dict(vm: &VirtualMachine, color: &acadrust::types::Color) -> PyResult<PyObjectRef> {
use acadrust::types::Color;
let dict = vm.ctx.new_dict();
match color {
Color::ByLayer => dict.set_item("kind", vm.new_pyobj("ByLayer"), vm)?,
Color::None => dict.set_item("kind", vm.new_pyobj("None"), vm)?,
Color::ByBlock => dict.set_item("kind", vm.new_pyobj("ByBlock"), vm)?,
Color::Index(index) => {
dict.set_item("kind", vm.new_pyobj("Index"), vm)?;
dict.set_item("value", vm.new_pyobj(i64::from(*index)), vm)?;
}
Color::Rgb { r, g, b } => {
dict.set_item("kind", vm.new_pyobj("Rgb"), vm)?;
let rgb = vm.ctx.new_dict();
rgb.set_item("r", vm.new_pyobj(i64::from(*r)), vm)?;
rgb.set_item("g", vm.new_pyobj(i64::from(*g)), vm)?;
rgb.set_item("b", vm.new_pyobj(i64::from(*b)), vm)?;
dict.set_item("value", PyObjectRef::from(rgb), vm)?;
}
}
Ok(dict.into())
}
fn py_to_color_dict(value: PyObjectRef, vm: &VirtualMachine) -> PyResult<acadrust::types::Color> {
use acadrust::types::Color;
let dict = value.try_into_value::<rustpython_vm::builtins::PyDictRef>(vm)?;
let kind = dict.get_item("kind", vm)?.try_into_value::<String>(vm)?;
let byte = |v: PyObjectRef, what: &str| -> PyResult<u8> {
let n = v.try_into_value::<i64>(vm)?;
u8::try_from(n).map_err(|_| vm.new_value_error(format!("Color {what} must be 0..=255")))
};
Ok(match kind.as_str() {
"ByLayer" => Color::ByLayer,
"None" => Color::None,
"ByBlock" => Color::ByBlock,
"Index" => {
let index = byte(dict.get_item("value", vm)?, "index")?;
if index == 0 {
return Err(vm.new_value_error("Color index must be 1..=255; use ByBlock for 0".to_owned()));
}
Color::Index(index)
}
"Rgb" => {
let rgb = dict.get_item("value", vm)?.try_into_value::<rustpython_vm::builtins::PyDictRef>(vm)?;
Color::Rgb {
r: byte(rgb.get_item("r", vm)?, "r")?,
g: byte(rgb.get_item("g", vm)?, "g")?,
b: byte(rgb.get_item("b", vm)?, "b")?,
}
}
other => return Err(vm.new_value_error(format!("ocs: unsupported Color kind: {other}"))),
})
}
fn py_to_f64_array<const N: usize>(value: PyObjectRef, vm: &VirtualMachine) -> PyResult<[f64; N]> {
let items: Vec<PyObjectRef> = value.try_into_value(vm)?;
if items.len() != N {
return Err(vm.new_value_error(format!("expected {N} numbers, got {}", items.len())));
}
let mut out = [0.0; N];
for (slot, item) in out.iter_mut().zip(items) {
*slot = py_number_to_f64(item, vm)?;
}
Ok(out)
}
/// Accept a Python int or float; `try_into_value::<f64>` rejects ints.
fn py_number_to_f64(value: PyObjectRef, vm: &VirtualMachine) -> PyResult<f64> {
Ok(value.try_float(vm)?.to_f64())
}
fn get_opt_bool(
dict: &rustpython_vm::builtins::PyDictRef,
key: &str,
vm: &VirtualMachine,
) -> PyResult<bool> {
match dict.get_item_opt(key, vm)? {
None => Ok(false),
Some(v) if vm.is_none(&v) => Ok(false),
Some(v) => v.try_into_value::<bool>(vm),
}
}
fn get_opt_u64(
dict: &rustpython_vm::builtins::PyDictRef,
key: &str,
vm: &VirtualMachine,
) -> PyResult<u64> {
match dict.get_item_opt(key, vm)? {
None => Ok(0),
Some(v) if vm.is_none(&v) => Ok(0),
Some(v) => v.try_into_value::<u64>(vm),
}
}
fn get_opt_string(
dict: &rustpython_vm::builtins::PyDictRef,
key: &str,
default: &str,
vm: &VirtualMachine,
) -> PyResult<String> {
match dict.get_item_opt(key, vm)? {
None => Ok(default.to_owned()),
Some(v) if vm.is_none(&v) => Ok(default.to_owned()),
Some(v) => v.try_into_value::<String>(vm),
}
}
fn ensure_known_entity_keys(
dict: &rustpython_vm::builtins::PyDictRef,
kind: &str,
allowed: &[&str],
vm: &VirtualMachine,
) -> PyResult<()> {
for key in dict.keys_vec() {
let key = key.try_into_value::<String>(vm)?;
if !allowed.contains(&key.as_str()) {
return Err(vm.new_value_error(format!(
"ocs: {kind} property {key:?} is outside the editable schema"
)));
}
}
Ok(())
}
include!(concat!(env!("OUT_DIR"), "/entity_crud.rs"));
include!("dimension_model.rs.inc");
include!("patch_helpers.rs.inc");
// ════════════════════════════════════════════════════════════════════
// Phase 1 — wiring the generic conversion functions above into `ocs`.
//
// `get_entity` is deliberately a *new*, separate function rather than a
// change to `get()` above: existing scripts already depend on `get()`'s
// small `{handle, kind, layer, point}` shape (Felix's Phase 1 review —
// don't break that compatibility to hand out the fuller schema).
/// Full entity schema for `handle` (every field the type registry knows
/// about, not just `get()`'s small `{handle, kind, layer, point}`), or
/// `None` if the entity doesn't exist or isn't one of the kinds this
/// generic API covers yet.
#[pyfunction]
fn get_entity(handle: u64, vm: &VirtualMachine) -> PyResult<PyObjectRef> {
let found =
host_ctx::with_host(|host| host.document().get_entity(Handle::new(handle)).cloned())
.ok_or_else(|| {
vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned())
})?;
match found {
Some(entity) => entity_to_dict(vm, &entity),
None => Ok(vm.ctx.none()),
}
}
/// API v7 document-model view: every entity has identity, kind and layer;
/// only manifest-covered kinds also expose editable geometry properties.
#[cfg(feature = "experimental-host-model")]
#[pyfunction]
fn entity_descriptor(handle: u64, vm: &VirtualMachine) -> PyResult<PyObjectRef> {
let found =
host_ctx::with_host(|host| host.document().get_entity(Handle::new(handle)).cloned())
.ok_or_else(|| {
vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned())
})?;
let Some(entity) = found else {
return Ok(vm.ctx.none());
};
if let Ok(full) = entity_to_dict(vm, &entity) {
let dict = full
.clone()
.try_into_value::<rustpython_vm::builtins::PyDictRef>(vm)?;
dict.set_item("_editable", vm.new_pyobj(true), vm)?;
return Ok(full);
}
let dict = vm.ctx.new_dict();
dict.set_item("handle", vm.new_pyobj(handle), vm)?;
// `entity_type()` is the DXF name (for example `HATCH`), while the
// coverage catalog uses the Rust `EntityType` variant (`Hatch`).
let kind = ocs_plugin_api::entity_coverage::entity_snapshot(&entity)
.ok()
.and_then(|value| {
value
.as_object()
.and_then(|object| object.keys().next().cloned())
})
.ok_or_else(|| {
vm.new_runtime_error("ocs: cannot identify entity variant".to_owned())
})?;
dict.set_item("kind", vm.new_pyobj(kind), vm)?;
dict.set_item("layer", vm.new_pyobj(entity.common().layer.clone()), vm)?;
dict.set_item(
"owner_handle",
vm.new_pyobj(entity.common().owner_handle.value()),
vm,
)?;
dict.set_item("_editable", vm.new_pyobj(false), vm)?;
Ok(dict.into())
}
#[cfg(feature = "experimental-host-model")]
fn snapshot_value_to_py(
value: serde_json::Value,
vm: &VirtualMachine,
) -> PyResult<PyObjectRef> {
Ok(match value {
serde_json::Value::Null => vm.ctx.none(),
serde_json::Value::Bool(value) => vm.new_pyobj(value),
serde_json::Value::Number(value) => {
if let Some(value) = value.as_i64() {
vm.new_pyobj(value)
} else if let Some(value) = value.as_u64() {
vm.new_pyobj(value)
} else {
vm.new_pyobj(value.as_f64().expect("JSON number"))
}
}
serde_json::Value::String(value) => vm.new_pyobj(value),
serde_json::Value::Array(values) => vm
.ctx
.new_list(
values
.into_iter()
.map(|value| snapshot_value_to_py(value, vm))
.collect::<PyResult<Vec<_>>>()?,
)
.into(),
serde_json::Value::Object(values) => {
let dict = vm.ctx.new_dict();
for (key, value) in values {
dict.set_item(&key, snapshot_value_to_py(value, vm)?, vm)?;
}
dict.into()
}
})
}
/// Copy every serializable field of an entity, including kinds outside
/// the editable Python schema. The result is detached and read-only with
/// respect to the drawing; mutations to the returned dict do not commit.
#[cfg(feature = "experimental-host-model")]
#[pyfunction]
fn entity_snapshot(handle: u64, vm: &VirtualMachine) -> PyResult<PyObjectRef> {
let found =
host_ctx::with_host(|host| host.document().get_entity(Handle::new(handle)).cloned())
.ok_or_else(|| {
vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned())
})?;
let Some(entity) = found else {
return Ok(vm.ctx.none());
};
let value = ocs_plugin_api::entity_coverage::entity_snapshot(&entity).map_err(|error| {
vm.new_runtime_error(format!("ocs: entity snapshot failed: {error}"))
})?;
snapshot_value_to_py(value, vm)
}
/// Host-owned coverage for a variant. `unmapped` properties exist in the
/// typed snapshot but have no Python document-model getter or setter.
#[cfg(feature = "experimental-host-model")]
#[pyfunction]
fn entity_coverage(kind: String, vm: &VirtualMachine) -> PyResult<PyObjectRef> {
use ocs_plugin_api::entity_coverage::{EntityCoverageCatalog, EntityScope, ModelAccess};
let catalog: EntityCoverageCatalog =
serde_json::from_str(ocs_plugin_api::get_embedded_entity_coverage_json())
.map_err(|error| vm.new_runtime_error(error.to_string()))?;
let Some(entry) = catalog
.entity_kinds
.into_iter()
.find(|entry| entry.kind == kind)
else {
return Ok(vm.ctx.none());
};
let row = vm.ctx.new_dict();
row.set_item("kind", vm.new_pyobj(entry.kind), vm)?;
let scope = match entry.scope {
EntityScope::Canvas => "canvas",
EntityScope::Internal => "internal",
EntityScope::Opaque => "opaque",
};
row.set_item("scope", vm.new_pyobj(scope), vm)?;
let mut readable = Vec::new();
let mut editable = Vec::new();
let mut unmapped = Vec::new();
let mut properties = Vec::new();
for property in entry.properties {
let access = match property.model_access {
ModelAccess::ReadWrite => {
readable.push(property.name.clone());
editable.push(property.name.clone());
"read_write"
}
ModelAccess::ReadOnly => {
readable.push(property.name.clone());
"read_only"
}
ModelAccess::Unmapped => {
unmapped.push(property.name.clone());
"unmapped"
}
};
let item = vm.ctx.new_dict();
item.set_item("name", vm.new_pyobj(property.name), vm)?;
item.set_item("source_path", vm.new_pyobj(property.source_path), vm)?;
item.set_item("type", vm.new_pyobj(property.type_id), vm)?;
item.set_item("optional", vm.new_pyobj(property.optional), vm)?;
item.set_item("sequence", vm.new_pyobj(property.is_sequence), vm)?;
item.set_item(
"snapshot_readable",
vm.new_pyobj(property.snapshot_readable),
vm,
)?;
item.set_item("access", vm.new_pyobj(access), vm)?;
item.set_item("validation", vm.new_pyobj(property.validation), vm)?;
properties.push(item.into());
}
let list = |values: Vec<String>| {
vm.ctx.new_list(
values
.into_iter()
.map(|value| vm.new_pyobj(value))
.collect(),
)
};
row.set_item("readable", list(readable).into(), vm)?;
row.set_item("editable", list(editable).into(), vm)?;
row.set_item("unmapped", list(unmapped).into(), vm)?;
row.set_item("properties", vm.ctx.new_list(properties).into(), vm)?;
Ok(row.into())
}
/// Enumerate every kind in the host-owned coverage catalog.
#[cfg(feature = "experimental-host-model")]
#[pyfunction]
fn entity_kinds(vm: &VirtualMachine) -> PyResult<PyObjectRef> {
use ocs_plugin_api::entity_coverage::EntityCoverageCatalog;
let catalog: EntityCoverageCatalog =
serde_json::from_str(ocs_plugin_api::get_embedded_entity_coverage_json())
.map_err(|error| vm.new_runtime_error(error.to_string()))?;
Ok(vm
.ctx
.new_list(
catalog
.entity_kinds
.into_iter()
.map(|entry| vm.new_pyobj(entry.kind))
.collect(),
)
.into())
}
#[cfg(feature = "experimental-host-model")]
#[pyfunction]
fn entity_handles(vm: &VirtualMachine) -> PyResult<PyObjectRef> {
let handles = host_ctx::with_host(|host| {
host.document()
.entities()
.map(|entity| entity.common().handle.value())
.collect::<Vec<_>>()
})
.ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?;
Ok(vm
.ctx
.new_list(
handles
.into_iter()
.map(|handle| vm.new_pyobj(handle))
.collect(),
)
.into())
}
/// Add a new entity from a dict with a `"kind"` key (see `entity_to_dict`
/// for the shape `get_entity`/this function share) — a generic
/// alternative to `add_line`/`add_circle`/etc. covering every entity kind
/// the type registry currently traces. Returns the new handle. Errors if
/// `entity` is missing the geometry that actually defines the shape (a
/// line's `start`/`end`, a circle's `center`/`radius`, ...) rather than
/// silently defaulting it to zero.
#[pyfunction]
fn add(entity: PyObjectRef, vm: &VirtualMachine) -> PyResult<u64> {
let dict = entity.try_into_value::<rustpython_vm::builtins::PyDictRef>(vm)?;
let built = dict_to_entity(&dict, vm)?;
#[cfg(feature = "experimental-host-model")]
ocs_plugin_api::entity_coverage::validate_new_canvas_entity(&built)
.map_err(|error| vm.new_value_error(error))?;
host_ctx::with_host(|host| -> PyResult<u64> {
#[allow(unused_mut)]
let mut built = built;
#[cfg(feature = "experimental-host-model")]
{
ocs_plugin_api::entity_coverage::bind_canvas_entity_references(
host.document(),
&mut built,
)
.map_err(|error| vm.new_value_error(error))?;
}
host_ctx::ensure_undo_started(host);
Ok(host.add_entity(built).value())
})
.ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?
}
/// Update `handle` by merging `entity`'s keys onto its *current* value —
/// a key `entity` doesn't mention is left exactly as it was, not reset to
/// a type default (Felix's Phase 1 review: reconstructing from a partial
/// dict could silently drop properties this generic API can't represent
/// at all, e.g. color/line-weight/XDATA). Errors if `handle` doesn't
/// exist or isn't a supported kind.
#[pyfunction]
fn update(handle: u64, entity: PyObjectRef, vm: &VirtualMachine) -> PyResult<()> {
let dict = entity.try_into_value::<rustpython_vm::builtins::PyDictRef>(vm)?;
let changed = host_ctx::with_host(|host| -> PyResult<bool> {
let Some(existing) = host.document().get_entity(Handle::new(handle)).cloned() else {
return Ok(false);
};
#[allow(unused_mut)]
let mut updated = apply_dict_to_entity(&existing, &dict, vm)?;
#[cfg(feature = "experimental-host-model")]
{
ocs_plugin_api::entity_coverage::bind_canvas_entity_references(
host.document(),
&mut updated,
)
.map_err(|error| vm.new_value_error(error))?;
ocs_plugin_api::entity_coverage::validate_entity_mutation(&existing, &updated)
.map_err(|error| vm.new_value_error(error))?;
}
host_ctx::ensure_undo_started(host);
Ok(host.update_entity(updated))
})
.ok_or_else(|| {
vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned())
})??;
if !changed {
return Err(vm.new_runtime_error(format!("ocs.update: entity {handle} does not exist")));
}
Ok(())
}
/// API v7: merge a list of property dictionaries and commit one undo step.
/// The host validates the entire batch before changing the drawing.
#[cfg(feature = "experimental-host-model")]
fn apply_layer_only_patch(
existing: &EntityType,
dict: &rustpython_vm::builtins::PyDictRef,
vm: &VirtualMachine,
) -> PyResult<EntityType> {
if dict.keys_vec().len() != 2 || dict.get_item_opt("layer", vm)?.is_none() {
return Err(vm.new_value_error(
"ocs.update_many: this kind supports only a layer patch".to_owned(),
));
}
let layer = dict.get_item("layer", vm)?.try_into_value::<String>(vm)?;
ocs_plugin_api::entity_coverage::patch_canvas_layer(existing, &layer)
.map_err(|error| vm.new_value_error(format!("ocs.update_many: {error}")))
}
#[cfg(feature = "experimental-host-model")]
#[pyfunction]
fn update_many(label: String, updates: Vec<PyObjectRef>, vm: &VirtualMachine) -> PyResult<()> {
let result = host_ctx::with_host(|host| -> PyResult<Result<(), String>> {
let mut entities = Vec::with_capacity(updates.len());
for patch in updates {
let dict = patch.try_into_value::<rustpython_vm::builtins::PyDictRef>(vm)?;
let handle = dict.get_item("handle", vm)?.try_into_value::<u64>(vm)?;
let existing = host
.document()
.get_entity(Handle::new(handle))
.cloned()
.ok_or_else(|| {
vm.new_runtime_error(format!("entity {handle} does not exist"))
})?;
let changed = if entity_to_dict(vm, &existing).is_ok() {
apply_dict_to_entity(&existing, &dict, vm)?
} else {
apply_layer_only_patch(&existing, &dict, vm)?
};
entities.push(changed);
}
Ok(host.update_entities_transaction(&label, entities))
})
.ok_or_else(|| {
vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned())
})??;
result.map_err(|error| vm.new_runtime_error(format!("ocs.update_many: {error}")))
}
#[cfg(test)]
mod entity_crud_tests {
use super::*;
#[cfg(feature = "experimental-host-model")]
use rustpython_vm::AsObject;
#[cfg(feature = "experimental-host-model")]
#[test]
fn snapshot_conversion_preserves_nested_values() {
with_vm(|vm| {
let source = serde_json::json!({"Hatch": {"common": {"layer": "0"},
"angles": [1.5, 2.0], "optional": null, "enabled": true}});
let converted = snapshot_value_to_py(source, vm).unwrap();
let dict = converted
.try_into_value::<rustpython_vm::builtins::PyDictRef>(vm)
.unwrap();
let hatch = dict
.get_item("Hatch", vm)
.unwrap()
.try_into_value::<rustpython_vm::builtins::PyDictRef>(vm)
.unwrap();
let common = hatch
.get_item("common", vm)
.unwrap()
.try_into_value::<rustpython_vm::builtins::PyDictRef>(vm)
.unwrap();
assert_eq!(
common
.get_item("layer", vm)
.unwrap()
.try_into_value::<String>(vm)
.unwrap(),
"0"
);
assert!(hatch.get_item("optional", vm).unwrap().is(&vm.ctx.none()));
assert!(hatch
.get_item("enabled", vm)
.unwrap()
.try_into_value::<bool>(vm)
.unwrap());
});
}
#[cfg(feature = "experimental-host-model")]
#[test]
fn unmapped_canvas_kind_accepts_only_layer_patch() {
with_vm(|vm| {
let entity = EntityType::MLine(acadrust::entities::MLine::default());
let patch = vm.ctx.new_dict();
patch.set_item("handle", vm.new_pyobj(1_u64), vm).unwrap();
patch
.set_item("layer", vm.new_pyobj("HATCHES"), vm)
.unwrap();
let changed = apply_layer_only_patch(&entity, &patch, vm).unwrap();
assert_eq!(changed.common().layer, "HATCHES");
patch.set_item("scale", vm.new_pyobj(2.0), vm).unwrap();
assert!(apply_layer_only_patch(&entity, &patch, vm).is_err());
});
}
fn with_vm<R>(f: impl FnOnce(&VirtualMachine) -> R) -> R {
let interp = rustpython_vm::Interpreter::without_stdlib(Default::default());
interp.enter(f)
}
fn set_vector3(
vm: &VirtualMachine,
dict: &rustpython_vm::builtins::PyDictRef,
key: &str,
x: f64,
y: f64,
z: f64,
) {
let v = vm.ctx.new_dict();
v.set_item("x", vm.new_pyobj(x), vm).unwrap();
v.set_item("y", vm.new_pyobj(y), vm).unwrap();
v.set_item("z", vm.new_pyobj(z), vm).unwrap();
dict.set_item(key, v.into(), vm).unwrap();
}
#[cfg(feature = "experimental-host-model")]
fn vector2(vm: &VirtualMachine, x: f64, y: f64) -> PyObjectRef {
let value = vm.ctx.new_dict();
value.set_item("x", vm.new_pyobj(x), vm).unwrap();
value.set_item("y", vm.new_pyobj(y), vm).unwrap();
value.into()
}
#[cfg(feature = "experimental-host-model")]
fn hatch_boundary(vm: &VirtualMachine, size: f64) -> PyObjectRef {
let edges = [
(0.0, 0.0, size, 0.0),
(size, 0.0, size, size),
(size, size, 0.0, size),
(0.0, size, 0.0, 0.0),
]
.into_iter()
.map(|(x1, y1, x2, y2)| {
let line = vm.ctx.new_dict();
line.set_item("start", vector2(vm, x1, y1), vm).unwrap();
line.set_item("end", vector2(vm, x2, y2), vm).unwrap();
let edge = vm.ctx.new_dict();
edge.set_item("kind", vm.new_pyobj("Line"), vm).unwrap();
edge.set_item("value", line.into(), vm).unwrap();
edge.into()
})
.collect();
let path = vm.ctx.new_dict();
path.set_item("flags", vm.new_pyobj(1_i64), vm).unwrap();
path.set_item("edges", vm.ctx.new_list(edges).into(), vm)
.unwrap();
path.set_item("boundary_handles", vm.ctx.new_list(Vec::new()).into(), vm)
.unwrap();
path.into()
}
#[cfg(feature = "experimental-host-model")]
#[test]
fn phase_seven_kinds_create_and_preserve_unmentioned_properties() {
with_vm(|vm| {
for kind in ["Ray", "XLine", "Solid", "Face3D"] {
let input = vm.ctx.new_dict();
input.set_item("kind", vm.new_pyobj(kind), vm).unwrap();
input
.set_item("layer", vm.new_pyobj("Geometry"), vm)
.unwrap();
match kind {
"Ray" | "XLine" => {
set_vector3(vm, &input, "base_point", 1.0, 2.0, 0.0);
set_vector3(vm, &input, "direction", 1.0, 0.0, 0.0);
}
_ => {
for (name, x, y) in [
("first_corner", 0.0, 0.0),
("second_corner", 1.0, 0.0),
("third_corner", 0.0, 1.0),
("fourth_corner", 1.0, 1.0),
] {
set_vector3(vm, &input, name, x, y, 0.0);
}
}
}
if kind == "Solid" {
input.set_item("is_trace", vm.new_pyobj(true), vm).unwrap();
}
if kind == "Face3D" {
input
.set_item("invisible_edges", vm.new_pyobj(5_i64), vm)
.unwrap();
}
let original = dict_to_entity(&input, vm).unwrap();
ocs_plugin_api::entity_coverage::validate_new_canvas_entity(&original).unwrap();
let rendered = entity_to_dict(vm, &original)
.unwrap()
.try_into_value::<rustpython_vm::builtins::PyDictRef>(vm)
.unwrap();
assert_eq!(
rendered
.get_item("kind", vm)
.unwrap()
.try_into_value::<String>(vm)
.unwrap(),
kind
);
let patch = vm.ctx.new_dict();
let changed_field = if matches!(kind, "Ray" | "XLine") {
"base_point"
} else {
"first_corner"
};
set_vector3(vm, &patch, changed_field, 7.0, 8.0, 0.0);
let updated = apply_dict_to_entity(&original, &patch, vm).unwrap();
ocs_plugin_api::entity_coverage::validate_entity_mutation(&original, &updated)
.unwrap();
let updated_dict = entity_to_dict(vm, &updated)
.unwrap()
.try_into_value::<rustpython_vm::builtins::PyDictRef>(vm)
.unwrap();
assert_eq!(
updated_dict
.get_item("layer", vm)
.unwrap()
.try_into_value::<String>(vm)
.unwrap(),
"Geometry"
);
if kind == "Solid" {
assert!(updated_dict
.get_item("is_trace", vm)
.unwrap()
.try_into_value::<bool>(vm)
.unwrap());
}
if kind == "Face3D" {
assert_eq!(
updated_dict
.get_item("invisible_edges", vm)
.unwrap()
.try_into_value::<i64>(vm)
.unwrap(),
5
);
}
}
});
}
#[cfg(feature = "experimental-host-model")]
#[test]
fn face3d_flags_reject_integer_overflow() {
with_vm(|vm| {
let input = vm.ctx.new_dict();
input.set_item("kind", vm.new_pyobj("Face3D"), vm).unwrap();
for name in [
"first_corner",
"second_corner",
"third_corner",
"fourth_corner",
] {
set_vector3(vm, &input, name, 0.0, 0.0, 0.0);
}
input
.set_item("invisible_edges", vm.new_pyobj(256_i64), vm)
.unwrap();
assert!(dict_to_entity(&input, vm).is_err());
});
}
#[cfg(feature = "experimental-host-model")]
#[test]
fn insert_is_created_by_block_name_and_preserves_block_identity() {
with_vm(|vm| {
let mut insert =
acadrust::entities::Insert::new("DOOR", Vector3::new(1.0, 2.0, 0.0));
insert.set_x_scale(2.0);
let original = EntityType::Insert(insert);
let rendered = entity_to_dict(vm, &original)
.unwrap()
.try_into_value::<rustpython_vm::builtins::PyDictRef>(vm)
.unwrap();
assert_eq!(
rendered
.get_item("block_name", vm)
.unwrap()
.try_into_value::<String>(vm)
.unwrap(),
"DOOR"
);
assert_eq!(
rendered
.get_item("x_scale", vm)
.unwrap()
.try_into_value::<f64>(vm)
.unwrap(),
2.0
);
// A round-tripped dict names an existing block, so it creates one.
let recreated = dict_to_entity(&rendered, vm).unwrap();
assert!(matches!(&recreated, EntityType::Insert(value)
if value.block_name == "DOOR" && value.x_scale() == 2.0));
// Creation must name the block.
let unnamed = vm.ctx.new_dict();
unnamed.set_item("kind", vm.new_pyobj("Insert"), vm).unwrap();
assert!(dict_to_entity(&unnamed, vm).is_err());
let patch = vm.ctx.new_dict();
set_vector3(vm, &patch, "insert_point", 4.0, 5.0, 0.0);
patch.set_item("y_scale", vm.new_pyobj(3.0), vm).unwrap();
let updated = apply_dict_to_entity(&original, &patch, vm).unwrap();
ocs_plugin_api::entity_coverage::validate_entity_mutation(&original, &updated)
.unwrap();
let EntityType::Insert(updated) = updated else {
panic!("expected Insert");
};
assert_eq!(updated.block_name, "DOOR");
assert_eq!(updated.insert_point, Vector3::new(4.0, 5.0, 0.0));
assert_eq!(updated.x_scale(), 2.0);
assert_eq!(updated.y_scale(), 3.0);
patch
.set_item("block_name", vm.new_pyobj("OTHER"), vm)
.unwrap();
// The adapter only converts; the host mutation check refuses a
// different block, so an edit cannot swap block identity.
let renamed = apply_dict_to_entity(&original, &patch, vm).unwrap();
assert!(ocs_plugin_api::entity_coverage::validate_entity_mutation(&original, &renamed).is_err());
patch.del_item("block_name", vm).unwrap();
patch.set_item("z_scale", vm.new_pyobj(0.0), vm).unwrap();
assert!(apply_dict_to_entity(&original, &patch, vm).is_err());
});
}
#[cfg(feature = "experimental-host-model")]
#[test]
fn tolerance_converts_and_style_change_clears_stale_handle() {
with_vm(|vm| {
let input = vm.ctx.new_dict();
input
.set_item("kind", vm.new_pyobj("Tolerance"), vm)
.unwrap();
set_vector3(vm, &input, "insertion_point", 1.0, 2.0, 0.0);
set_vector3(vm, &input, "direction", 1.0, 0.0, 0.0);
input
.set_item("text", vm.new_pyobj("{\\Fgdt;p}%%v0.1"), vm)
.unwrap();
input
.set_item("dimension_style_name", vm.new_pyobj("Standard"), vm)
.unwrap();
let original = dict_to_entity(&input, vm).unwrap();
ocs_plugin_api::entity_coverage::validate_new_canvas_entity(&original).unwrap();
let EntityType::Tolerance(mut with_handle) = original.clone() else {
panic!("expected Tolerance");
};
with_handle.dimension_style_handle = Some(Handle::new(42));
let original = EntityType::Tolerance(with_handle);
let patch = vm.ctx.new_dict();
patch
.set_item("text", vm.new_pyobj("{\\Fgdt;p}%%v0.2"), vm)
.unwrap();
patch
.set_item("dimension_style_name", vm.new_pyobj("ISO-25"), vm)
.unwrap();
let updated = apply_dict_to_entity(&original, &patch, vm).unwrap();
let EntityType::Tolerance(updated) = updated else {
panic!("expected Tolerance");
};
assert_eq!(updated.text, "{\\Fgdt;p}%%v0.2");
assert_eq!(updated.dimension_style_name, "ISO-25");
assert_eq!(updated.dimension_style_handle, None);
assert_eq!(updated.insertion_point, Vector3::new(1.0, 2.0, 0.0));
let rendered = entity_to_dict(vm, &EntityType::Tolerance(updated))
.unwrap()
.try_into_value::<rustpython_vm::builtins::PyDictRef>(vm)
.unwrap();
assert!(rendered
.get_item_opt("dimension_style_handle", vm)
.unwrap()
.is_some());
assert!(rendered
.get_item_opt("dwg_unknown_short", vm)
.unwrap()
.is_none());
});
}
#[cfg(feature = "experimental-host-model")]
#[test]
fn shape_converts_and_style_change_clears_stale_handle() {
with_vm(|vm| {
let input = vm.ctx.new_dict();
input.set_item("kind", vm.new_pyobj("Shape"), vm).unwrap();
set_vector3(vm, &input, "insertion_point", 1.0, 2.0, 0.0);
input.set_item("size", vm.new_pyobj(2.0), vm).unwrap();
input
.set_item("shape_name", vm.new_pyobj("ARROW"), vm)
.unwrap();
input
.set_item("shape_number", vm.new_pyobj(1_i32), vm)
.unwrap();
input
.set_item("style_name", vm.new_pyobj("Symbols"), vm)
.unwrap();
let original = dict_to_entity(&input, vm).unwrap();
ocs_plugin_api::entity_coverage::validate_new_canvas_entity(&original).unwrap();
let EntityType::Shape(mut with_handle) = original.clone() else {
panic!("expected Shape");
};
with_handle.style_handle = Some(Handle::new(42));
let original = EntityType::Shape(with_handle);
let patch = vm.ctx.new_dict();
patch.set_item("size", vm.new_pyobj(3.0), vm).unwrap();
patch
.set_item("style_name", vm.new_pyobj("OtherSymbols"), vm)
.unwrap();
let updated = apply_dict_to_entity(&original, &patch, vm).unwrap();
let EntityType::Shape(updated) = updated else {
panic!("expected Shape");
};
assert_eq!(updated.size, 3.0);
assert_eq!(updated.shape_name, "ARROW");
assert_eq!(updated.shape_number, 1);
assert_eq!(updated.style_name, "OtherSymbols");
assert_eq!(updated.style_handle, None);
assert_eq!(updated.insertion_point, Vector3::new(1.0, 2.0, 0.0));
});
}
#[cfg(feature = "experimental-host-model")]
#[test]
fn attribute_definition_converts_owner_and_preserves_embedded_mtext() {
with_vm(|vm| {
let input = vm.ctx.new_dict();
input
.set_item("kind", vm.new_pyobj("AttributeDefinition"), vm)
.unwrap();
input
.set_item("owner_handle", vm.new_pyobj(42_u64), vm)
.unwrap();
input.set_item("tag", vm.new_pyobj("PART_NO"), vm).unwrap();
input
.set_item("prompt", vm.new_pyobj("Part number"), vm)
.unwrap();
input
.set_item("default_value", vm.new_pyobj("PN-001"), vm)
.unwrap();
set_vector3(vm, &input, "insertion_point", 1.0, 2.0, 0.0);
input.set_item("height", vm.new_pyobj(2.5), vm).unwrap();
let entity = dict_to_entity(&input, vm).unwrap();
let EntityType::AttributeDefinition(mut definition) = entity else {
panic!("expected AttributeDefinition");
};
assert_eq!(definition.common.owner_handle, Handle::new(42));
assert_eq!(definition.tag, "PART_NO");
assert_eq!(definition.text_style, "Standard");
let mut embedded = acadrust::entities::MText::default();
embedded.value = "first\\Psecond".into();
definition.embedded_mtext = Some(Box::new(embedded));
let entity = EntityType::AttributeDefinition(definition);
let patch = vm.ctx.new_dict();
patch
.set_item("default_value", vm.new_pyobj("PN-002"), vm)
.unwrap();
set_vector3(vm, &patch, "insertion_point", 4.0, 5.0, 0.0);
let updated = apply_dict_to_entity(&entity, &patch, vm).unwrap();
let EntityType::AttributeDefinition(updated) = updated else {
panic!("expected AttributeDefinition");
};
assert_eq!(updated.default_value, "PN-002");
assert_eq!(updated.insertion_point, Vector3::new(4.0, 5.0, 0.0));
assert_eq!(
updated.embedded_mtext.as_ref().unwrap().value,
"first\\Psecond"
);
let rendered = entity_to_dict(vm, &EntityType::AttributeDefinition(updated))
.unwrap()
.try_into_value::<rustpython_vm::builtins::PyDictRef>(vm)
.unwrap();
assert_eq!(
rendered
.get_item("owner_handle", vm)
.unwrap()
.try_into_value::<u64>(vm)
.unwrap(),
42
);
assert!(rendered
.get_item_opt("embedded_mtext", vm)
.unwrap()
.is_none());
let owner_patch = vm.ctx.new_dict();
owner_patch
.set_item("owner_handle", vm.new_pyobj(43_u64), vm)
.unwrap();
assert!(apply_dict_to_entity(&entity, &owner_patch, vm)
.unwrap_err()
.args()
.as_slice()[0]
.clone()
.try_into_value::<String>(vm)
.unwrap()
.contains("read-only after creation"));
});
}
#[cfg(feature = "experimental-host-model")]
#[test]
fn attribute_entity_converts_nested_identity_and_preserves_embedded_mtext() {
with_vm(|vm| {
let input = vm.ctx.new_dict();
input
.set_item("kind", vm.new_pyobj("AttributeEntity"), vm)
.unwrap();
input
.set_item("owner_handle", vm.new_pyobj(50_u64), vm)
.unwrap();
input.set_item("tag", vm.new_pyobj("PART_NO"), vm).unwrap();
input.set_item("value", vm.new_pyobj("PN-101"), vm).unwrap();
set_vector3(vm, &input, "insertion_point", 10.0, 2.0, 0.0);
input.set_item("height", vm.new_pyobj(2.5), vm).unwrap();
let entity = dict_to_entity(&input, vm).unwrap();
let EntityType::AttributeEntity(mut attribute) = entity else {
panic!("expected AttributeEntity");
};
assert_eq!(attribute.common.owner_handle, Handle::new(50));
attribute.attdef_handle = Handle::new(42);
let mut embedded = acadrust::entities::MText::default();
embedded.value = "first\\Psecond".into();
attribute.embedded_mtext = Some(Box::new(embedded));
let entity = EntityType::AttributeEntity(attribute);
let patch = vm.ctx.new_dict();
patch.set_item("value", vm.new_pyobj("PN-102"), vm).unwrap();
set_vector3(vm, &patch, "insertion_point", 12.0, 3.0, 0.0);
let updated_entity = apply_dict_to_entity(&entity, &patch, vm).unwrap();
let EntityType::AttributeEntity(updated) = &updated_entity else {
unreachable!()
};
assert_eq!(updated.value, "PN-102");
assert_eq!(updated.attdef_handle, Handle::new(42));
assert_eq!(
updated.embedded_mtext.as_ref().unwrap().value,
"first\\Psecond"
);
let tag_patch = vm.ctx.new_dict();
tag_patch
.set_item("tag", vm.new_pyobj("SERIAL"), vm)
.unwrap();
let retagged = apply_dict_to_entity(&updated_entity, &tag_patch, vm).unwrap();
assert!(matches!(retagged, EntityType::AttributeEntity(value)
if value.tag == "SERIAL" && value.attdef_handle == Handle::new(42)));
let rendered = entity_to_dict(vm, &updated_entity)
.unwrap()
.try_into_value::<rustpython_vm::builtins::PyDictRef>(vm)
.unwrap();
assert_eq!(
rendered
.get_item("attdef_handle", vm)
.unwrap()
.try_into_value::<u64>(vm)
.unwrap(),
42
);
assert!(rendered
.get_item_opt("embedded_mtext", vm)
.unwrap()
.is_none());
});
}
#[cfg(feature = "experimental-host-model")]
#[test]
fn hatch_converts_tagged_boundaries_patterns_and_preserves_gradient() {
with_vm(|vm| {
let input = vm.ctx.new_dict();
input.set_item("kind", vm.new_pyobj("Hatch"), vm).unwrap();
input
.set_item(
"paths",
vm.ctx.new_list(vec![hatch_boundary(vm, 10.0)]).into(),
vm,
)
.unwrap();
let entity = dict_to_entity(&input, vm).unwrap();
ocs_plugin_api::entity_coverage::validate_new_canvas_entity(&entity).unwrap();
let EntityType::Hatch(mut hatch) = entity else {
panic!("expected Hatch")
};
hatch.gradient_color.enabled = true;
hatch.gradient_color.name = "LINEAR".into();
let entity = EntityType::Hatch(hatch);
let pattern_line = vm.ctx.new_dict();
pattern_line
.set_item("angle", vm.new_pyobj(0.0), vm)
.unwrap();
pattern_line
.set_item("base_point", vector2(vm, 0.0, 0.0), vm)
.unwrap();
pattern_line
.set_item("offset", vector2(vm, 0.0, 2.0), vm)
.unwrap();
pattern_line
.set_item(
"dash_lengths",
vm.ctx
.new_list(vec![vm.new_pyobj(1.0), vm.new_pyobj(-1.0)])
.into(),
vm,
)
.unwrap();
let pattern = vm.ctx.new_dict();
pattern.set_item("name", vm.new_pyobj("TEST"), vm).unwrap();
pattern
.set_item("description", vm.new_pyobj("test pattern"), vm)
.unwrap();
pattern
.set_item(
"lines",
vm.ctx.new_list(vec![pattern_line.into()]).into(),
vm,
)
.unwrap();
let patch = vm.ctx.new_dict();
patch.set_item("is_solid", vm.new_pyobj(false), vm).unwrap();
patch.set_item("pattern", pattern.into(), vm).unwrap();
patch
.set_item("pattern_scale", vm.new_pyobj(2.0), vm)
.unwrap();
patch
.set_item(
"paths",
vm.ctx.new_list(vec![hatch_boundary(vm, 12.0)]).into(),
vm,
)
.unwrap();
let updated = apply_dict_to_entity(&entity, &patch, vm).unwrap();
ocs_plugin_api::entity_coverage::validate_entity_mutation(&entity, &updated)
.unwrap();
let EntityType::Hatch(updated) = updated else {
unreachable!()
};
assert!(!updated.is_solid);
assert_eq!(updated.pattern.name, "TEST");
assert_eq!(updated.pattern.lines.len(), 1);
assert_eq!(updated.paths[0].edges.len(), 4);
assert!(updated.gradient_color.enabled);
let rendered = entity_to_dict(vm, &EntityType::Hatch(updated))
.unwrap()
.try_into_value::<rustpython_vm::builtins::PyDictRef>(vm)
.unwrap();
assert!(rendered
.get_item_opt("gradient_color", vm)
.unwrap()
.is_none());
assert_eq!(
rendered
.get_item("paths", vm)
.unwrap()
.try_into_value::<Vec<PyObjectRef>>(vm)
.unwrap()
.len(),
1
);
let forbidden = vm.ctx.new_dict();
forbidden
.set_item("gradient_color", vm.ctx.new_dict().into(), vm)
.unwrap();
assert!(apply_dict_to_entity(&entity, &forbidden, vm).is_err());
});
}
#[cfg(feature = "experimental-host-model")]
#[test]
fn phase_seven_kinds_survive_dwg_round_trip() {
with_vm(|vm| {
let mut doc = acadrust::CadDocument::new();
let mut handles = Vec::new();
for kind in ["Ray", "XLine", "Solid", "Face3D"] {
let input = vm.ctx.new_dict();
input.set_item("kind", vm.new_pyobj(kind), vm).unwrap();
if matches!(kind, "Ray" | "XLine") {
set_vector3(vm, &input, "base_point", 2.0, 3.0, 0.0);
set_vector3(vm, &input, "direction", 1.0, 0.0, 0.0);
} else {
for (name, x, y) in [
("first_corner", 0.0, 0.0),
("second_corner", 1.0, 0.0),
("third_corner", 0.0, 1.0),
("fourth_corner", 1.0, 1.0),
] {
set_vector3(vm, &input, name, x, y, 0.0);
}
}
let entity = dict_to_entity(&input, vm).unwrap();
ocs_plugin_api::entity_coverage::validate_new_canvas_entity(&entity).unwrap();
handles.push((kind, doc.add_entity(entity).unwrap()));
}
let path = std::env::temp_dir()
.join(format!("ocs_phase_seven_{}.dwg", std::process::id()));
acadrust::DwgWriter::write_to_file(&path, &doc).unwrap();
let reopened = acadrust::DwgReader::from_file(&path)
.unwrap()
.read()
.unwrap();
let _ = std::fs::remove_file(&path);
for (kind, handle) in handles {
let entity = reopened.get_entity(handle).expect("entity survives reopen");
let rendered = entity_to_dict(vm, entity)
.unwrap()
.try_into_value::<rustpython_vm::builtins::PyDictRef>(vm)
.unwrap();
assert_eq!(
rendered
.get_item("kind", vm)
.unwrap()
.try_into_value::<String>(vm)
.unwrap(),
kind
);
}
});
}
#[test]
fn text_is_in_editable_schema_and_keeps_unmentioned_fields() {
with_vm(|vm| {
let mut text = acadrust::entities::Text::new();
text.value = "Before".into();
text.height = 3.5;
text.insertion_point = Vector3::new(1.0, 2.0, 0.0);
let entity = EntityType::Text(text);
let data = entity_to_dict(vm, &entity).unwrap();
let data = data
.try_into_value::<rustpython_vm::builtins::PyDictRef>(vm)
.unwrap();
assert_eq!(
data.get_item("text", vm)
.unwrap()
.try_into_value::<String>(vm)
.unwrap(),
"Before"
);
let patch = vm.ctx.new_dict();
patch.set_item("text", vm.new_pyobj("After"), vm).unwrap();
let updated = apply_dict_to_entity(&entity, &patch, vm).unwrap();
let EntityType::Text(updated) = updated else {
panic!("expected text");
};
assert_eq!(updated.value, "After");
assert_eq!(updated.height, 3.5);
assert_eq!(updated.insertion_point, Vector3::new(1.0, 2.0, 0.0));
});
}
#[test]
fn circle_round_trips_through_dict_to_entity_and_back() {
with_vm(|vm| {
let dict = vm.ctx.new_dict();
dict.set_item("kind", vm.new_pyobj("Circle"), vm).unwrap();
dict.set_item("layer", vm.new_pyobj("Walls"), vm).unwrap();
set_vector3(vm, &dict, "center", 1.0, 2.0, 0.0);
dict.set_item("radius", vm.new_pyobj(5.0), vm).unwrap();
let entity = dict_to_entity(&dict, vm).expect("dict_to_entity");
let acadrust::EntityType::Circle(circle) = &entity else {
panic!("expected Circle, got {entity:?}");
};
assert_eq!(circle.common.layer, "Walls");
assert_eq!(circle.center, Vector3::new(1.0, 2.0, 0.0));
assert_eq!(circle.radius, 5.0);
let round_tripped = entity_to_dict(vm, &entity).expect("entity_to_dict");
let round_tripped = round_tripped
.try_into_value::<rustpython_vm::builtins::PyDictRef>(vm)
.unwrap();
assert_eq!(
round_tripped
.get_item("kind", vm)
.unwrap()
.try_into_value::<String>(vm)
.unwrap(),
"Circle"
);
assert_eq!(
round_tripped
.get_item("radius", vm)
.unwrap()
.try_into_value::<f64>(vm)
.unwrap(),
5.0
);
});
}
#[test]
fn add_rejects_missing_required_geometry_instead_of_defaulting_to_zero() {
with_vm(|vm| {
// Felix's Phase 1 review: a Circle with no center/radius must
// error, not silently become a zero-radius circle at the origin.
let dict = vm.ctx.new_dict();
dict.set_item("kind", vm.new_pyobj("Circle"), vm).unwrap();
let err = dict_to_entity(&dict, vm).expect_err("missing radius/center must error");
let message = err.args().as_slice()[0]
.clone()
.try_into_value::<String>(vm)
.unwrap();
assert!(
message.contains("requires"),
"unexpected message: {message}"
);
let dict = vm.ctx.new_dict();
dict.set_item("kind", vm.new_pyobj("Polyline2D"), vm).unwrap();
let err = dict_to_entity(&dict, vm).expect_err("missing vertices must error");
let message = err.args().as_slice()[0]
.clone()
.try_into_value::<String>(vm)
.unwrap();
assert!(
message.contains("at least one"),
"unexpected message: {message}"
);
// An explicitly empty vertex list is exactly as invalid as an
// absent one — both parse to the same empty Vec.
let dict = vm.ctx.new_dict();
dict.set_item("kind", vm.new_pyobj("Polyline2D"), vm).unwrap();
dict.set_item("vertices", vm.ctx.new_list(vec![]).into(), vm)
.unwrap();
assert!(dict_to_entity(&dict, vm).is_err());
});
}
#[test]
fn legacy_polyline_creation_is_refused_with_a_hint() {
with_vm(|vm| {
let dict = vm.ctx.new_dict();
dict.set_item("kind", vm.new_pyobj("Polyline"), vm).unwrap();
let err = dict_to_entity(&dict, vm).expect_err("legacy Polyline is update-only");
let message = err.args().as_slice()[0]
.clone()
.try_into_value::<String>(vm)
.unwrap();
assert!(message.contains("Polyline2D or Polyline3D"), "{message}");
});
}
#[test]
fn polyline_flags_override_exposes_closed_as_a_plain_bool() {
with_vm(|vm| {
let dict = vm.ctx.new_dict();
dict.set_item("kind", vm.new_pyobj("Polyline2D"), vm).unwrap();
dict.set_item("closed", vm.new_pyobj(true), vm).unwrap();
let vertex = vm.ctx.new_dict();
let location = vm.ctx.new_dict();
location.set_item("x", vm.new_pyobj(0.0), vm).unwrap();
location.set_item("y", vm.new_pyobj(0.0), vm).unwrap();
location.set_item("z", vm.new_pyobj(0.0), vm).unwrap();
vertex.set_item("location", location.into(), vm).unwrap();
dict.set_item("vertices", vm.ctx.new_list(vec![vertex.into()]).into(), vm)
.unwrap();
let entity = dict_to_entity(&dict, vm).expect("dict_to_entity");
let acadrust::EntityType::Polyline2D(polyline) = &entity else {
panic!("expected Polyline2D, got {entity:?}");
};
assert!(polyline.flags.is_closed());
assert_eq!(polyline.vertices.len(), 1);
let round_tripped = entity_to_dict(vm, &entity).expect("entity_to_dict");
let round_tripped = round_tripped
.try_into_value::<rustpython_vm::builtins::PyDictRef>(vm)
.unwrap();
assert!(round_tripped
.get_item("closed", vm)
.unwrap()
.try_into_value::<bool>(vm)
.unwrap());
});
}
#[test]
fn mtext_round_trips_unit_enum_and_excluded_fields() {
with_vm(|vm| {
let dict = vm.ctx.new_dict();
dict.set_item("kind", vm.new_pyobj("MText"), vm).unwrap();
dict.set_item("text", vm.new_pyobj("hello"), vm).unwrap();
set_vector3(vm, &dict, "insertion", 0.0, 0.0, 0.0);
dict.set_item("attachment_point", vm.new_pyobj("MiddleCenter"), vm)
.unwrap();
let entity = dict_to_entity(&dict, vm).expect("dict_to_entity");
let acadrust::EntityType::MText(text) = &entity else {
panic!("expected MText, got {entity:?}");
};
assert_eq!(text.value, "hello");
assert_eq!(
text.attachment_point,
acadrust::entities::AttachmentPoint::MiddleCenter
);
let round_tripped = entity_to_dict(vm, &entity).expect("entity_to_dict");
let round_tripped = round_tripped
.try_into_value::<rustpython_vm::builtins::PyDictRef>(vm)
.unwrap();
assert_eq!(
round_tripped
.get_item("attachment_point", vm)
.unwrap()
.try_into_value::<String>(vm)
.unwrap(),
"MiddleCenter"
);
assert!(round_tripped
.get_item_opt("background_color", vm)
.unwrap()
.is_none());
});
}
#[test]
fn update_preserves_fields_the_dict_does_not_mention() {
with_vm(|vm| {
let add_dict = vm.ctx.new_dict();
add_dict.set_item("kind", vm.new_pyobj("Line"), vm).unwrap();
add_dict
.set_item("layer", vm.new_pyobj("Geometry"), vm)
.unwrap();
set_vector3(vm, &add_dict, "start", 0.0, 0.0, 0.0);
set_vector3(vm, &add_dict, "end", 10.0, 0.0, 0.0);
let existing = dict_to_entity(&add_dict, vm).expect("dict_to_entity");
// Only "end" is mentioned — "start" and "layer" must survive
// untouched, unlike a fresh `dict_to_entity` from this same
// partial dict (which would reset them to their type defaults).
let update_dict = vm.ctx.new_dict();
set_vector3(vm, &update_dict, "end", 20.0, 5.0, 0.0);
let updated = apply_dict_to_entity(&existing, &update_dict, vm)
.expect("apply_dict_to_entity");
let acadrust::EntityType::Line(line) = &updated else {
panic!("expected Line, got {updated:?}");
};
assert_eq!(line.common.layer, "Geometry", "layer must be preserved");
assert_eq!(
line.start,
Vector3::new(0.0, 0.0, 0.0),
"start must be preserved"
);
assert_eq!(
line.end,
Vector3::new(20.0, 5.0, 0.0),
"end must be updated"
);
// A second update, this time touching the layer, must not
// disturb the geometry it doesn't mention either.
let update_dict = vm.ctx.new_dict();
update_dict
.set_item("layer", vm.new_pyobj("Renamed"), vm)
.unwrap();
let updated =
apply_dict_to_entity(&updated, &update_dict, vm).expect("apply_dict_to_entity");
let acadrust::EntityType::Line(line) = &updated else {
panic!("expected Line, got {updated:?}");
};
assert_eq!(line.common.layer, "Renamed");
assert_eq!(line.start, Vector3::new(0.0, 0.0, 0.0));
assert_eq!(line.end, Vector3::new(20.0, 5.0, 0.0));
});
}
/// The concrete Phase 1 acceptance test Felix asked for: create, read,
/// edit, delete, save, and reopen a Line and an Ellipse, with common
/// properties preserved — exercised against a real
/// `acadrust::CadDocument` and a real DWG round trip (not a fake
/// `HostApi`; `dict_to_entity`/`apply_dict_to_entity`/`entity_to_dict`
/// are exactly what a live `HostApi`-backed `ocs.add`/`ocs.update`/
/// `ocs.get_entity` call into, so this covers the same logic without
/// needing a from-scratch test double for the whole `HostApi` trait).
#[test]
fn line_and_ellipse_survive_create_read_edit_delete_save_reopen() {
with_vm(|vm| {
let mut doc = acadrust::CadDocument::new();
// A layer must exist in the document's layer table before an
// entity can reference it — same requirement `set_entity_layer`
// above already enforces for a live document. Without this,
// the DWG writer silently falls back the entity to layer "0"
// instead of erroring, which this test caught the first time
// around (it used "Geometry" without registering it first).
doc.layers.add(acadrust::Layer::new("Geometry")).unwrap();
// --- CREATE ---
let add_line = vm.ctx.new_dict();
add_line.set_item("kind", vm.new_pyobj("Line"), vm).unwrap();
add_line
.set_item("layer", vm.new_pyobj("Geometry"), vm)
.unwrap();
set_vector3(vm, &add_line, "start", 0.0, 0.0, 0.0);
set_vector3(vm, &add_line, "end", 10.0, 5.0, 0.0);
let line_handle = doc
.add_entity(dict_to_entity(&add_line, vm).expect("dict_to_entity Line"))
.expect("doc.add_entity Line");
let add_ellipse = vm.ctx.new_dict();
add_ellipse
.set_item("kind", vm.new_pyobj("Ellipse"), vm)
.unwrap();
add_ellipse
.set_item("layer", vm.new_pyobj("Geometry"), vm)
.unwrap();
set_vector3(vm, &add_ellipse, "center", 1.0, 2.0, 0.0);
set_vector3(vm, &add_ellipse, "major_axis", 5.0, 0.0, 0.0);
add_ellipse
.set_item("minor_axis_ratio", vm.new_pyobj(0.5), vm)
.unwrap();
let ellipse_handle = doc
.add_entity(dict_to_entity(&add_ellipse, vm).expect("dict_to_entity Ellipse"))
.expect("doc.add_entity Ellipse");
// --- READ ---
let line_dict =
entity_to_dict(vm, doc.get_entity(line_handle).expect("line exists"))
.expect("entity_to_dict Line");
assert_eq!(
line_dict
.clone()
.try_into_value::<rustpython_vm::builtins::PyDictRef>(vm)
.unwrap()
.get_item("layer", vm)
.unwrap()
.try_into_value::<String>(vm)
.unwrap(),
"Geometry"
);
// --- EDIT (update-merge: only "end" changes) ---
let existing = doc.get_entity(line_handle).unwrap().clone();
let edit_dict = vm.ctx.new_dict();
set_vector3(vm, &edit_dict, "end", 20.0, 5.0, 0.0);
let updated = apply_dict_to_entity(&existing, &edit_dict, vm)
.expect("apply_dict_to_entity Line");
*doc.get_entity_mut(line_handle).unwrap() = updated;
let acadrust::EntityType::Line(line) = doc.get_entity(line_handle).unwrap() else {
panic!("expected Line");
};
assert_eq!(
line.common.layer, "Geometry",
"layer survives an unrelated edit"
);
assert_eq!(
line.start,
Vector3::new(0.0, 0.0, 0.0),
"start survives an unrelated edit"
);
assert_eq!(
line.end,
Vector3::new(20.0, 5.0, 0.0),
"end reflects the edit"
);
// --- DELETE ---
assert!(doc.remove_entity(ellipse_handle).is_some());
assert!(doc.get_entity(ellipse_handle).is_none());
// --- SAVE / REOPEN ---
let path = std::env::temp_dir().join(format!(
"ocs_entity_crud_acceptance_test_{}.dwg",
std::process::id()
));
acadrust::DwgWriter::write_to_file(&path, &doc).expect("write dwg");
let reopened = acadrust::DwgReader::from_file(&path)
.expect("open dwg")
.read()
.expect("read dwg");
let _ = std::fs::remove_file(&path);
let acadrust::EntityType::Line(reopened_line) = reopened
.get_entity(line_handle)
.expect("line survives save/reopen")
else {
panic!("expected Line");
};
assert_eq!(
reopened_line.common.layer, "Geometry",
"layer survives a real DWG round trip"
);
assert_eq!(
reopened_line.start,
Vector3::new(0.0, 0.0, 0.0),
"start survives a real DWG round trip"
);
assert_eq!(
reopened_line.end,
Vector3::new(20.0, 5.0, 0.0),
"the edit survives a real DWG round trip"
);
assert!(
reopened.get_entity(ellipse_handle).is_none(),
"the deleted ellipse must not reappear after reopen"
);
});
}
}
}
/// These run a real RustPython interpreter with the `ocs` module registered
/// (same setup as `lib.rs`'s `run_eval`), but with no `HostGuard` active — so
/// `host_ctx::with_host` always returns `None`. `add_points` and
/// `write_record` both finish all of their own argument/dict parsing
/// *before* calling `host_ctx::with_host`, so getting exactly the sentinel
/// "not running inside a PY_ command" `RuntimeError` (rather than a
/// `TypeError`/`ValueError` from the parsing itself) confirms the new
/// RustPython glue code — `Vec<PyObjectRef>` extraction, `PyDictRef`
/// downcasting, `try_into_value` for every XDATA value kind — actually works
/// against the real interpreter, without needing a live `HostApi`.
#[cfg(test)]
mod tests {
use super::module_def;
#[cfg(feature = "experimental-host-model")]
#[test]
fn host_coverage_matches_generated_python_keys() {
use ocs_plugin_api::entity_coverage::{EntityCoverageCatalog, ModelAccess};
use std::collections::{BTreeMap, BTreeSet};
let catalog: EntityCoverageCatalog =
serde_json::from_str(ocs_plugin_api::get_embedded_entity_coverage_json()).unwrap();
let generated = include_str!(concat!(env!("OUT_DIR"), "/entity_crud.rs"));
let converter = generated
.split("pub(crate) fn entity_to_dict")
.nth(1)
.unwrap()
.split("pub(crate) fn dict_to_entity")
.next()
.unwrap();
let mut emitted: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
let mut current = None;
for line in converter.lines() {
let line = line.trim();
if let Some(arm) = line.strip_prefix("acadrust::EntityType::") {
if let Some((kind, _)) = arm.split_once("(value) => {") {
current = Some(kind.to_owned());
emitted.entry(kind.to_owned()).or_default();
}
}
if let (Some(kind), Some(key)) = (&current, line.strip_prefix("dict.set_item(\"")) {
let key = key.split('"').next().unwrap();
emitted.get_mut(kind).unwrap().insert(key.to_owned());
}
if line == "}" {
current = None;
}
}
// Hand-written kinds: keys come from the include file's dict writes
// and per-subtype field table.
let manual = include_str!("dimension_model.rs.inc");
let dimension_keys = emitted.entry("Dimension".to_owned()).or_default();
for line in manual.lines() {
let line = line.trim();
let key = line
.strip_prefix("dict.set_item(\"")
.or_else(|| {
(line.contains("\", V(") || line.contains("\", F(") || line.contains("\", B("))
.then(|| line.strip_prefix("(\""))
.flatten()
});
if let Some(key) = key {
dimension_keys.insert(key.split('"').next().unwrap().to_owned());
}
}
for entry in &catalog.entity_kinds {
let expected: BTreeSet<_> = entry
.properties
.iter()
.filter(|property| property.model_access != ModelAccess::Unmapped)
.map(|property| property.name.clone())
.collect();
if let Some(actual) = emitted.get(&entry.kind) {
assert_eq!(&expected, actual, "{}", entry.kind);
} else {
assert_eq!(
expected,
BTreeSet::from([
"handle".to_owned(),
"kind".to_owned(),
"layer".to_owned(),
"owner_handle".to_owned()
]),
"{}",
entry.kind
);
}
}
assert_eq!(emitted.len(), 42);
}
#[cfg(feature = "experimental-host-model")]
#[test]
fn entity_snapshot_uses_catalog_variant_name() {
use ocs_plugin_api::host::acadrust;
let hatch = acadrust::EntityType::Hatch(acadrust::entities::Hatch::default());
let value = serde_json::to_value(hatch).unwrap();
let kind = value.as_object().unwrap().keys().next().unwrap();
assert_eq!(kind, "Hatch");
assert_ne!(kind, "HATCH");
}
#[cfg(feature = "experimental-host-model")]
#[test]
fn python_can_query_host_coverage_without_document() {
assert_eq!(eval("len(ocs.entity_kinds())"), Ok("48".to_owned()));
assert_eq!(
eval("'paths' in ocs.entity_coverage('Hatch')['editable']"),
Ok("True".to_owned())
);
assert_eq!(
eval("ocs.entity_coverage('Line')['scope']"),
Ok("canvas".to_owned())
);
}
#[cfg(feature = "experimental-host-model")]
#[test]
fn update_many_binding_parses_python_batch() {
let error = eval("ocs.update_many('Move', [{'handle': 1, 'layer': '0'}])").unwrap_err();
assert!(
error.contains("not running inside a PY_ command"),
"{error}"
);
}
// Runs `source` (a hardcoded literal in each test below, never external
// input) as a single expression inside a fresh, stdlib-free RustPython
// interpreter with only the `ocs` module registered — the same sandboxed
// `Mode::Eval` setup `lib.rs`'s `run_eval` uses for real `PY_EVAL`
// commands. Not a generic "eval untrusted string" helper.
fn eval(source: &str) -> Result<String, String> {
let builder = rustpython_vm::Interpreter::builder(Default::default());
let ocs_def = module_def(&builder.ctx);
let interp = builder.add_native_module(ocs_def).build();
interp.enter(|vm| {
let scope = vm.new_scope_with_builtins();
let ocs = vm.import("ocs", 0).map_err(|exc| exc_string(vm, &exc))?;
scope
.globals
.set_item("ocs", ocs, vm)
.map_err(|exc| exc_string(vm, &exc))?;
let code_obj = vm
.compile(
source,
rustpython_vm::compiler::Mode::Eval,
"<test>".to_string(),
)
.map_err(|err| err.to_string())?;
match vm.run_code_obj(code_obj, scope) {
Ok(result) => result
.str(vm)
.map(|s| s.to_string())
.map_err(|exc| exc_string(vm, &exc)),
Err(exc) => Err(exc_string(vm, &exc)),
}
})
}
fn exc_string(
vm: &rustpython_vm::VirtualMachine,
exc: &rustpython_vm::builtins::PyBaseExceptionRef,
) -> String {
let obj: rustpython_vm::PyObjectRef = exc.clone().into();
let name = obj.class().name().to_string();
let message = obj.str(vm).map(|s| s.to_string()).unwrap_or_default();
format!("{name}: {message}")
}
const NO_HOST: &str = "RuntimeError: ocs: not running inside a PY_ command";
#[test]
fn add_points_parses_before_reaching_the_host() {
let result = eval("ocs.add_points([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]])");
assert_eq!(result, Err(NO_HOST.to_string()));
}
#[test]
fn add_points_rejects_wrong_point_shape() {
let result = eval("ocs.add_points([[1.0, 2.0]])");
assert!(matches!(result, Err(ref msg) if msg.starts_with("ValueError: ocs.add_points")));
}
#[test]
fn add_points_rejects_empty_list() {
let result = eval("ocs.add_points([])");
assert!(matches!(result, Err(ref msg) if msg.starts_with("ValueError: ocs.add_points")));
}
#[test]
fn write_record_parses_every_xdata_kind_before_reaching_the_host() {
let result = eval(
r#"ocs.write_record(1, "TEST", [
{"kind": "String", "value": "hi"},
{"kind": "ControlString", "value": "{"},
{"kind": "LayerName", "value": "0"},
{"kind": "BinaryData", "value": b"\x01\x02"},
{"kind": "Handle", "value": 7},
{"kind": "Point3D", "value": [1.0, 2.0, 3.0]},
{"kind": "Position3D", "value": [1.0, 2.0, 3.0]},
{"kind": "Displacement3D", "value": [1.0, 2.0, 3.0]},
{"kind": "Direction3D", "value": [1.0, 2.0, 3.0]},
{"kind": "Real", "value": 1.5},
{"kind": "Distance", "value": 2.5},
{"kind": "ScaleFactor", "value": 1.0},
{"kind": "Integer16", "value": 16},
{"kind": "Integer32", "value": 32},
])"#,
);
assert_eq!(result, Err(NO_HOST.to_string()));
}
#[test]
fn write_record_rejects_unknown_kind() {
let result = eval(r#"ocs.write_record(1, "TEST", [{"kind": "Nope", "value": 1}])"#);
assert!(matches!(
result,
Err(ref msg) if msg.starts_with("ValueError: unsupported XDATA value kind: Nope")
));
}
#[test]
fn write_record_rejects_malformed_point_value() {
let result =
eval(r#"ocs.write_record(1, "TEST", [{"kind": "Point3D", "value": [1.0, 2.0]}])"#);
assert!(matches!(
result,
Err(ref msg) if msg.starts_with("ValueError: XDATA Point3D")
));
}
#[test]
fn read_record_and_remove_record_parse_before_reaching_the_host() {
assert_eq!(
eval(r#"ocs.read_record(1, "TEST")"#),
Err(NO_HOST.to_string())
);
assert_eq!(
eval(r#"ocs.remove_record(1, "TEST")"#),
Err(NO_HOST.to_string())
);
}
#[test]
fn add_parses_before_reaching_the_host() {
let result = eval(
r#"ocs.add({"kind": "Line", "start": {"x": 0.0, "y": 0.0, "z": 0.0}, "end": {"x": 1.0, "y": 1.0, "z": 0.0}})"#,
);
assert_eq!(result, Err(NO_HOST.to_string()));
}
#[test]
fn add_rejects_missing_required_geometry_before_reaching_the_host() {
let result = eval(r#"ocs.add({"kind": "Circle"})"#);
assert!(
matches!(result, Err(ref msg) if msg.starts_with("ValueError: ocs.add: Circle requires")),
"unexpected result: {result:?}"
);
}
#[test]
fn add_rejects_unknown_kind() {
let result = eval(r#"ocs.add({"kind": "Nope"})"#);
assert!(matches!(
result,
Err(ref msg) if msg.starts_with("ValueError: ocs: unsupported entity kind: Nope")
));
}
#[test]
fn update_and_get_entity_hit_the_host_sentinel() {
assert_eq!(
eval(r#"ocs.update(1, {"layer": "X"})"#),
Err(NO_HOST.to_string())
);
assert_eq!(eval("ocs.get_entity(1)"), Err(NO_HOST.to_string()));
}
#[test]
fn add_text_left_validates_before_reaching_host() {
assert_eq!(
eval(r#"ocs.add_text_left("Note", 0.0, 0.0, 0.0, 2.0, 0.0)"#),
Err(NO_HOST.to_string())
);
let bad = eval(r#"ocs.add_text_left("Note", 0.0, 0.0, 0.0, 0.0, 0.0)"#);
assert!(matches!(bad, Err(ref msg) if msg.starts_with("ValueError: ocs.add_text_left:")));
}
}