Map Underlay in the Python host model; record DXF rotation blocker

Scripts place an underlay against an existing UnderlayDefinition; the host
checks that the definition exists and matches the underlay type.
staged_python_underlay_lifecycle_over_real_ipc passes every gate except DXF
persistence: acadrust's reader returns the degree-valued rotation
unconverted and repeated saves compound it. Pinned by canary expectations.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
Felix RiestraandClaude Sonnet 5 committed 2026-09-20 15:05:11 +02:00
1 parent 96e3b54f08
commit 2e4d77dc09
10 files changed
+170 -12

No files matched your search

+1
View File
@@ -793,6 +793,7 @@ fn generate_entity_coverage(out_dir: &Path, registry: &TypeRegistry) {
| ("Helix", _)
| ("RasterImage", _)
| ("Wipeout", _)
| ("Underlay", _)
| (
"MLine",
"flags"
@@ -416,6 +416,22 @@
"clip_mode",
"clip_type",
"clip_boundary_vertices"
],
"Underlay": [
"layer",
"underlay_type",
"definition_handle",
"insertion_point",
"x_scale",
"y_scale",
"z_scale",
"rotation",
"normal",
"flags",
"contrast",
"fade",
"clip_boundary_vertices",
"clip_inverted"
]
},
"readable": {
@@ -874,6 +874,51 @@ fn validate_wipeout(
Ok(())
}
/// Underlay placement and display. The file and page live on the referenced
/// `UnderlayDefinition`, which a script names by handle at creation.
#[cfg(feature = "host")]
fn validate_underlay(
old: Option<&crate::host::acadrust::entities::Underlay>,
new: &crate::host::acadrust::entities::Underlay,
) -> Result<(), String> {
if old == Some(new) {
return Ok(());
}
if let Some(old) = old {
if old.definition_handle != new.definition_handle {
return Err("Underlay.definition_handle is fixed at creation".into());
}
if old.underlay_type != new.underlay_type {
return Err("Underlay.underlay_type is fixed at creation".into());
}
}
finite_vector("Underlay.insertion_point", &new.insertion_point)?;
solid_normal("Underlay.normal", &new.normal)?;
for (name, value) in [("x_scale", new.x_scale), ("y_scale", new.y_scale), ("z_scale", new.z_scale)] {
if !value.is_finite() || value == 0.0 {
return Err(format!("Underlay.{name} must be finite and nonzero"));
}
}
if !new.rotation.is_finite() {
return Err("Underlay.rotation must be finite".into());
}
if new.contrast > 100 || new.fade > 100 {
return Err("Underlay contrast and fade must be within 0..=100".into());
}
if new.flags.bits() & !0x0f != 0 {
return Err("Underlay.flags has unknown bits".into());
}
for (index, vertex) in new.clip_boundary_vertices.iter().enumerate() {
if !vertex.x.is_finite() || !vertex.y.is_finite() {
return Err(format!("Underlay.clip_boundary_vertices[{index}] must be finite"));
}
}
if !new.clip_boundary_vertices.is_empty() && new.clip_boundary_vertices.len() < 2 {
return Err("Underlay clip boundary needs at least 2 vertices".into());
}
Ok(())
}
/// Validate newly created geometry for the kinds whose mapped fields have
/// host checks. Called by the Python add path before an entity enters the document.
#[cfg(feature = "host")]
@@ -976,6 +1021,7 @@ pub fn validate_new_canvas_entity(entity: &crate::host::EntityType) -> Result<()
EntityType::Helix(value) => validate_helix(None, value)?,
EntityType::RasterImage(value) => validate_raster_image(None, value)?,
EntityType::Wipeout(value) => validate_wipeout(None, value)?,
EntityType::Underlay(value) => validate_underlay(None, value)?,
EntityType::AttributeDefinition(value) => {
finite_vector(
"AttributeDefinition.insertion_point",
@@ -1339,6 +1385,7 @@ pub fn validate_entity_mutation(
(EntityType::Helix(old), EntityType::Helix(new)) => validate_helix(Some(old), new)?,
(EntityType::RasterImage(old), EntityType::RasterImage(new)) => validate_raster_image(Some(old), new)?,
(EntityType::Wipeout(old), EntityType::Wipeout(new)) => validate_wipeout(Some(old), new)?,
(EntityType::Underlay(old), EntityType::Underlay(new)) => validate_underlay(Some(old), new)?,
(EntityType::MLine(old), EntityType::MLine(new)) => validate_mline(Some(old), new)?,
(EntityType::Leader(old), EntityType::Leader(new)) => validate_leader(Some(old), new)?,
(EntityType::AttributeDefinition(old), EntityType::AttributeDefinition(new)) => {
@@ -1602,6 +1649,24 @@ pub fn validate_canvas_entity_references(
return Err(format!("Shape text style {:?} has no SHX file", style.name));
}
}
if let EntityType::Underlay(value) = entity {
match document.objects.get(&value.definition_handle) {
Some(crate::host::acadrust::objects::ObjectType::UnderlayDefinition(definition)) => {
if definition.underlay_type != value.underlay_type {
return Err(format!(
"Underlay type {:?} does not match its definition ({:?})",
value.underlay_type, definition.underlay_type
));
}
}
_ => {
return Err(format!(
"Underlay definition {:?} does not exist",
value.definition_handle
));
}
}
}
if let EntityType::RasterImage(value) = entity {
if let Some(handle) = value.definition_handle.filter(|handle| !handle.is_null()) {
if !matches!(
@@ -2029,6 +2094,7 @@ mod tests {
| "Helix"
| "RasterImage"
| "Wipeout"
| "Underlay"
)
{
assert!(
@@ -2314,6 +2380,19 @@ mod tests {
"Wipeout.clip_mode".to_owned(),
"Wipeout.clip_type".to_owned(),
"Wipeout.clip_boundary_vertices".to_owned(),
"Underlay.underlay_type".to_owned(),
"Underlay.definition_handle".to_owned(),
"Underlay.insertion_point".to_owned(),
"Underlay.x_scale".to_owned(),
"Underlay.y_scale".to_owned(),
"Underlay.z_scale".to_owned(),
"Underlay.rotation".to_owned(),
"Underlay.normal".to_owned(),
"Underlay.flags".to_owned(),
"Underlay.contrast".to_owned(),
"Underlay.fade".to_owned(),
"Underlay.clip_boundary_vertices".to_owned(),
"Underlay.clip_inverted".to_owned(),
])
);
}
+5 -4
View File
@@ -7,13 +7,13 @@ Build a general Python document model over the OCS host API for all **43 canvas
As of 20 September 2026, the working branch is `plugin/host-model-api` at
`a8dd3640` plus the Leader increment, published as draft PR
[#1391](https://github.com/HakanSeven12/OpenCADStudio/pull/1391). Host API is
v7. The Python feature has explicit writes for **33 of 43** canvas kinds: 32
are creatable and `Insert` remains update-only. This is **not** 33 completed
v7. The Python feature has explicit writes for **34 of 43** canvas kinds: 33
are creatable and `Insert` remains update-only. This is **not** 34 completed
kinds: Tolerance, Shape, AttributeEntity, Hatch, MLine, Dimension, MultiLeader, Table, PolygonMesh, PolyfaceMesh, Mesh and Wipeout pass every completion gate.
AttributeDefinition passes its host and real IPC lifecycle but remains short of
`Complete` because the previously pinned CAD codec dropped optional ATTDEF
fields during DXF reads; revalidate that blocker against the current
`acadrust` revision `7ea4247`. Leader is mapped and integration-tested but blocked at W by three acadrust persistence gaps (see the ledger). Leader and Helix are mapped and integration-tested but blocked at W by acadrust persistence gaps (see the ledger). RasterImage is mapped and integration-tested, held short of `Complete` by unverifiable image-dictionary linkage. The other 10 canvas kinds have only `layer`
`acadrust` revision `7ea4247`. Leader is mapped and integration-tested but blocked at W by three acadrust persistence gaps (see the ledger). Leader, Helix and Underlay are mapped and integration-tested but blocked at W by acadrust persistence gaps (see the ledger). RasterImage is mapped and integration-tested, held short of `Complete` by unverifiable image-dictionary linkage. The other 9 canvas kinds have only `layer`
writes through the document model. All 43 have raw typed snapshots. There are
three internal records and two opaque fallbacks outside the 43 canvas kinds.
@@ -123,7 +123,7 @@ The last five may require new engine capabilities rather than only Python conver
## Handoff start point
Continue with **Underlay** (queue item 12). Leader is done except for its W
Continue with **Viewport** (queue item 13). Leader is done except for its W
gate; when the engine writes DXF group 340, reads group 213 and the DWG
R2010+ text-size question is decided, flip the canary assertions in
`staged_python_leader_lifecycle_over_real_ipc` and mark it `Complete`.
@@ -166,3 +166,4 @@ exact queue above and update statuses with evidence after each increment.
- Mapped `Helix` with a host-rebuilt spline. `run_mesh_lifecycle` gained per-format DXF expectations so an engine gap is pinned by a canary instead of hidden. All gates pass except W for `handedness` in DXF (reader ignores boolean group 290). The queue now continues with `RasterImage`.
- Mapped `RasterImage`. The host reads the image file, creates and links the `ImageDefinition`, and derives `size` and the default clip boundary. The shared lifecycle driver passes every gate in OCS with a real PNG; `ACAD_IMAGE_DICT`/reactor creation is not implemented, so the kind stays integration-tested. The queue now continues with `Wipeout`.
- Completed `Wipeout` (queue item 11) with rectangular and polygonal clip masks through the shared lifecycle driver; scripted creation matches OCS's native command. The queue now continues with `Underlay`.
- Mapped `Underlay` against a fixture PDF `UnderlayDefinition`. All gates pass except DXF W: the reader does not convert the degree-valued rotation back to radians, and repeated DXF round trips compound the error (pinned by a canary). The queue now continues with `Viewport`.
+2 -2
View File
@@ -1,6 +1,6 @@
# OCS canvas kind coverage ledger
Use with [the completion plan](plugin-host-model-completion-plan.md). This ledger is current as of 20 September 2026: **33 of 43 canvas kinds have explicit geometry writes** (32 creatable, plus update-only `Insert`), **12 kinds are complete**, and 10 remain layer-only. `Mapped` means at least one non-layer field is exposed for Python writes; it does **not** mean the kind passes the completion gate. `Update-only` means Python creation is unavailable. `Layer-only` means raw inspection and canvas layer edits exist, but geometry is not mapped. Record actual evidence or a blocking issue before changing any status to `Complete`.
Use with [the completion plan](plugin-host-model-completion-plan.md). This ledger is current as of 20 September 2026: **34 of 43 canvas kinds have explicit geometry writes** (33 creatable, plus update-only `Insert`), **12 kinds are complete**, and 9 remain layer-only. `Mapped` means at least one non-layer field is exposed for Python writes; it does **not** mean the kind passes the completion gate. `Update-only` means Python creation is unavailable. `Layer-only` means raw inspection and canvas layer edits exist, but geometry is not mapped. Record actual evidence or a blocking issue before changing any status to `Complete`.
Completion gate codes: **C** = create through Python document model; **R** = read; **E** = edit defining geometry and another property; **D** = delete; **U** = undo/redo and atomic failure; **I** = real IPC plus GUI/canvas pick or observable behavior; **W** = DWG and DXF save/reopen; **V** = invalid-input checks and untouched-field/reference preservation; **P** = build and load the in-tree bundled plugin with a manifest matching the host toolchain and acadrust source. A kind is `Complete` only when **C/R/E/D/U/I/W/V/P** all pass and its property catalog matches implementation. `Blocked` requires a specific dependency or failing fixture.
@@ -39,7 +39,7 @@ Completion gate codes: **C** = create through Python document model; **R** = rea
| Helix | **Integration-tested / blocked at W by acadrust (DXF handedness)** | Writable generating parameters: `axis_base_point`, `start_point`, `axis_vector`, `radius` (the top radius; the base radius is the start point's distance from the axis), `turns`, `turn_height`, `handedness`, `constraint`. Read-only: `spline`, `major_version`, `maintenance_version`. The host rebuilds the derived NURBS spline with OCS's own helix builder on every scripted create or parameter edit (`normalize_scripted_helix`), so the spline always matches the parameters. Host validation requires finite positive radius, turns and turn height, a nonzero axis and a start point off the axis. `staged_python_helix_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`) passes C/R/E/D/U/I/V/P: creation with a derived spline ending at z = turns × turn height, a turns and handedness edit that regenerates it, canvas wires, seven rejection cases plus an atomic rollback, DWG reopen and re-edit, DXF reopen and re-edit, and three-step undo/redo. **W blocker (engine, pinned by a canary assertion):** the DXF reader parses boolean group 290 as an `i16` and never applies it, so a clockwise helix reopens counter-clockwise from DXF while DWG keeps it. Not `Complete` until the reader is fixed. |
| RasterImage | **Integration-tested** | Writable: `file_path` (creation only; a later change is rejected because the image definition owns it), `insertion_point`, `u_vector`, `v_vector` (world units per pixel), `flags`, `clipping_enabled`, `brightness`, `contrast`, `fade`, `clip_boundary`. Read-only: `size` (pixel size, taken from the file), `class_version`, `definition_handle`, `definition_reactor_handle`. Creating from a script requires a readable image file: the host reads its pixel size, creates the `ImageDefinition` object, links it, and sets `size` and a whole-image clip boundary from the file. Host validation requires finite, non-parallel `u`/`v` vectors, brightness/contrast/fade within 0..=100, known display flags, finite clip vertices, at least 2 clip vertices when clipping is enabled, and an existing definition. `staged_python_raster_image_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`, real PNG fixture) passes C/R/E/D/U/I/W/V/P inside OCS: creation with a host-made definition, placement/scale/brightness/contrast/fade and clip edit in one transaction, canvas wires, eight rejection cases plus an atomic rollback, DWG and DXF reopen with the definition link and file path intact, re-edit, and three-step undo/redo. **Why not `Complete`:** the host does not create an `ACAD_IMAGE_DICT` entry or `IMAGEDEF_REACTOR` for a scripted image, so other CAD applications may need to repair the linkage; that cannot be verified here. Editing an existing definition (file path, resolution) is out of scope. |
| Wipeout | **Complete** | Writable: `insertion_point`, `u_vector`, `v_vector` (the region's world extent), `size`, `flags`, `clipping_enabled`, `brightness`, `contrast`, `fade`, `clip_mode`, `clip_type` and `clip_boundary_vertices` (normalized image coordinates, -0.5..0.5). Read-only: `class_version`, `definition_handle`, `definition_reactor_handle`. Scripted creation matches OCS's native WIPEOUT command, which also leaves the definition link unset. Host validation requires finite non-parallel `u`/`v` vectors, a positive `size`, brightness/contrast/fade within 0..=100, known flags, finite clip vertices, exactly 2 vertices for a rectangular clip and at least 3 for a polygonal one. `staged_python_wipeout_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`) passes C/R/E/D/U/I/W/V/P: rectangular creation, a switch to a three-vertex polygonal inside-clip with a move in one transaction, canvas wires, eight rejection cases plus an atomic rollback, DWG and DXF reopen with re-edit, and three-step undo/redo. |
| Underlay | Layer-only | Requires external definition/file fixture. |
| Underlay | **Integration-tested / blocked at W by acadrust (DXF rotation units)** | Writable: `underlay_type` and `definition_handle` (both fixed by the definition: creation names an existing `UnderlayDefinition`, and a mismatched type or missing definition is rejected), `insertion_point`, `x_scale`, `y_scale`, `z_scale`, `rotation`, `normal`, `flags`, `contrast`, `fade`, `clip_boundary_vertices`, `clip_inverted`. The file path and page live on the definition object, which scripts cannot create or list yet; they reference one the drawing already owns. Host validation requires finite nonzero scales, a finite rotation, a nonzero normal, contrast/fade within 0..=100, known flags, finite clip vertices (none, or at least 2) and an existing definition of the same type. `staged_python_underlay_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle` with a fixture PDF definition) passes C/R/E/D/U/I/V/P and DWG W: creation, a placement/scale/rotation/contrast/fade/clip transaction, canvas wires, eight rejection cases plus an atomic rollback, DWG reopen and re-edit, and three-step undo/redo. **W blocker (engine, pinned by canary assertions):** DXF stores the rotation in degrees but the reader returns it unconverted, so 0.5 rad reopens as 28.6 and each further DXF save-reopen compounds it (1641.4 after a second), silently corrupting the rotation. Not `Complete` until the reader converts degrees back to radians. Not covered: creating definitions from a file path, DWF/DGN fixtures. |
| Viewport | Layer-only | Requires layout and view-state fixture. |
| ViewBorder | Layer-only | Requires linked view records. |
| SectionSymbol | Layer-only | Requires section-view style and point records. |
+2 -2
View File
@@ -35,7 +35,7 @@ attached attribute and sequence records. Those fields report `transaction_geomet
catalog. All 43 canvas kinds also support nonempty `layer` changes through
the same undoable transaction; internal and opaque variants remain read-only
through the document model. The adapter accepts only `layer` patches for
canvas kinds outside its 33 geometry converters. Other writable properties
canvas kinds outside its 34 geometry converters. Other writable properties
still report `type_conversion_only`; broader CAD range and cross-property
checks remain outstanding.
The host's `entity_snapshot` helper serializes any typed entity as a detached
@@ -91,7 +91,7 @@ Input tokens are bound to the drawing tab that requested the pick. Polling a
token from another tab returns no result and does not consume it.
Python `doc.entities[handle]` returns a descriptor for every entity. For kinds
outside the 33-kind generated schema it contains only handle, kind, and layer;
outside the 34-kind generated schema it contains only handle, kind, and layer;
`layer` is editable on canvas kinds. `doc.coverage()` enumerates the
catalog; `doc.coverage("Line")` and `line.coverage` return one entry. For
covered kinds, the coverage object lists actual readable and editable
+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 33 mapped kinds.
the 34 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
+8 -1
View File
@@ -32,7 +32,8 @@
"Mesh",
"Helix",
"RasterImage",
"Wipeout"
"Wipeout",
"Underlay"
],
"manual_kinds": ["Dimension"],
"base_fields": ["handle", "layer", "owner_handle"],
@@ -360,6 +361,12 @@
"definition_handle": { "read_only": true },
"definition_reactor_handle": { "read_only": true }
}
},
"Underlay": {
"fields": {
"definition_handle": { "required": true },
"insertion_point": { "required": true }
}
}
}
}
+1 -1
View File
@@ -4756,7 +4756,7 @@ mod tests {
);
}
}
assert_eq!(emitted.len(), 33);
assert_eq!(emitted.len(), 34);
}
#[cfg(feature = "experimental-host-model")]
+55 -1
View File
@@ -3194,7 +3194,16 @@ mod tests {
};
let tmp = std::env::temp_dir().to_string_lossy().into_owned();
run_script(&mut host, "create", &case.create.replace("TMPDIR", &tmp));
let mut create_script = case.create.replace("TMPDIR", &tmp);
if create_script.contains("DEFHANDLE") {
// Scripts reference an underlay definition that the drawing owns.
let handle = host.document_mut().allocate_handle();
let mut definition = acadrust::objects::UnderlayDefinition::pdf("plan.pdf", "1");
definition.handle = handle;
host.document_mut().objects.insert(handle, acadrust::objects::ObjectType::UnderlayDefinition(definition));
create_script = create_script.replace("DEFHANDLE", &handle.value().to_string());
}
run_script(&mut host, "create", &create_script);
let handle = host.document().entities().find(|entity| (case.is_kind)(entity))
.map(|entity| entity.common().handle)
.unwrap_or_else(|| panic!("Python did not create the mesh: {}", last_output(&host)));
@@ -3532,6 +3541,51 @@ mod tests {
});
}
#[test]
fn staged_python_underlay_lifecycle_over_real_ipc() {
run_mesh_lifecycle(&MeshCase {
create: concat!(
"doc.create_entity('Underlay', underlay_type='Pdf', definition_handle=DEFHANDLE,\n",
" insertion_point=P(5.0, 5.0, 0.0), x_scale=2.0, y_scale=2.0)\n"),
edit: concat!(
"u = doc.entities[HANDLE]\n",
"with doc.transaction('Edit underlay'):\n",
" u.insertion_point = (8.0, 9.0, 0.0)\n",
" u.x_scale = 3.0\n",
" u.y_scale = 3.0\n",
" u.rotation = 0.5\n",
" u.contrast = 70\n",
" u.fade = 20\n",
" u.clip_boundary_vertices = [{'x': 0.0, 'y': 0.0}, {'x': 4.0, 'y': 3.0}]\n",
"doc.selection = [u]\n"),
rejects: &[
("'definition_handle':999999", "does not exist"),
("'underlay_type':'Dwf'", "does not match its definition"),
("'x_scale':0.0", "nonzero"),
("'rotation':float('nan')", "finite"),
("'contrast':101", "within 0..=100"),
("'normal':{'x':0.0,'y':0.0,'z':0.0}", "nonzero"),
("'clip_boundary_vertices':[{'x':1.0,'y':1.0}]", "at least 2"),
("'flags':64", "unknown or out-of-range bits"),
],
is_kind: |entity| matches!(entity, EntityType::Underlay(_)),
digest: |entity| match entity {
EntityType::Underlay(u) => format!("{:?} at{:.1},{:.1} s{:.1} r{:.1} c{} f{} clip{}", u.underlay_type,
u.insertion_point.x, u.insertion_point.y, u.x_scale, u.rotation, u.contrast, u.fade,
u.clip_boundary_vertices.len()),
_ => "wrong kind".into(),
},
reedit: |entity| if let EntityType::Underlay(u) = entity { u.x_scale = 4.0; },
expect_created: "Pdf at5.0,5.0 s2.0 r0.0 c100 f0 clip0",
expect_edited: "Pdf at8.0,9.0 s3.0 r0.5 c70 f20 clip2",
expect_reedited: "Pdf at8.0,9.0 s4.0 r0.5 c70 f20 clip2",
// BLOCKER: DXF stores the rotation in degrees and the reader returns
// it unconverted, so 0.5 rad reopens as 28.6 and a second save-reopen compounds it to 1641.4.
expect_edited_dxf: "Pdf at8.0,9.0 s3.0 r28.6 c70 f20 clip2",
expect_reedited_dxf: "Pdf at8.0,9.0 s4.0 r1641.4 c70 f20 clip2",
});
}
#[test]
fn staged_python_point_pick_and_cancel_over_real_ipc() {
let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else {