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;