Add kernel solid operations and complete Solid3D (create and rigid move)

An additive HostApi::solid_operation builds cadkernel primitives and applies
rigid transforms, verifying each result lifts back losslessly and refusing a
payload it cannot lift. Exposed as doc.solids in Python; Solid3D, Body and
Region are mapped read-only with the opaque payload preserved. The lifecycle
test passes through the real runner with analytic volumes and exact extents as
oracles, DWG and DXF reopen with re-edit, and undo restores exactly.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
Felix RiestraandClaude Sonnet 5 committed 2026-09-20 17:29:06 +02:00
1 parent 07fe739571
commit 04cd9e462d
18 files changed
+717 -13

No files matched your search

+1 -1
View File
@@ -94,7 +94,7 @@ The host-owned catalog classifies all 48 entity variants and tracks every
traced field, including fields marked `unmapped`. Query it with
`doc.coverage()` or `doc.coverage("Line")`; each row includes source field,
type, access, and current validation status. Geometry edits remain limited to
the 38 mapped kinds.
the 41 mapped kinds.
For any entity, `entity.snapshot` returns a detached dictionary of its raw
CAD fields, including `snapshot["common"]` and kind-specific nested values.
It can inspect an unmapped kind; editing the dictionary does
+85 -1
View File
@@ -37,7 +37,10 @@
"Viewport",
"ViewBorder",
"Light",
"Ole2Frame"
"Ole2Frame",
"Solid3D",
"Body",
"Region"
],
"manual_kinds": [
"Dimension"
@@ -807,6 +810,87 @@
"read_only": true
}
}
},
"Solid3D": {
"update_only": true,
"update_only_message": "Solid3D is built and edited by the geometry kernel: use doc.solids to create solids and move them; a script never writes the ACIS payload",
"fields": {
"acis_data": {
"exclude": true,
"_comment": "Opaque ACIS payload; snapshot-readable and never rewritten by a script."
},
"wires": {
"exclude": true,
"_comment": "Derived edge wires; regenerated by the host."
},
"silhouettes": {
"exclude": true,
"_comment": "Derived; regenerated by the host."
},
"uid": {
"read_only": true
},
"point_of_reference": {
"read_only": true
},
"history_handle": {
"read_only": true
}
}
},
"Body": {
"update_only": true,
"update_only_message": "Body is built and edited by the geometry kernel: use doc.solids to create solids and move them; a script never writes the ACIS payload",
"fields": {
"acis_data": {
"exclude": true,
"_comment": "Opaque ACIS payload; snapshot-readable and never rewritten by a script."
},
"wires": {
"exclude": true,
"_comment": "Derived edge wires; regenerated by the host."
},
"silhouettes": {
"exclude": true,
"_comment": "Derived; regenerated by the host."
},
"uid": {
"read_only": true
},
"point_of_reference": {
"read_only": true
},
"history_handle": {
"read_only": true
}
}
},
"Region": {
"update_only": true,
"update_only_message": "Region is built and edited by the geometry kernel: use doc.solids to create solids and move them; a script never writes the ACIS payload",
"fields": {
"acis_data": {
"exclude": true,
"_comment": "Opaque ACIS payload; snapshot-readable and never rewritten by a script."
},
"wires": {
"exclude": true,
"_comment": "Derived edge wires; regenerated by the host."
},
"silhouettes": {
"exclude": true,
"_comment": "Derived; regenerated by the host."
},
"uid": {
"read_only": true
},
"point_of_reference": {
"read_only": true
},
"history_handle": {
"read_only": true
}
}
}
}
}
@@ -84,10 +84,65 @@ class _Transaction:
return False
class _Solids:
"""Kernel-backed solid creation and rigid moves.
The host builds and edits the geometry; a script never sees the ACIS
payload. Every call is its own operation in the script's undo group and
fails, leaving the drawing unchanged, when the kernel cannot do it.
"""
def __init__(self, document):
self._document = document
def _create(self, primitive, values, layer):
handle = ocs.solid_create(primitive, [float(v) for v in values], layer)
return self._document.entities[handle]
def box(self, center=(0, 0, 0), size=(1, 1, 1), layer=None):
return self._create("box", tuple(center) + tuple(size), layer)
def wedge(self, origin=(0, 0, 0), size=(1, 1, 1), layer=None):
return self._create("wedge", tuple(origin) + tuple(size), layer)
def cylinder(self, center=(0, 0, 0), radius=1, height=1, layer=None):
return self._create("cylinder", tuple(center) + (radius, height), layer)
def sphere(self, center=(0, 0, 0), radius=1, layer=None):
return self._create("sphere", tuple(center) + (radius,), layer)
def torus(self, center=(0, 0, 0), major=2, minor=0.5, layer=None):
return self._create("torus", tuple(center) + (major, minor), layer)
def pyramid(self, center=(0, 0, 0), radius=1, height=1, sides=4, layer=None):
return self._create("pyramid", tuple(center) + (radius, height, sides), layer)
def transform(self, entity, matrix):
"""Apply a column-major 4x4 rigid transform (16 numbers) in place."""
handle = entity.handle if isinstance(entity, _Entity) else int(entity)
ocs.solid_transform(handle, [float(v) for v in matrix])
return self._document.entities[handle]
def translate(self, entity, offset):
dx, dy, dz = (float(v) for v in offset)
return self.transform(entity, [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, dx, dy, dz, 1])
def rotate(self, entity, axis, angle, about=(0, 0, 0)):
"""Turn about the world axis 'x', 'y' or 'z' through `about`."""
index = {"x": 0, "y": 1, "z": 2}[axis]
return self.transform(entity, ocs.rotation_matrix(index, float(angle), [float(v) for v in about]))
def mirror(self, entity, axis, about=(0, 0, 0)):
"""Reflect across the plane normal to the world axis through `about`."""
index = {"x": 0, "y": 1, "z": 2}[axis]
return self.transform(entity, ocs.mirror_matrix(index, [float(v) for v in about]))
class _Document:
def __init__(self):
self._pending = None
self.entities = _Entities(self)
self.solids = _Solids(self)
def transaction(self, label):
return _Transaction(self, label)
+109 -1
View File
@@ -411,6 +411,114 @@ mod ocs {
.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}")))
}
/// 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<()> {
@@ -4780,7 +4888,7 @@ mod tests {
);
}
}
assert_eq!(emitted.len(), 38);
assert_eq!(emitted.len(), 41);
}
#[cfg(feature = "experimental-host-model")]