forked from 1b-agent/opencadstudio-upstream
Add named parameters, UI scale setting, OPTIONS/OP dispatch, and help manual
Named parameters (docs/named_parameters_design.md, stages 3-4): - ParameterTable (src/scene/named_parameters.rs): a document-wide table of named formulas, each resolved through a shared expression evaluator with cross-parameter references, cycle detection, and division-by-zero/parse error reporting. SketchConstraint::driving_param becomes DrivingValue (Literal | Named) instead of a bare f64, resolved at solve time by sketch_solve::build_constraint via Scene::named_parameters. - Persistence (src/scene/named_parameters_persist.rs): same lazy XRecord model sketch_persist.rs already established, synced around open/save. Bumps the sketch-constraint XRecord's own format version (1 -> 2) since DrivingValue's shape doesn't line up with a version-1 blob's bytes. - PARAMETERS command and its editor (src/ui/window/named_parameters.rs): add/remove/edit rows, live validation, duplicate-name detection: Apply defines the table and re-solves every constraint scope with a named-parameter-driven constraint (command_driver.rs). Distance/Angle constraint prompts (modules/draw/constrain/value.rs) now accept either a literal or an existing parameter's name, mirroring how the constraint's own value prompt already worked. - Undo scope-down carried over from this project's own established pattern (constraint-system stage 9): the geometry an Apply moves is one undo step, but the parameter table edit itself has no undo entry yet — a documented follow-up, not a silent gap. UI scale setting (src/app/settings.rs, app/mod.rs, ui/window/options.rs): a whole-interface scale percentage (50-200%), wired directly into iced's own per-window scale_factor hook rather than per-panel font-size plumbing, so it applies immediately to every panel, dialog, and the ribbon at once. Persisted alongside the existing lineweight-display-scale setting. OPTIONS/OP command dispatch (app/commands/view.rs, app/commands/mod.rs): OPTIONS was registered for autocomplete but had no dispatch arm — the Start-tab welcome screen's own button was the only way to reach it. Found live-testing the UI-scale setting, unrelated to that feature itself. "OP" is AutoCAD's own short alias. help/: a new self-contained static user-help manual (manual.md as the authored source, tools/build_help.py splits it into the committed static HTML pages plus search index) — not yet wired into the application. 64 new/updated tests across the named-parameters and constraint-prompt changes. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
e2d39b049d
commit
7cddf31bcb
47 files changed
+4136
-52
No files matched your search
@@ -0,0 +1,201 @@
|
||||
//! 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";
|
||||
|
||||
/// 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 one more Chunk entry every time.
|
||||
record.entries.clear();
|
||||
record
|
||||
.entries
|
||||
.push(acadrust::objects::XRecordEntry::new(310, acadrust::objects::XRecordValue::Chunk(bytes)));
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 };
|
||||
let Some(bytes) = record.entries.iter().find_map(|entry| match &entry.value {
|
||||
acadrust::objects::XRecordValue::Chunk(bytes) => Some(bytes.clone()),
|
||||
_ => None,
|
||||
}) else {
|
||||
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");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user