From 1c242eb489ccb7f6da670405d8ba2dc189aed9fc Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Tue, 6 Oct 2026 11:38:01 +0300 Subject: [PATCH] Fields: stored values on redraw, evaluation events, per-field shading MTEXT draws its stored text instead of re-evaluating fields on every redraw. Fields update on their evaluation events as the reference does: open (1), save (2, not autosave), plot (4) and REGEN/REGENALL (16), each masked with the drawing's FIELDEVAL (variable dictionary, 0-31, with the reference's prompt and message), and the new UPDATEFIELD command (selection, attributes of selected block references, "N field(s) found." / "N field(s) updated."). A Date field (on demand only) keeps its value until UPDATEFIELD. The FIELDDISPLAY gray box now covers each field's own glyphs, one box per line it spans, instead of the whole text. Codec moved to the update_fields / field_spans revision. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/app/automation.rs | 1 + src/app/commands/display.rs | 2 + src/app/commands/field.rs | 62 ++++++++++++++++++++++ src/app/commands/mod.rs | 2 + src/app/update/file.rs | 10 +++- src/entities/field.rs | 30 +++++++++-- src/entities/mtext.rs | 13 +++-- src/entities/text_support.rs | 16 ++++++ src/io/mod.rs | 4 ++ src/scene/convert/tessellate.rs | 93 +++++++++++++++++++++++++++++++-- src/scene/mod.rs | 11 ++++ 13 files changed, 229 insertions(+), 19 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7b35d3a90..6a2ad04b6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4206,7 +4206,7 @@ dependencies = [ [[package]] name = "opencadcodec" version = "0.5.5" -source = "git+https://github.com/ramox81/cadcodec.git?rev=f51f468#f51f4682c656d9d932c881918e3b8106446f013d" +source = "git+https://github.com/ramox81/cadcodec.git?rev=4892548#4892548f141c9e1389f6a5f0ccf75b30c4753b1b" dependencies = [ "ahash 0.8.12", "anyhow", diff --git a/Cargo.toml b/Cargo.toml index 8d37d1fbe..f522bd606 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -95,7 +95,7 @@ iced_core = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aa iced_widget = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aad9e00b9327cb7b8546ed84db39" } [patch."https://github.com/HakanSeven12/opencadcodec.git"] -opencadcodec = { git = "https://github.com/ramox81/cadcodec.git", rev = "f51f468" } +opencadcodec = { git = "https://github.com/ramox81/cadcodec.git", rev = "4892548" } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] getrandom = "0.3" diff --git a/src/app/automation.rs b/src/app/automation.rs index 559575263..24d86ab88 100644 --- a/src/app/automation.rs +++ b/src/app/automation.rs @@ -848,6 +848,7 @@ impl OpenCADStudio { ".{}.tmp.dwg", path.file_name().unwrap().to_string_lossy() )); + self.tabs[i].scene.update_fields(2, None); let document = self.tabs[i].scene.document_for_save(); if let Err(e) = crate::io::save(&document, &scratch) { return err(format!("save: {e}")); diff --git a/src/app/commands/display.rs b/src/app/commands/display.rs index b979fec69..5e43f5f50 100644 --- a/src/app/commands/display.rs +++ b/src/app/commands/display.rs @@ -504,6 +504,8 @@ impl OpenCADStudio { } // Rebuild tessellation caches without modifying the document. "REGEN" | "REGENALL" => { + // A regen is a field evaluation event (FIELDEVAL bit 16). + self.tabs[i].scene.update_fields(16, None); self.tabs[i].scene.populate_meshes_from_document(); self.command_line.push_output(crate::t!("REGEN: regenerated model.").as_ref()); return Some(Task::none()); diff --git a/src/app/commands/field.rs b/src/app/commands/field.rs index 422e51bcb..da40cab49 100644 --- a/src/app/commands/field.rs +++ b/src/app/commands/field.rs @@ -17,6 +17,68 @@ impl OpenCADStudio { self.open_field_dialog(FieldTarget::NewText); Some(Task::none()) } + // UPDATEFIELD — re-evaluate the fields of the selected objects + // (a block reference's attributes included), whatever FIELDEVAL. + "UPDATEFIELD" => { + let selected: Vec = + self.tabs[i].scene.selected_entities().into_iter().map(|(h, _)| h).collect(); + if selected.is_empty() { + use crate::modules::draw::select::SelectObjectsCommand; + let cmd = SelectObjectsCommand::new("UPDATEFIELD"); + self.command_line.push_info(&cmd.prompt()); + self.tabs[i].active_cmd = Some(Box::new(cmd)); + return Some(Task::none()); + } + let document = &self.tabs[i].scene.document; + let mut hosts = selected.clone(); + for handle in &selected { + if let Some(codec::EntityType::Insert(insert)) = document.get_entity(*handle) { + hosts.extend(insert.attributes.iter().map(|a| a.common.handle)); + } + } + self.push_undo_snapshot(i, "UPDATEFIELD"); + let found = self.tabs[i].scene.update_fields(32, Some(&hosts)); + self.tabs[i].scene.deselect_all(); + self.refresh_properties(); + if found > 0 { + self.tabs[i].dirty = true; + } + self.command_line.push_output(crate::tf!("{} field(s) found.", found).as_ref()); + self.command_line.push_output(crate::tf!("{} field(s) updated.", found).as_ref()); + Some(Task::none()) + } + // FIELDEVAL — the events that update fields, kept in the drawing. + cmd if cmd == "FIELDEVAL" || cmd.starts_with("FIELDEVAL ") || cmd.starts_with("SETVAR FIELDEVAL") => { + let value = cmd + .trim_start_matches("SETVAR ") + .trim_start_matches("FIELDEVAL") + .trim(); + let current = crate::entities::field::fieldeval(&self.tabs[i].scene.document); + let ask = |app: &mut Self| { + app.command_line + .push_output(&format!("Enter new value for FIELDEVAL <{current}>:")); + app.pending_setvar = Some("FIELDEVAL".into()); + }; + match value.parse::() { + _ if value.is_empty() => ask(self), + Ok(n) if (0..=31).contains(&n) => { + if n != current { + self.push_undo_snapshot(i, "FIELDEVAL"); + crate::io::set_drawing_variable( + &mut self.tabs[i].scene.document, + "FIELDEVAL", + &n.to_string(), + ); + self.tabs[i].dirty = true; + } + } + _ => { + self.command_line.push_error("Requires an integer between 0 and 31."); + ask(self); + } + } + Some(Task::none()) + } cmd if cmd == "_FIELD_OBJECT" || cmd.starts_with("_FIELD_OBJECT ") => { let handle = cmd .split_whitespace() diff --git a/src/app/commands/mod.rs b/src/app/commands/mod.rs index b17c9eee6..ef612e064 100644 --- a/src/app/commands/mod.rs +++ b/src/app/commands/mod.rs @@ -697,6 +697,8 @@ inventory::submit!(crate::command::CommandRegistration { "PDFOSNAP", "UOSNAP", "FIELDDISPLAY", + "FIELDEVAL", + "UPDATEFIELD", "PDFIMPORTMODE", "PDFIMPORTFILTER", "PDFIMPORTLAYERS", diff --git a/src/app/update/file.rs b/src/app/update/file.rs index 24ce4b7b0..d07821314 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -2144,6 +2144,14 @@ impl OpenCADStudio { #[cfg(not(target_arch = "wasm32"))] pub(in crate::app) fn prepare_native_save(&mut self, i: usize) { + // A save is a field evaluation event (FIELDEVAL bit 2). + self.tabs[i].scene.update_fields(2, None); + self.prepare_snapshot_save(i); + } + + /// Bring the drawing state the file stores up to date, without the save + /// event an autosave does not raise. + pub(in crate::app) fn prepare_snapshot_save(&mut self, i: usize) { self.sync_view_state_for_save(i); sync_annotation_scale_header(&mut self.tabs[i].scene); self.stamp_header_sysvars(i); @@ -3275,7 +3283,7 @@ impl OpenCADStudio { if !self.tabs[i].dirty || self.active_save_jobs.contains_key(&self.tabs[i].id) { continue; } - self.prepare_native_save(i); + self.prepare_snapshot_save(i); let version = self.tabs[i].scene.document.version; let target = self.autosave_target(i); tasks.push(self.queue_native_save( diff --git a/src/entities/field.rs b/src/entities/field.rs index 375250697..17eab834d 100644 --- a/src/entities/field.rs +++ b/src/entities/field.rs @@ -84,10 +84,30 @@ impl FieldContext for OcsFieldContext<'_> { } } -/// Re-evaluate the field hosted by entity `host` (usually an MTEXT), or `None` -/// to keep the cached text. Thin wrapper over the library engine. -pub fn resolve(document: &CadDocument, host: Handle) -> Option { - codec::fields::resolve(document, host, &OcsFieldContext(Some(document))) +/// FIELDEVAL of the drawing (kept in its variable dictionary; default 31): +/// the events that update fields — 1 open, 2 save, 4 plot, 8 eTransmit, +/// 16 regen. +pub fn fieldeval(document: &CadDocument) -> i32 { + crate::io::drawing_variable(document, "FIELDEVAL") + .and_then(|v| v.trim().parse().ok()) + .unwrap_or(31) +} + +/// Update the fields an event evaluates and store their values in the host +/// texts: `event` 1 open, 2 save or 16 regen (masked with FIELDEVAL), or 32 +/// an explicit UPDATEFIELD. `hosts` limits it to those hosts. Returns the +/// hosts whose text changed and the number of fields the hosts hold. +pub fn update_fields( + document: &mut CadDocument, + event: i32, + hosts: Option<&[Handle]>, +) -> (Vec, usize) { + let event = if event == 32 { event } else { event & fieldeval(document) }; + if event == 0 || document.fields.is_empty() { + return (Vec::new(), 0); + } + let snapshot = document.clone(); + document.update_fields(&OcsFieldContext(Some(&snapshot)), event, hosts) } pub fn resolve_handle( @@ -388,7 +408,7 @@ pub fn stamp_plot_fields(document: &mut CadDocument) -> Vec { .objects .values() .any(|object| matches!(object, codec::objects::ObjectType::Field(_))); - if !has_fields { + if !has_fields || fieldeval(document) & 4 == 0 { return Vec::new(); } let snapshot = document.clone(); diff --git a/src/entities/mtext.rs b/src/entities/mtext.rs index c92c92858..291e2ffc4 100644 --- a/src/entities/mtext.rs +++ b/src/entities/mtext.rs @@ -82,11 +82,6 @@ fn drawing_dir_str(d: &DrawingDirection) -> &'static str { /// Per-visible-character world-space boxes for the MText editor's /// click-to-select preview. Uses the exact same layout opts as `to_render` /// so the boxes line up with the rendered glyphs. -/// The MTEXT string to render: the live re-evaluated value when the entity -/// hosts a dynamic field, otherwise its stored (cached) value. -fn display_value(t: &MText, document: &codec::CadDocument) -> String { - crate::entities::field::resolve(document, t.common.handle).unwrap_or_else(|| t.value.clone()) -} pub fn glyph_boxes(t: &MText, document: &codec::CadDocument) -> Vec { let resolved_style = resolve_text_style(&t.style, document); @@ -115,7 +110,9 @@ pub fn glyph_boxes(t: &MText, document: &codec::CadDocument) -> Vec { } else { t.rotation as f32 }; - let display = display_value(t, document); + // A field shows its stored value: fields update on their evaluation + // events (open, save, plot, regen, UPDATEFIELD), never on a redraw. + let display = t.value.clone(); let layout = layout_mtext(&MTextRenderOpts { value: &display, insertion: [ @@ -171,7 +168,9 @@ fn to_render(t: &MText, document: &codec::CadDocument) -> RenderEntity { } else { t.rotation as f32 }; - let display = display_value(t, document); + // A field shows its stored value: fields update on their evaluation + // events (open, save, plot, regen, UPDATEFIELD), never on a redraw. + let display = t.value.clone(); let layout = layout_mtext(&MTextRenderOpts { value: &display, insertion: [ diff --git a/src/entities/text_support.rs b/src/entities/text_support.rs index 88209381b..429ff0466 100644 --- a/src/entities/text_support.rs +++ b/src/entities/text_support.rs @@ -488,6 +488,22 @@ fn font_stem(name: &str) -> String { .to_string() } +/// How many inked glyphs (non-blank characters, stacked parts included) an +/// MTEXT string draws, in drawing order: the index of the first glyph of +/// what follows `s` in a longer string. +pub fn mtext_glyph_count(s: &str) -> usize { + let ink = |t: &str| t.chars().filter(|c| !c.is_whitespace()).count(); + adapt_mtext_paragraphs(s, 1.0, false) + .iter() + .flat_map(|line| &line.runs) + .map(|run| match &run.kind { + MTextRunKind::Glyphs(text) => ink(text), + MTextRunKind::Tab => 0, + MTextRunKind::Stack { numerator, denominator, .. } => ink(numerator) + ink(denominator), + }) + .sum() +} + /// Parse an MTEXT string into the layout's `Vec`, using opencadcodec's /// structured `mtext_format::parse_mtext` — OCS keeps only the layout engine /// (`layout_mtext` and callers read `MTextLine`/`RunState`), not a second MTEXT diff --git a/src/io/mod.rs b/src/io/mod.rs index 3a4c9d52a..b81d5ea5c 100644 --- a/src/io/mod.rs +++ b/src/io/mod.rs @@ -794,6 +794,8 @@ pub fn load_bytes_finalized(path: &Path, bytes: Vec) -> Result<(CadDocument, normalize_block_origins(&mut doc); resolve_raster_image_paths(&mut doc, path.parent()); doc.source_path = Some(path.to_string_lossy().into_owned()); + // Opening is a field evaluation event (FIELDEVAL bit 1). + crate::entities::field::update_fields(&mut doc, 1, None); let dropped = purge_corrupt_entities(&mut doc); Ok((doc, dropped)) } @@ -1047,6 +1049,8 @@ fn finalize_loaded_outcome( crate::entities::field::local_header_dates(doc); resolve_raster_image_paths(doc, path.parent()); doc.source_path = Some(path.to_string_lossy().into_owned()); + // Opening is a field evaluation event (FIELDEVAL bit 1). + crate::entities::field::update_fields(doc, 1, None); Ok(outcome) } diff --git a/src/scene/convert/tessellate.rs b/src/scene/convert/tessellate.rs index 917e71a3d..8ab0ce15f 100644 --- a/src/scene/convert/tessellate.rs +++ b/src/scene/convert/tessellate.rs @@ -78,6 +78,74 @@ fn oriented_text_corners( ] } +/// FIELDDISPLAY boxes: the glyph bounds of each field's shown text, one box +/// per line the field spans, in the text's own frame (`origin`, `rotation`). +/// Glyph quads come six vertices each in drawing order, one per inked +/// character, so a field's characters select its quads. `None` when the +/// host's fields cannot be located in its text (the whole text is boxed). +fn field_run_corner_groups( + verts: &[crate::scene::pipeline::text_gpu::TextVertex], + document: &codec::CadDocument, + entity: &EntityType, + origin: [f64; 2], + rotation: f64, +) -> Option> { + use crate::entities::text_support::{mtext_glyph_count, resolve_dxf_special_chars}; + fn plain_glyph_count(s: &str) -> usize { + resolve_dxf_special_chars(s).chars().filter(|c| !c.is_whitespace()).count() + } + let (text, count): (&str, fn(&str) -> usize) = match entity { + EntityType::MText(m) => (&m.value, mtext_glyph_count), + EntityType::Text(t) => (&t.value, plain_glyph_count), + EntityType::AttributeEntity(a) => (&a.value, plain_glyph_count), + _ => return None, + }; + let spans = codec::fields::field_spans(document, entity.common().handle, text)?; + let (sin_r, cos_r) = rotation.sin_cos(); + // Each quad's local bounds [left, bottom, right, top]. + let quads: Vec<[f64; 4]> = verts + .chunks_exact(6) + .map(|quad| { + let mut b = [f64::MAX, f64::MAX, f64::MIN, f64::MIN]; + for v in quad { + let x = v.pos[0] as f64 + v.pos_low[0] as f64 - origin[0]; + let y = v.pos[1] as f64 + v.pos_low[1] as f64 - origin[1]; + let (lx, ly) = (x * cos_r + y * sin_r, -x * sin_r + y * cos_r); + b = [b[0].min(lx), b[1].min(ly), b[2].max(lx), b[3].max(ly)]; + } + b + }) + .collect(); + let to_world = |x: f64, y: f64| [origin[0] + x * cos_r - y * sin_r, origin[1] + x * sin_r + y * cos_r]; + let mut groups = Vec::new(); + for span in spans { + let first = count(text.get(..span.start)?); + let last = count(text.get(..span.end)?).min(quads.len()); + // A wrapped field starts a new box where the next glyph moves back + // left or off the current line. + let mut line: Option<[f64; 4]> = None; + for q in quads.get(first..last).unwrap_or_default() { + line = Some(match line { + Some(b) if q[0] >= b[0] && (q[1] + q[3]) * 0.5 > b[1] && (q[1] + q[3]) * 0.5 < b[3] => { + [b[0].min(q[0]), b[1].min(q[1]), b[2].max(q[2]), b[3].max(q[3])] + } + Some(b) => { + groups.push(b); + *q + } + None => *q, + }); + } + groups.extend(line); + } + Some( + groups + .into_iter() + .map(|[l, b, r, t]| [to_world(l, b), to_world(r, b), to_world(r, t), to_world(l, t)]) + .collect(), + ) +} + fn oriented_mtext_corner_groups( verts: &[crate::scene::pipeline::text_gpu::TextVertex], text: &codec::MText, @@ -1421,12 +1489,29 @@ pub fn tessellate( && crate::entities::field::display() && crate::entities::field::hosts_field(document, entity) { - let corner_groups = match entity { - EntityType::MText(m) => { - let rotation = stroke_groups + let frame = match entity { + EntityType::MText(m) => Some(( + [m.insertion_point.x, m.insertion_point.y], + stroke_groups .iter() .find_map(|group| group.run.as_ref().map(|run| run.rotation as f64)) - .unwrap_or(m.rotation); + .unwrap_or(m.rotation), + )), + EntityType::Text(t) => { + Some(([t.insertion_point.x, t.insertion_point.y], t.rotation)) + } + EntityType::AttributeEntity(a) => { + Some(([a.insertion_point.x, a.insertion_point.y], a.rotation)) + } + _ => None, + }; + let runs = frame.and_then(|(origin, rotation)| { + field_run_corner_groups(&sdf_verts, document, entity, origin, rotation) + }); + let corner_groups = match entity { + _ if runs.is_some() => runs.unwrap_or_default(), + EntityType::MText(m) => { + let rotation = frame.map_or(m.rotation, |(_, rotation)| rotation); oriented_mtext_corner_groups(&sdf_verts, m, rotation, 0.0, anno) } EntityType::Text(t) => vec![oriented_text_corners( diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 71d538ef6..0246ce123 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -3396,6 +3396,17 @@ impl Scene { self.bump_entities_with_parametric_policy(changes, &[], false); } + /// Update the fields an evaluation event re-evaluates (see + /// [`crate::entities::field::update_fields`]) and redraw the hosts whose + /// text changed. Returns the number of fields the hosts hold. + pub fn update_fields(&mut self, event: i32, hosts: Option<&[Handle]>) -> usize { + let (changed, found) = crate::entities::field::update_fields(&mut self.document, event, hosts); + if !changed.is_empty() { + self.bump_text_hosts(&changed); + } + found + } + /// Redraw text hosts whose stored text changed (re-evaluated fields). An /// attribute is drawn by its INSERT, and a host inside a block definition /// by every reference to that block, so those redraw instead of the host;