forked from 1b-agent/opencadstudio-upstream
The DWG writer truncates an XRecord Chunk to u8::MAX bytes, so a sketch's constraint set or named-parameter table silently lost every entry on the next DWG open once serialization passed ~255 bytes (found live: a 23-constraint demo round-tripped fine through DXF but came back empty through DWG). Split the blob across multiple same-code 310 entries on save and reassemble them on load, entirely on the OCS side. Co-Authored-By: Claude Sonnet 5 <[email protected]>
215 lines
10 KiB
Rust
215 lines
10 KiB
Rust
//! Save/load integration for [`super::named_parameters::ParameterTable`] —
|
|
//! design doc §4 stage 2 (the XRecord persistence spike) plus enough of
|
|
//! stage 2's "wire it into open/save" to actually be useful, following
|
|
//! [`super::sketch_persist`]'s exact precedent (itself the sketch-constraint
|
|
//! project's stage 4/§5.2): the "lazy" model, where the table lives only in
|
|
//! `Scene::named_parameters` in-memory during editing and is materialized
|
|
//! into `document.objects` as one XRecord right before a save, then read
|
|
//! back right after a load.
|
|
//!
|
|
//! The one structural difference from `sketch_persist`: a
|
|
//! [`super::named_parameters::ParameterTable`] is document-wide, not
|
|
//! per-[`super::sketch_constraints::SketchScope`], so there is exactly one
|
|
//! XRecord for the whole document rather than one per scope owner.
|
|
//!
|
|
//! **Owner handle, and a real round-trip gap found while building this**:
|
|
//! the obvious choice was `document.header.named_objects_dict_handle` (the
|
|
//! document's own root Named Objects Dictionary) — `extension_dictionary_
|
|
//! handle` in the vendored `cadcodec` source explicitly resolves an
|
|
//! extension dictionary for a `Dictionary`-type owner (`ObjectType::
|
|
//! Dictionary(value) => value.xdictionary_handle`), and that crate's own
|
|
//! tests (e.g. `tests/issue51.rs`) use `named_objects_dict_handle` as an
|
|
//! XRecord owner directly. In practice this round-tripped through DWG but
|
|
//! **silently lost the XRecord through DXF** (caught by this module's own
|
|
//! save/load tests, not assumed): `CadDocument::ensure_xrecord` only writes
|
|
//! the owner's *own* `xdictionary_handle` field when `get_entity_mut(owner)`
|
|
//! succeeds (a real "entity"); a `Dictionary` owner like the root NOD isn't
|
|
//! one, so `ensure_xrecord` falls back to a runtime-only side map
|
|
//! (`xdic_by_handle`) instead. The DWG writer consults that side map
|
|
//! (`io/dwg/dwg_document_builder.rs` et al.); the DXF writer never does (no
|
|
//! `xdic_by_handle` reference anywhere under `io/dxf/writer/`) — so a
|
|
//! Dictionary-owned extension dictionary is invisible to a DXF save. This
|
|
//! is a gap in the vendored crate for this specific owner-type combination,
|
|
//! not something introduced here; per this project's own stated preference
|
|
//! to avoid needing changes to `acadrust`/`cadkernel`, the fix is to pick a
|
|
//! different, already-proven owner rather than patch it.
|
|
//!
|
|
//! **Owner used instead**: `document.header.model_space_block_handle` — a
|
|
//! real `BlockRecord` entity, the exact anchor `sketch_persist`'s own
|
|
//! `SketchScope::ModelSpace` case already proved round-trips cleanly
|
|
//! through both DWG and DXF. Model space is not a semantically perfect fit
|
|
//! for "the whole document" (a parameter table has nothing to do with model
|
|
//! space specifically), but it is guaranteed to exist in every
|
|
//! `CadDocument` and is a real entity, which is the property that actually
|
|
//! matters here.
|
|
|
|
use super::named_parameters::ParameterTable;
|
|
use super::Scene;
|
|
|
|
const XRECORD_KEY: &str = "OCS_NAMED_PARAMETERS";
|
|
|
|
/// See `sketch_persist::MAX_CHUNK_BYTES`'s doc comment: the vendored DWG
|
|
/// writer truncates any single `XRecordValue::Chunk` entry to `u8::MAX`
|
|
/// bytes, so a table past a handful of parameters must be split across
|
|
/// multiple same-code `310` entries on write and concatenated back on read.
|
|
const MAX_CHUNK_BYTES: usize = u8::MAX as usize;
|
|
|
|
/// Prefixed onto the serialized blob so a future schema change can be
|
|
/// detected and gracefully skipped rather than silently misreading bytes —
|
|
/// same call-out as `sketch_persist::FORMAT_VERSION`, same caveat that the
|
|
/// migration path itself is still undesigned.
|
|
const FORMAT_VERSION: u8 = 1;
|
|
|
|
fn encode(table: &ParameterTable) -> Option<Vec<u8>> {
|
|
let mut bytes = vec![FORMAT_VERSION];
|
|
bytes.extend(bincode::serialize(table).ok()?);
|
|
Some(bytes)
|
|
}
|
|
|
|
fn decode(bytes: &[u8]) -> Option<ParameterTable> {
|
|
let (&version, body) = bytes.split_first()?;
|
|
if version != FORMAT_VERSION {
|
|
return None;
|
|
}
|
|
bincode::deserialize(body).ok()
|
|
}
|
|
|
|
impl Scene {
|
|
/// Writes `named_parameters` into the document's `OCS_NAMED_PARAMETERS`
|
|
/// XRecord, ready for whatever save call happens next to serialize
|
|
/// `self.document` as-is. Called from the same save entry points as
|
|
/// `materialize_sketch_constraints_for_save`
|
|
/// (`OpenCADStudio::prepare_native_save`, the wasm save path).
|
|
///
|
|
/// An empty table is skipped rather than writing an empty XRecord — an
|
|
/// unconstrained-by-parameters drawing (the common case, at least until
|
|
/// this feature is used) should not gain persisted-but-empty
|
|
/// bookkeeping on every save.
|
|
pub(crate) fn materialize_named_parameters_for_save(&mut self) {
|
|
if self.named_parameters.is_empty() {
|
|
return;
|
|
}
|
|
let Some(bytes) = encode(&self.named_parameters) else { return };
|
|
let owner = self.document.header.model_space_block_handle;
|
|
if owner.is_null() {
|
|
return;
|
|
}
|
|
self.document.ensure_xrecord(owner, XRECORD_KEY);
|
|
if let Some(record) = self.document.xrecord_mut(owner, XRECORD_KEY) {
|
|
// Overwrite, not append: a resave must replace the prior blob,
|
|
// not accumulate more Chunk entries every time.
|
|
record.entries.clear();
|
|
for chunk in bytes.chunks(MAX_CHUNK_BYTES) {
|
|
record
|
|
.entries
|
|
.push(acadrust::objects::XRecordEntry::new(310, acadrust::objects::XRecordValue::Chunk(chunk.to_vec())));
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Populates `named_parameters` from the just-loaded `self.document`'s
|
|
/// `OCS_NAMED_PARAMETERS` XRecord, if any — called right after a
|
|
/// document open installs its `CadDocument` into this `Scene`, the same
|
|
/// call sites `load_sketch_constraints_from_document` already has
|
|
/// (`OpenCADStudio::on_file_opened`, the automation `"open"`/`"new"`
|
|
/// ops). Replaces whatever was already in `named_parameters` (a fresh
|
|
/// table for a real open; `"new"`'s blank document has nothing to find
|
|
/// anyway).
|
|
pub(crate) fn load_named_parameters_from_document(&mut self) {
|
|
self.named_parameters = ParameterTable::new();
|
|
let owner = self.document.header.model_space_block_handle;
|
|
if owner.is_null() {
|
|
return;
|
|
}
|
|
let Some(record) = self.document.xrecord(owner, XRECORD_KEY) else { return };
|
|
// Concatenate every Chunk entry in order, not just the first — see
|
|
// `MAX_CHUNK_BYTES`'s doc comment.
|
|
let mut bytes = Vec::new();
|
|
for entry in &record.entries {
|
|
if let acadrust::objects::XRecordValue::Chunk(chunk) = &entry.value {
|
|
bytes.extend_from_slice(chunk);
|
|
}
|
|
}
|
|
if bytes.is_empty() {
|
|
return;
|
|
}
|
|
if let Some(table) = decode(&bytes) {
|
|
self.named_parameters = table;
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn a_parameter_table_survives_materialize_and_reload() {
|
|
let mut scene = Scene::new();
|
|
scene.named_parameters_mut().set("hole_dia", "5").unwrap();
|
|
scene.named_parameters_mut().set("hole_spacing", "2 * hole_dia + 1.5").unwrap();
|
|
|
|
scene.materialize_named_parameters_for_save();
|
|
// Simulate the load-time side of a save/reload round trip: drop the
|
|
// in-memory table (a fresh open starts empty) and repopulate purely
|
|
// from what materialize just wrote into `document.objects`.
|
|
scene.named_parameters = ParameterTable::new();
|
|
scene.load_named_parameters_from_document();
|
|
|
|
assert_eq!(scene.named_parameters().len(), 2);
|
|
assert_eq!(scene.named_parameters().resolve("hole_spacing"), Ok(11.5));
|
|
assert_eq!(scene.named_parameters().get("hole_dia").unwrap().source, "5");
|
|
}
|
|
|
|
/// The above test only proves `materialize`/`load` agree with each
|
|
/// other on the same in-memory `CadDocument` — this drives a real
|
|
/// `save_to_bytes`/`load_bytes` round trip (the same primitives
|
|
/// `OpenCADStudio::on_file_opened`'s native save/open path uses),
|
|
/// through both supported formats, mirroring
|
|
/// `sketch_persist`'s own real-bytes tests.
|
|
fn full_bytes_roundtrip(ext: &str) -> ParameterTable {
|
|
let mut scene = Scene::new();
|
|
scene.named_parameters_mut().set("plate_len", "42.5").unwrap();
|
|
scene.named_parameters_mut().set("plate_width", "plate_len / 2").unwrap();
|
|
|
|
scene.materialize_named_parameters_for_save();
|
|
let bytes = crate::io::save_to_bytes(&scene.document, ext, scene.document.version)
|
|
.unwrap_or_else(|e| panic!("save to {ext} bytes: {e}"));
|
|
let mut reloaded_scene = Scene::new();
|
|
reloaded_scene.document = crate::io::load_bytes(&format!("named_params_roundtrip.{ext}"), bytes)
|
|
.unwrap_or_else(|e| panic!("reload {ext} bytes: {e}"));
|
|
reloaded_scene.load_named_parameters_from_document();
|
|
|
|
reloaded_scene.named_parameters
|
|
}
|
|
|
|
#[test]
|
|
fn a_parameter_table_survives_a_real_dxf_save_and_load_round_trip() {
|
|
let restored = full_bytes_roundtrip("dxf");
|
|
assert_eq!(restored.len(), 2);
|
|
assert_eq!(restored.resolve("plate_width"), Ok(21.25));
|
|
}
|
|
|
|
#[test]
|
|
fn a_parameter_table_survives_a_real_dwg_save_and_load_round_trip() {
|
|
let restored = full_bytes_roundtrip("dwg");
|
|
assert_eq!(restored.len(), 2);
|
|
assert_eq!(restored.resolve("plate_width"), Ok(21.25));
|
|
}
|
|
|
|
#[test]
|
|
fn an_empty_table_is_not_materialized() {
|
|
let mut scene = Scene::new();
|
|
scene.materialize_named_parameters_for_save();
|
|
let owner = scene.document.header.model_space_block_handle;
|
|
assert!(scene.document.xrecord(owner, XRECORD_KEY).is_none(), "an empty table should not create an XRecord");
|
|
}
|
|
|
|
#[test]
|
|
fn decode_rejects_a_mismatched_format_version() {
|
|
let mut bytes = vec![FORMAT_VERSION.wrapping_add(1)];
|
|
bytes.extend(bincode::serialize(&ParameterTable::new()).unwrap());
|
|
assert!(decode(&bytes).is_none(), "a future/unknown format version must be rejected, not misread");
|
|
}
|
|
}
|