Complete MultiLeader host model coverage

Extends the adapter generator (Color, LineWeight, mixed unit/payload enums,
fixed float arrays, bitflags-2 newtypes, keep_types override) and maps
MultiLeader with a merge-style context patch, host validation and
reference checks. staged_python_multileader_lifecycle_over_real_ipc passes
the full gate including DWG and DXF reopen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Felix RiestraandClaude Sonnet 5 committed 2026-09-20 14:27:43 +02:00
1 parent 22f9c20fe8
commit b3f5388208
14 files changed
+523 -31

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 25 mapped kinds.
the 26 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
+83 -19
View File
@@ -89,6 +89,11 @@ pub struct FieldOverride {
/// already-valid entity never needs it re-specified).
#[serde(default)]
pub required: bool,
/// Still generate this field type's converters although a
/// `rust_getter`/`rust_setter` override replaces the generic path (the
/// override's hand-written code calls them).
#[serde(default)]
pub keep_types: bool,
}
impl Manifest {
@@ -141,7 +146,7 @@ fn classify<'a>(name: &str, info: &'a TypeInfo) -> Classify {
}
TypeKind::Enum => {
if info.variants.iter().any(|v| !v.fields.is_empty()) {
if info.variants.iter().all(|v| v.fields.len() == 1) {
if info.variants.iter().all(|v| v.fields.len() <= 1) {
Classify::TaggedEnum
} else {
panic!("entity_manifest.json: {name} has a tagged variant without exactly one payload");
@@ -150,6 +155,9 @@ fn classify<'a>(name: &str, info: &'a TypeInfo) -> Classify {
Classify::UnitEnum
}
}
// acadrust `bitflags!` types serialize as a newtype over their integer
// bits; they convert exactly like the hand-written `bits` structs.
TypeKind::Newtype => Classify::BitFlags,
other => panic!("entity_manifest.json: {name} has unsupported registry kind {other:?}"),
}
}
@@ -181,7 +189,7 @@ fn helper_closure(manifest: &Manifest, registry: &TypeRegistry) -> Vec<String> {
}
if override_def
.and_then(|o| o.fields.get(&f.name))
.map(|o| o.exclude || o.rust_getter.is_some())
.map(|o| o.exclude || (o.rust_getter.is_some() && !o.keep_types))
.unwrap_or(false)
{
// Excluded, or fully hand-supplied by the override — its
@@ -193,7 +201,10 @@ fn helper_closure(manifest: &Manifest, registry: &TypeRegistry) -> Vec<String> {
}
while let Some(name) = queue.pop() {
if BUILTIN.contains(&name.as_str()) || SPECIAL.contains(&name.as_str()) {
if BUILTIN.contains(&name.as_str())
|| SPECIAL.contains(&name.as_str())
|| f64_array_len(&name).is_some()
{
continue;
}
if !needed.insert(name.clone()) {
@@ -276,11 +287,32 @@ fn native_rust_type(type_id: &str) -> &'static str {
}
}
/// Rust path of a traced type; most live in `acadrust::entities`.
fn type_path(name: &str) -> String {
match name {
"LineWeight" => "acadrust::types::LineWeight".to_owned(),
"LeaderLineBreakInfo" => "acadrust::entities::multileader::LeaderLineBreakInfo".to_owned(),
_ => format!("acadrust::entities::{name}"),
}
}
fn is_native(type_id: &str) -> bool {
BUILTIN.contains(&type_id)
}
/// `[f64; N]` fixed arrays (e.g. a 4x4 transform) convert as flat float lists.
fn f64_array_len(type_id: &str) -> Option<usize> {
type_id
.strip_prefix("[f64; ")?
.strip_suffix(']')?
.parse()
.ok()
}
fn default_expr(type_id: &str) -> String {
if let Some(n) = f64_array_len(type_id) {
return format!("[0.0f64; {n}]");
}
match type_id {
"f64" | "f32" => "0.0".to_string(),
"i8" | "i16" | "i32" | "i64" | "u8" | "u16" | "u32" | "u64" => "0".to_string(),
@@ -302,6 +334,11 @@ fn leaf_to_py(registry: &TypeRegistry, type_id: &str, item_expr: &str) -> String
// call or cast directly on `item_expr` (`item_expr.method()`,
// `item_expr as T`) does, since `&` binds looser than both.
let parenthesized = format!("({item_expr})");
if f64_array_len(type_id).is_some() {
return format!(
"Ok(vm.ctx.new_list(({item_expr}).iter().map(|item| vm.new_pyobj(*item)).collect()).into())"
);
}
match type_id {
"f64" | "f32" | "i8" | "i16" | "i32" | "i64" | "u8" | "u16" | "u32" | "u64" | "bool" => {
format!("Ok(vm.new_pyobj(*{parenthesized}))")
@@ -331,6 +368,9 @@ fn leaf_from_py(registry: &TypeRegistry, type_id: &str, value_expr: &str) -> Str
if is_native(type_id) {
return format!("{value_expr}.try_into_value::<{}>(vm)", native_rust_type(type_id));
}
if let Some(n) = f64_array_len(type_id) {
return format!("py_to_f64_array::<{n}>({value_expr}, vm)");
}
match type_id {
"Vector3" => format!("py_to_vector3_dict({value_expr}, vm)"),
"Vector2" => format!("py_to_vector2({value_expr}, vm)"),
@@ -346,11 +386,18 @@ fn leaf_from_py(registry: &TypeRegistry, type_id: &str, value_expr: &str) -> Str
),
Classify::TaggedEnum => format!("dict_to_{}({value_expr}, vm)", snake_case(other)),
Classify::BitFlags => {
let path = format!("acadrust::entities::{other}");
let path = type_path(other);
let bits_ty = bits_field_type(info);
format!(
"{value_expr}.try_into_value::<i64>(vm).and_then(|n| <{bits_ty}>::try_from(n).map({path}::from_bits).map_err(|_| vm.new_value_error(\"{other} bits out of range\".to_owned())))"
)
if matches!(info.kind, TypeKind::Newtype) {
// bitflags 2: `from_bits` is fallible and rejects unknown bits.
format!(
"{value_expr}.try_into_value::<i64>(vm).and_then(|n| <{bits_ty}>::try_from(n).ok().and_then({path}::from_bits).ok_or_else(|| vm.new_value_error(\"{other} has unknown or out-of-range bits\".to_owned())))"
)
} else {
format!(
"{value_expr}.try_into_value::<i64>(vm).and_then(|n| <{bits_ty}>::try_from(n).map({path}::from_bits).map_err(|_| vm.new_value_error(\"{other} bits out of range\".to_owned())))"
)
}
}
Classify::Struct => format!("dict_to_{}({value_expr}, vm)", snake_case(other)),
}
@@ -371,6 +418,8 @@ fn getter_for_field(
is_sequence: bool,
field_place: &str,
) -> String {
// The registry reports a fixed array as a sequence of itself.
let is_sequence = is_sequence && f64_array_len(type_id).is_none();
if is_sequence {
let inner = leaf_to_py(registry, type_id, "item");
format!(
@@ -398,6 +447,7 @@ fn setter_for_field(
py_name: &str,
keep_expr: &str,
) -> String {
let is_sequence = is_sequence && f64_array_len(type_id).is_none();
if is_sequence {
let inner = leaf_from_py(registry, type_id, "item");
format!(
@@ -417,7 +467,7 @@ fn setter_for_field(
}
fn field_default_expr(f: &FieldInfo) -> String {
if f.is_sequence {
if f.is_sequence && f64_array_len(f.type_id.as_str()).is_none() {
"Vec::new()".to_string()
} else if f.optional {
"None".to_string()
@@ -451,7 +501,7 @@ fn wrap_required_nonempty(setter_expr: &str, entity_kind: &str, py_name: &str) -
// ════════════════════════════════════════════════════════════════════════════
fn gen_unit_enum(name: &str, info: &TypeInfo) -> String {
let path = format!("acadrust::entities::{name}");
let path = type_path(name);
let snake = snake_case(name);
let mut name_arms = String::new();
let mut parse_arms = String::new();
@@ -486,13 +536,14 @@ fn default_{snake}() -> {path} {{
)
}
fn gen_bitflags(name: &str) -> String {
let path = format!("acadrust::entities::{name}");
fn gen_bitflags(name: &str, info: &TypeInfo) -> String {
let path = type_path(name);
let snake = snake_case(name);
let ctor = if matches!(info.kind, TypeKind::Newtype) { "empty" } else { "new" };
format!(
r#"#[allow(dead_code)]
fn default_{snake}() -> {path} {{
{path}::new()
{path}::{ctor}()
}}
"#
@@ -500,11 +551,22 @@ fn default_{snake}() -> {path} {{
}
fn gen_tagged_enum(name: &str, info: &TypeInfo, registry: &TypeRegistry) -> String {
let path = format!("acadrust::entities::{name}");
let path = type_path(name);
let snake = snake_case(name);
let mut to_arms = String::new();
let mut from_arms = String::new();
for variant in &info.variants {
if variant.fields.is_empty() {
to_arms.push_str(&format!(
" {path}::{variant} => {{ let dict = vm.ctx.new_dict(); dict.set_item(\"kind\", vm.new_pyobj(\"{variant}\"), vm)?; Ok(dict.into()) }}\n",
variant = variant.name,
));
from_arms.push_str(&format!(
" \"{variant}\" => Ok({path}::{variant}),\n",
variant = variant.name,
));
continue;
}
let payload = &variant.fields[0];
let getter = leaf_to_py(registry, payload.type_id.as_str(), "value");
let setter = leaf_from_py(registry, payload.type_id.as_str(), "payload");
@@ -518,7 +580,10 @@ fn gen_tagged_enum(name: &str, info: &TypeInfo, registry: &TypeRegistry) -> Stri
));
}
let first = &info.variants[0];
let default_payload = default_expr(first.fields[0].type_id.as_str());
let default_value = match first.fields.first() {
None => format!("{path}::{}", first.name),
Some(payload) => format!("{path}::{}({})", first.name, default_expr(payload.type_id.as_str())),
};
format!(
r#"fn {snake}_to_dict(vm: &VirtualMachine, value: &{path}) -> PyResult<PyObjectRef> {{
match value {{
@@ -535,16 +600,15 @@ fn dict_to_{snake}(value: PyObjectRef, vm: &VirtualMachine) -> PyResult<{path}>
#[allow(dead_code)]
fn default_{snake}() -> {path} {{
{path}::{first_variant}({default_payload})
{default_value}
}}
"#,
first_variant = first.name,
"#
)
}
fn gen_struct(name: &str, info: &TypeInfo, registry: &TypeRegistry) -> String {
let path = format!("acadrust::entities::{name}");
let path = type_path(name);
let snake = snake_case(name);
let mut to_dict_body = String::new();
let mut from_dict_fields = String::new();
@@ -910,7 +974,7 @@ pub fn generate_entity_crud(manifest: &Manifest, registry: &TypeRegistry) -> Str
match classify(&name, info) {
Classify::UnitEnum => out.push_str(&gen_unit_enum(&name, info)),
Classify::TaggedEnum => out.push_str(&gen_tagged_enum(&name, info, registry)),
Classify::BitFlags => out.push_str(&gen_bitflags(&name)),
Classify::BitFlags => out.push_str(&gen_bitflags(&name, info)),
Classify::Struct => out.push_str(&gen_struct(&name, info, registry)),
}
}
+17 -1
View File
@@ -24,7 +24,8 @@
"Hatch",
"Leader",
"MLine",
"Dimension"
"Dimension",
"MultiLeader"
],
"manual_kinds": ["Dimension"],
"base_fields": ["handle", "layer", "owner_handle"],
@@ -264,6 +265,21 @@
"dwg_unknown_bit5": { "exclude": true },
"dwg_unknown_short1": { "exclude": true }
}
},
"MultiLeader": {
"fields": {
"arrowhead_overrides": { "exclude": true, "_comment": "Per-arrow override records carry linked block handles; preserved untouched until dedicated validation exists." },
"block_attributes": { "exclude": true, "_comment": "Block attribute records carry attribute-definition handles; preserved untouched." },
"dwg_version": { "read_only": true },
"text_height": { "read_only": true, "_comment": "Neither writer stores the entity-level text height; scripts set the persisted context.text_height." },
"property_override_flags": { "read_only": true },
"text_align_in_ipe": { "read_only": true },
"context": {
"keep_types": true,
"rust_getter": "multi_leader_annot_context_to_dict(vm, &VAR.context)?",
"rust_setter": "if let Some(v) = dict.get_item_opt(\"context\", vm)? { VAR.context = merge_multileader_context(&VAR.context, v, vm)?; }"
}
}
}
}
}
+14 -1
View File
@@ -3188,6 +3188,18 @@ mod ocs {
})
}
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())
@@ -3249,6 +3261,7 @@ mod ocs {
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`.
@@ -4743,7 +4756,7 @@ mod tests {
);
}
}
assert_eq!(emitted.len(), 25);
assert_eq!(emitted.len(), 26);
}
#[cfg(feature = "experimental-host-model")]
@@ -0,0 +1,23 @@
// Merge helpers for nested structs that scripts edit as a partial patch.
/// Apply `patch` (a dict of context fields) onto `existing`, so a script can
/// change one context field without resetting the rest. Unknown keys error.
fn merge_multileader_context(
existing: &acadrust::entities::MultiLeaderAnnotContext,
patch: PyObjectRef,
vm: &VirtualMachine,
) -> PyResult<acadrust::entities::MultiLeaderAnnotContext> {
let patch = patch.try_into_value::<rustpython_vm::builtins::PyDictRef>(vm)?;
let merged = multi_leader_annot_context_to_dict(vm, existing)?
.try_into_value::<rustpython_vm::builtins::PyDictRef>(vm)?;
for key in patch.keys_vec() {
let name = key.clone().try_into_value::<String>(vm)?;
if merged.get_item_opt(name.as_str(), vm)?.is_none() {
return Err(vm.new_value_error(format!(
"ocs: MultiLeader.context property {name:?} is outside the editable schema"
)));
}
merged.set_item(name.as_str(), patch.get_item(name.as_str(), vm)?, vm)?;
}
dict_to_multi_leader_annot_context(merged.into(), vm)
}