forked from 1b-agent/opencadstudio-upstream
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:
1 parent
22f9c20fe8
commit
b3f5388208
14 files changed
+523
-31
No files matched your search
@@ -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
|
||||
|
||||
@@ -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)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)?; }"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user