From 71cebbb3fef470eb5609fa60c8a05cc5fc73602e Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Tue, 6 Oct 2026 10:37:04 +0300 Subject: [PATCH 1/4] Plot: redraw PlotDate hosts inside blocks and attributes A plot stamps PlotDate fields, but the redraw only dropped the host's own tessellation: an attribute is drawn by its INSERT and a host inside a block definition by every reference to the block, so both still showed the old value on screen and in the plotted output. The changed hosts now redraw through their INSERT / the block's references (block cache refreshed), without re-tessellating anything else. The codec is patched to the field format code fixes. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 2 +- Cargo.toml | 3 +++ src/app/update/file.rs | 3 +-- src/scene/mod.rs | 45 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 50 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 85762f6fa..7b35d3a90 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4206,7 +4206,7 @@ dependencies = [ [[package]] name = "opencadcodec" version = "0.5.5" -source = "git+https://github.com/HakanSeven12/opencadcodec.git?rev=04b26f2#04b26f2c992110c1da4ed6bc0491d76906fbf4e5" +source = "git+https://github.com/ramox81/cadcodec.git?rev=f51f468#f51f4682c656d9d932c881918e3b8106446f013d" dependencies = [ "ahash 0.8.12", "anyhow", diff --git a/Cargo.toml b/Cargo.toml index 86fcca40e..8d37d1fbe 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -94,6 +94,9 @@ objc2-foundation = { version = "0.2", features = ["NSArray", "NSURL", "NSString" iced_core = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aad9e00b9327cb7b8546ed84db39" } 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" } + [target.'cfg(not(target_arch = "wasm32"))'.dependencies] getrandom = "0.3" ocs_plugin_api = { path = "crates/ocs_plugin_api", features = ["host"] } diff --git a/src/app/update/file.rs b/src/app/update/file.rs index fe0e4d946..24ce4b7b0 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -3771,8 +3771,7 @@ impl OpenCADStudio { let i = self.active_tab; let hosts = crate::entities::field::stamp_plot_fields(&mut self.tabs[i].scene.document); if !hosts.is_empty() { - let changes: Vec<_> = hosts.into_iter().map(|h| (h, crate::scene::ChangeKind::Modified)).collect(); - self.tabs[i].scene.bump_entities(&changes); + self.tabs[i].scene.bump_text_hosts(&hosts); self.tabs[i].dirty = true; } } diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 67335f4bb..71d538ef6 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -567,6 +567,9 @@ struct SceneDependencyIndex { text_geometry: DependencyTargets, annotation_geometry: DependencyTargets, signatures: HashMap, + /// Block name (normalised) → the layout entities that draw it, nested + /// references included. + block_roots: HashMap>, } fn hatch_interaction_aabb(hatch: &model::hatch_model::HatchModel) -> Option<[f64; 4]> { @@ -3393,6 +3396,47 @@ impl Scene { self.bump_entities_with_parametric_policy(changes, &[], false); } + /// 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; + /// nothing else re-tessellates. + pub fn bump_text_hosts(&mut self, hosts: &[Handle]) { + if self.dependency_index_cache.borrow().is_none() { + *self.dependency_index_cache.borrow_mut() = Some(self.rebuild_dependency_index()); + } + let mut targets = DependencyTargets::default(); + for &host in hosts { + let drawn = match self.document.get_entity(host) { + Some(entity) => entity, + None => match self.document.entities().find(|e| { + matches!(e, EntityType::Insert(i) if i.attributes.iter().any(|a| a.common.handle == host)) + }) { + Some(insert) => insert, + None => continue, + }, + }; + let handle = drawn.common().handle; + let owner = drawn.common().owner_handle; + if !self.changes_touch_block_definition(&[(handle, ChangeKind::Modified)]) { + targets.render_handles.insert(handle); + continue; + } + let Some(record) = self.document.block_records.iter().find(|r| { + r.handle == owner || r.entity_handles.contains(&handle) + }) else { + continue; + }; + let name = normalize_name(&record.name); + if let Some(index) = self.dependency_index_cache.borrow().as_ref() { + if let Some(users) = index.block_roots.get(&name) { + targets.render_handles.extend(users.iter().copied()); + } + } + targets.touches_block_definition = true; + } + self.invalidate_dependency_targets(targets); + } + pub fn bump_entities_with_parametric_driven( &mut self, changes: &[(Handle, ChangeKind)], @@ -11565,6 +11609,7 @@ vis_index={:.1} visible_probe={:.1}", _ => {} } } + index.block_roots = roots; index } 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 2/4] 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; From 383eba14e5e3c4ee90ef9176a1696e3dafc2d4f2 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Tue, 6 Oct 2026 12:25:24 +0300 Subject: [PATCH 3/4] Fields: table cells keep stored values, UPDATEFIELD reaches blocks and tables, measured shading box Table cell fields show their stored value; only formula cells evaluate live. UPDATEFIELD on a block reference also updates the fields of its block definition, and on a table the fields of its cells; a changed table cell text redraws the table. The FIELDDISPLAY box is placed from the text layout (MTEXT layout cells, TEXT / attribute pen advances) and spans 0.345 text heights below the baseline to 1.19 above it. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/app/commands/field.rs | 16 +++- src/entities/field.rs | 10 +- src/entities/table.rs | 2 +- src/entities/text_support.rs | 31 +++++-- src/scene/convert/tessellate.rs | 160 +++++++++++++++++--------------- src/scene/mod.rs | 6 ++ 8 files changed, 134 insertions(+), 95 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6a2ad04b6..4f241e9e6 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=4892548#4892548f141c9e1389f6a5f0ccf75b30c4753b1b" +source = "git+https://github.com/ramox81/cadcodec.git?rev=07191be#07191be6571c43d8334eb7da02e6d42283246628" dependencies = [ "ahash 0.8.12", "anyhow", diff --git a/Cargo.toml b/Cargo.toml index f522bd606..138a3301c 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 = "4892548" } +opencadcodec = { git = "https://github.com/ramox81/cadcodec.git", rev = "07191be" } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] getrandom = "0.3" diff --git a/src/app/commands/field.rs b/src/app/commands/field.rs index da40cab49..13643b2ed 100644 --- a/src/app/commands/field.rs +++ b/src/app/commands/field.rs @@ -29,11 +29,23 @@ impl OpenCADStudio { self.tabs[i].active_cmd = Some(Box::new(cmd)); return Some(Task::none()); } + // A block reference brings its attributes and the fields of its + // block definition; a table, the texts of its cells. 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)); + let record = match document.get_entity(*handle) { + Some(codec::EntityType::Insert(insert)) => { + hosts.extend(insert.attributes.iter().map(|a| a.common.handle)); + document.block_records.iter().find(|r| r.name.eq_ignore_ascii_case(&insert.block_name)) + } + Some(codec::EntityType::Table(table)) => table + .block_record_handle + .and_then(|h| document.block_records.iter().find(|r| r.handle == h)), + _ => None, + }; + if let Some(record) = record { + hosts.extend(record.entity_handles.iter().copied()); } } self.push_undo_snapshot(i, "UPDATEFIELD"); diff --git a/src/entities/field.rs b/src/entities/field.rs index 17eab834d..29af2e20b 100644 --- a/src/entities/field.rs +++ b/src/entities/field.rs @@ -110,12 +110,10 @@ pub fn update_fields( document.update_fields(&OcsFieldContext(Some(&snapshot)), event, hosts) } -pub fn resolve_handle( - document: &CadDocument, - field: Handle, - host: Handle, -) -> Option { - codec::fields::resolve_handle(document, field, host, &OcsFieldContext(Some(document))) +/// The text a table cell shows for its field: a formula is evaluated live +/// (it reads other cells), any other field shows its stored value. +pub fn cell_text(document: &CadDocument, field: Handle, table: Handle) -> Option { + codec::fields::cell_field_text(document, field, table, &OcsFieldContext(Some(document))) } /// Evaluate one field code (`\AcVar Date \f "yyyy-MM-dd"`, or the `%<…>%` diff --git a/src/entities/table.rs b/src/entities/table.rs index 0130996b9..e6ddd855b 100644 --- a/src/entities/table.rs +++ b/src/entities/table.rs @@ -646,7 +646,7 @@ fn content_display_value( ) -> String { if let Some(handle) = content.field_handle { if let Some(value) = - crate::entities::field::resolve_handle(document, handle, table.common.handle) + crate::entities::field::cell_text(document, handle, table.common.handle) { return value; } diff --git a/src/entities/text_support.rs b/src/entities/text_support.rs index 429ff0466..d8aef296d 100644 --- a/src/entities/text_support.rs +++ b/src/entities/text_support.rs @@ -488,18 +488,29 @@ 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(); +/// The visible-character offset that follows the MTEXT string `s` — the +/// `vis` index [`layout_mtext`]'s glyph boxes give the first character of +/// whatever comes after `s` in a longer string: every laid-out character, +/// a tab or stack slot, and one per paragraph break. +pub fn mtext_visible_count(s: &str) -> usize { 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), + .enumerate() + .map(|(i, para)| { + usize::from(i > 0) + + para + .runs + .iter() + .map(|run| match &run.kind { + MTextRunKind::Glyphs(text) => text.chars().count(), + MTextRunKind::Tab => 1, + MTextRunKind::Stack { numerator, denominator, .. } => { + numerator.chars().count() + + denominator.chars().count() + + usize::from(!denominator.is_empty()) + } + }) + .sum::() }) .sum() } diff --git a/src/scene/convert/tessellate.rs b/src/scene/convert/tessellate.rs index 8ab0ce15f..a6ae7f710 100644 --- a/src/scene/convert/tessellate.rs +++ b/src/scene/convert/tessellate.rs @@ -78,70 +78,97 @@ 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). +/// FIELDDISPLAY box extent around a line of field text, in text heights: +/// from a third of the height below the baseline to 1.19 heights above it +/// (the reference's box, measured at 2.5 and 10 unit text). +const FIELD_BOX_BELOW: f64 = 0.345; +const FIELD_BOX_ABOVE: f64 = 1.19; + +/// FIELDDISPLAY boxes: each field's shown text, one box per line it spans, +/// placed from the text layout itself — MTEXT's per-character layout cells +/// (the editor's glyph boxes) and TEXT / attribute pen advances, the same +/// layout and shaping the glyphs are drawn with — so formatting codes, +/// missing glyphs or shaped scripts before a field do not shift it. A box +/// spans the field's character cells horizontally and FIELD_BOX_BELOW / +/// FIELD_BOX_ABOVE vertically. `None` when the host's fields cannot be +/// located in its text (the whole text is boxed then). fn field_run_corner_groups( - verts: &[crate::scene::pipeline::text_gpu::TextVertex], document: &codec::CadDocument, entity: &EntityType, - origin: [f64; 2], - rotation: f64, + stroke_groups: &[crate::scene::convert::acad_to_render::TextStroke], + anno: 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), + use crate::entities::text_support::{mtext_visible_count, resolve_dxf_special_chars, text_local_bounds}; + let handle = entity.common().handle; + // Lines of a field in a local frame: [left, baseline, right, height]. + let mut lines: Vec<[f64; 4]> = Vec::new(); + let (origin, rotation) = match entity { + EntityType::MText(m) => { + let spans = codec::fields::field_spans(document, handle, &m.value)?; + let mut local = m.clone(); + local.insertion_point = codec::types::Vector3::new(0.0, 0.0, 0.0); + local.rotation = 0.0; + let boxes = crate::entities::mtext::glyph_boxes(&local, document); + for span in spans { + let first = mtext_visible_count(m.value.get(..span.start)?); + let last = mtext_visible_count(m.value.get(..span.end)?); + let mut line: Option<[f64; 4]> = None; + for b in boxes.iter().filter(|b| (first..last).contains(&b.vis)) { + let (l, base, r, h) = (b.xmin as f64, b.ymin as f64, b.xmax as f64, (b.ymax - b.ymin) as f64); + line = Some(match line { + Some(c) if (c[1] - base).abs() <= 1e-3 * h.max(c[3]) => { + [c[0].min(l), c[1], c[2].max(r), c[3].max(h)] + } + Some(c) => { + lines.push(c); + [l, base, r, h] + } + None => [l, base, r, h], + }); + } + lines.extend(line); + } + let rotation = stroke_groups + .iter() + .find_map(|g| g.run.as_ref().map(|run| run.rotation as f64)) + .unwrap_or(m.rotation); + for line in &mut lines { + *line = line.map(|v| v * anno); + } + ([m.insertion_point.x, m.insertion_point.y], rotation) + } + EntityType::Text(_) | EntityType::AttributeEntity(_) => { + let text = match entity { + EntityType::Text(t) => &t.value, + EntityType::AttributeEntity(a) => &a.value, + _ => return None, + }; + let spans = codec::fields::field_spans(document, handle, text)?; + // One laid-out line: a multi-line attribute keeps the whole box. + let [group] = stroke_groups else { return None }; + let run = group.run.as_ref()?; + let advance = |s: &str| { + text_local_bounds(&run.font, &resolve_dxf_special_chars(s), run.height, run.width_factor, run.oblique) + .map_or(0.0, |b| b.advance as f64) + }; + for span in spans { + let (l, r) = (advance(text.get(..span.start)?), advance(text.get(..span.end)?)); + lines.push([l * anno, 0.0, r * anno, run.height as f64 * anno]); + } + (group.origin, run.rotation as f64) + } _ => 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 + lines .into_iter() - .map(|[l, b, r, t]| [to_world(l, b), to_world(r, b), to_world(r, t), to_world(l, t)]) + .filter(|[l, _, r, _]| r > l) + .map(|[l, base, r, h]| { + let (b, t) = (base - FIELD_BOX_BELOW * h, base + FIELD_BOX_ABOVE * h); + [to_world(l, b), to_world(r, b), to_world(r, t), to_world(l, t)] + }) .collect(), ) } @@ -1489,29 +1516,14 @@ pub fn tessellate( && crate::entities::field::display() && crate::entities::field::hosts_field(document, entity) { - 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), - )), - 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 runs = field_run_corner_groups(document, entity, &stroke_groups, anno); 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); + let rotation = stroke_groups + .iter() + .find_map(|group| group.run.as_ref().map(|run| run.rotation as f64)) + .unwrap_or(m.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 0246ce123..97c3d857d 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -3443,6 +3443,12 @@ impl Scene { targets.render_handles.extend(users.iter().copied()); } } + // A table draws its cells from its own block of cell texts. + let record_handle = record.handle; + targets.render_handles.extend(self.document.entities().filter_map(|e| match e { + EntityType::Table(t) if t.block_record_handle == Some(record_handle) => Some(t.common.handle), + _ => None, + })); targets.touches_block_definition = true; } self.invalidate_dependency_targets(targets); From 3830a69ca705d18647b38ee2cae49882c6c4e189 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Tue, 6 Oct 2026 13:24:52 +0300 Subject: [PATCH 4/4] Fields: exact field boxes for rotated, multi-line and shaped text Layout glyph cells carry their unrotated cell, so a field box in rotated MTEXT is placed exactly; multi-line attributes box each line of a field from their own layout cells and single-line attributes split on \P per line; shaped words (TrueType fonts, right-to-left and complex scripts) take the shaper's glyph cells for their characters instead of evenly divided slots. The field background is also drawn for text rendered as shaped strokes. Co-Authored-By: Claude Opus 5.5 --- src/entities/attribute.rs | 50 +++++++++- src/entities/text_support.rs | 106 +++++++++++++++++++-- src/scene/convert/tessellate.rs | 160 +++++++++++++++++++------------- src/scene/text/ttf_glyph.rs | 32 ++++++- 4 files changed, 273 insertions(+), 75 deletions(-) diff --git a/src/entities/attribute.rs b/src/entities/attribute.rs index 43d87f683..770e66bc2 100644 --- a/src/entities/attribute.rs +++ b/src/entities/attribute.rs @@ -101,7 +101,21 @@ fn mtext_flag_str(f: MTextFlag) -> &'static str { /// Render text strokes for an attribute, honouring alignment, oblique angle, /// width factor, generation flags (backward / upside-down), text-style /// resolution, and basic multiline splitting on `\n` / `\\P`. -fn build_attr_render(mut input: AttrTextInputs<'_>, document: &codec::CadDocument) -> RenderEntity { +/// Laid-out character cells of a multi-line (MTEXT) attribute: the layout's +/// glyph boxes, the point they are relative to and the layout rotation. +pub(crate) type AttrCells = (Vec, [f64; 2], f32); + +fn build_attr_render(input: AttrTextInputs<'_>, document: &codec::CadDocument) -> RenderEntity { + build_attr_layout(input, document, None) +} + +/// [`build_attr_render`], also handing back a multi-line attribute's +/// character cells in `cells` (left `None` for a single-line one). +fn build_attr_layout( + mut input: AttrTextInputs<'_>, + document: &codec::CadDocument, + mut cells: Option<&mut Option>, +) -> RenderEntity { let normal = (input.normal.x, input.normal.y, input.normal.z); let (wsx, wsy, wsz) = transform::ocs_point_to_wcs( ( @@ -248,8 +262,11 @@ fn build_attr_render(mut input: AttrTextInputs<'_>, document: &codec::CadDocumen exact_line_spacing: false, rectangle_height: 0.0, vertical_text: false, - want_glyph_boxes: false, + want_glyph_boxes: cells.is_some(), }); + if let Some(out) = cells.as_deref_mut() { + *out = Some((layout.glyph_boxes.clone(), [anchor_pt.x, anchor_pt.y], rotation)); + } let _ = input.line_count; let _ = input.is_multiline; return RenderEntity { @@ -839,6 +856,35 @@ impl Transformable for AttributeDefinition { // ── AttributeEntity ─────────────────────────────────────────────────────────── +/// The laid-out character cells of a multi-line (MTEXT) attribute, the +/// point they are relative to and the layout rotation; `None` for a +/// single-line attribute. +pub(crate) fn attribute_cells(a: &AttributeEntity, document: &codec::CadDocument) -> Option { + let mut out = None; + build_attr_layout(attr_inputs(a), document, Some(&mut out)); + out +} + +fn attr_inputs(a: &AttributeEntity) -> AttrTextInputs<'_> { + AttrTextInputs { + value: &a.value, + insertion_point: a.insertion_point, + alignment_point: a.alignment_point, + height: a.height, + rotation: a.rotation, + width_factor: a.width_factor, + oblique_angle: a.oblique_angle, + text_style: &a.text_style, + text_generation_flags: a.text_generation_flags, + horizontal_alignment: a.horizontal_alignment, + vertical_alignment: a.vertical_alignment, + normal: a.normal, + mtext_flag: a.mtext_flag, + is_multiline: a.is_multiline, + line_count: a.line_count, + } +} + impl RenderConvertible for AttributeEntity { fn to_render(&self, document: &codec::CadDocument) -> Option { Some(build_attr_render( diff --git a/src/entities/text_support.rs b/src/entities/text_support.rs index d8aef296d..2c1e36827 100644 --- a/src/entities/text_support.rs +++ b/src/entities/text_support.rs @@ -237,6 +237,42 @@ pub fn text_local_bounds( } } +/// Each character's [left, right] pen cell along a TEXT line (DXF specials +/// already resolved), measured as [`text_local_bounds`] lays the line out: +/// the shaper's glyph cells for a TrueType font or a script that needs +/// shaping, else the stroke font's advances. +pub fn text_char_cells(font_name: &str, text: &str, height: f32, width_factor: f32) -> Vec<(f32, f32)> { + let face = Face::resolve(font_name); + let scale = height / 9.0; + let wf = width_factor.abs().clamp(0.01, 100.0); + let shaping_family = face.ttf_family().or_else(|| { + crate::scene::text::web_font::requires_shaping(text) + .then(|| crate::scene::text::web_font::primary_script().family()) + }); + if let Some(run) = shaping_family.and_then(|family| crate::scene::text::ttf_glyph::shape_run(family, text)) { + return crate::scene::text::ttf_glyph::char_cells(&run, text) + .into_iter() + .map(|(x0, x1)| (x0 * scale * wf, x1 * scale * wf)) + .collect(); + } + let mut cursor = 0.0_f32; + text.chars() + .map(|ch| { + let advance = if ch == ' ' { + face.word_spacing() + } else { + match face.glyph(ch) { + Some(glyph) => glyph.advance + face.spacing_after(ch), + None => 6.0 + face.letter_spacing(), + } + }; + let cell = (cursor * scale * wf, (cursor + advance) * scale * wf); + cursor += advance; + cell + }) + .collect() +} + /// Expand DXF `%%x` special-character sequences that appear in both TEXT and MTEXT values: /// - `%%d` / `%%D` → `°` /// - `%%p` / `%%P` → `±` @@ -998,6 +1034,34 @@ pub fn measure_word( w } +/// Per-character cells of a word laid out by the shaper — the visual +/// [left, right] of each character in logical order, from the word start, +/// in drawing units — when the word is shaped (a TrueType font or a script +/// that needs shaping), as [`measure_word`] measures it. A glyph that +/// covers several characters (a ligature) shares its cell among them. +pub fn word_cells( + text: &str, + state: &RunState, + entity_h: f32, + base_wf: f32, + base_font: &str, +) -> Option> { + let scale = run_scale(state, entity_h, base_wf); + let font_name = resolve_font(state, base_font); + let face = Face::resolve(&font_name); + let family = face.ttf_family().or_else(|| { + crate::scene::text::web_font::requires_shaping(text) + .then(|| crate::scene::text::web_font::primary_script().family()) + })?; + let run = crate::scene::text::ttf_glyph::shape_run(family, text)?; + Some( + crate::scene::text::ttf_glyph::char_cells(&run, text) + .into_iter() + .map(|(x0, x1)| (x0 * scale, x1 * scale)) + .collect(), + ) +} + pub fn measure_space(state: &RunState, entity_h: f32, base_wf: f32, base_font: &str) -> f32 { let scale = run_scale(state, entity_h, base_wf); let font_name = resolve_font(state, base_font); @@ -1356,6 +1420,10 @@ pub struct GlyphBox { pub ymin: f32, pub ymax: f32, pub is_rtl: bool, + /// The same cell unrotated, relative to the insertion point + /// [left, bottom, right, top]: exact for rotated text, where the world + /// box above is only the bounds of two corners. + pub local: [f32; 4], } /// Output of [`layout_mtext`]: stroke groups + the geometry the caller @@ -2098,6 +2166,7 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout { ymin: ay.min(by), ymax: ay.max(by), is_rtl: sub.is_rtl, + local: [line_lx + cursor_start, line_ly, line_lx + cursor_start, line_ly + caret_h], }); vis += 1; } @@ -2324,25 +2393,41 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout { } else { measure_word(text, &atom.state, entity_h, base_wf, &base_font_name) }; + // The shaper's own glyph cells (visual positions, + // per character in logical order); evenly divided + // slots only when the word is not shaped. let slot_w = word_w / count as f32; - for ci in 0..count { - let cx = cursor_x + ci as f32 * slot_w; - let (ax, ay) = to_world(line_base_x, line_base_y, cx, ly); - let (bx, by) = to_world(line_base_x, line_base_y, cx + slot_w, ly + run_h); + let cells = word_cells(text, &atom.state, entity_h, base_wf, &base_font_name) + .unwrap_or_else(|| { + (0..count) + .map(|ci| { + let x = (count - 1 - ci) as f32 * slot_w; + (x, x + slot_w) + }) + .collect() + }); + for (ci, (x0, x1)) in cells.into_iter().enumerate() { + let (cx0, cx1) = (cursor_x + x0, cursor_x + x1); + let (ax, ay) = to_world(line_base_x, line_base_y, cx0, ly); + let (bx, by) = to_world(line_base_x, line_base_y, cx1, ly + run_h); glyph_boxes.push(GlyphBox { - vis: atom.char_offset + count - 1 - ci, + vis: atom.char_offset + ci, xmin: ax.min(bx), xmax: ax.max(bx), ymin: ay.min(by), ymax: ay.max(by), is_rtl: true, + local: [line_lx + cx0, line_ly + ly, line_lx + cx1, line_ly + ly + run_h], }); } } else { // Per-character boxes, advancing exactly as // `measure_word` does so they track the glyphs. + // A shaped word (TrueType or a complex script) + // takes the shaper's cells, as it draws. let scale = run_scale(&atom.state, entity_h, base_wf); let face = Face::resolve(&font_name); + let shaped = word_cells(text, &atom.state, entity_h, base_wf, &base_font_name); let mut cx = cursor_x; for (ci, ch) in text.chars().enumerate() { let adv = match face.glyph(ch) { @@ -2351,8 +2436,12 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout { } None => (6.0 + face.letter_spacing() * tracking) * scale, }; - let (ax, ay) = to_world(line_base_x, line_base_y, cx, ly); - let (bx, by) = to_world(line_base_x, line_base_y, cx + adv, ly + run_h); + let (cx0, cx1) = match shaped.as_ref().and_then(|cells| cells.get(ci)) { + Some(&(x0, x1)) => (cursor_x + x0, cursor_x + x1), + None => (cx, cx + adv), + }; + let (ax, ay) = to_world(line_base_x, line_base_y, cx0, ly); + let (bx, by) = to_world(line_base_x, line_base_y, cx1, ly + run_h); glyph_boxes.push(GlyphBox { vis: atom.char_offset + ci, xmin: ax.min(bx), @@ -2360,6 +2449,7 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout { ymin: ay.min(by), ymax: ay.max(by), is_rtl: false, + local: [line_lx + cx0, line_ly + ly, line_lx + cx1, line_ly + ly + run_h], }); cx += adv; } @@ -2487,6 +2577,7 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout { ymin, ymax, is_rtl: false, + local: [line_lx + x0, line_ly + valign_dy, line_lx + x1, line_ly + top], }); } } @@ -2603,6 +2694,7 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout { ymin: ay.min(by), ymax: ay.max(by), is_rtl: false, + local: [line_lx + cursor_x, line_ly, line_lx + cursor_x + adv, line_ly + run_h], }); } cursor_x += adv; diff --git a/src/scene/convert/tessellate.rs b/src/scene/convert/tessellate.rs index a6ae7f710..d7afd0298 100644 --- a/src/scene/convert/tessellate.rs +++ b/src/scene/convert/tessellate.rs @@ -85,12 +85,14 @@ const FIELD_BOX_BELOW: f64 = 0.345; const FIELD_BOX_ABOVE: f64 = 1.19; /// FIELDDISPLAY boxes: each field's shown text, one box per line it spans, -/// placed from the text layout itself — MTEXT's per-character layout cells -/// (the editor's glyph boxes) and TEXT / attribute pen advances, the same -/// layout and shaping the glyphs are drawn with — so formatting codes, -/// missing glyphs or shaped scripts before a field do not shift it. A box -/// spans the field's character cells horizontally and FIELD_BOX_BELOW / -/// FIELD_BOX_ABOVE vertically. `None` when the host's fields cannot be +/// placed from the text layout itself, in the text's own (rotated) frame — +/// MTEXT and multi-line attributes from the layout's character cells, TEXT +/// and single-line attributes from each line's character cells (the +/// shaper's glyph cells for TrueType fonts and shaped scripts, else the +/// stroke font's advances) — so formatting codes, missing glyphs, shaped or +/// right-to-left scripts before a field do not shift it. A box spans the +/// field's character cells along the line and FIELD_BOX_BELOW / +/// FIELD_BOX_ABOVE across it. `None` when the host's fields cannot be /// located in its text (the whole text is boxed then). fn field_run_corner_groups( document: &codec::CadDocument, @@ -98,44 +100,61 @@ fn field_run_corner_groups( stroke_groups: &[crate::scene::convert::acad_to_render::TextStroke], anno: f64, ) -> Option> { - use crate::entities::text_support::{mtext_visible_count, resolve_dxf_special_chars, text_local_bounds}; + use crate::entities::text_support::{ + mtext_visible_count, resolve_dxf_special_chars, resolve_text_style, text_char_cells, GlyphBox, + }; let handle = entity.common().handle; - // Lines of a field in a local frame: [left, baseline, right, height]. - let mut lines: Vec<[f64; 4]> = Vec::new(); - let (origin, rotation) = match entity { + // Field lines as (origin, rotation, [left, baseline, right, height]) in + // the origin's frame. + let mut lines: Vec<([f64; 2], f64, [f64; 4])> = Vec::new(); + // Group a field's layout cells by line (a shared baseline). + let from_cells = |lines: &mut Vec<([f64; 2], f64, [f64; 4])>, + cells: &[GlyphBox], + text: &str, + spans: &[std::ops::Range], + origin: [f64; 2], + rotation: f64| + -> Option<()> { + for span in spans { + let first = mtext_visible_count(text.get(..span.start)?); + let last = mtext_visible_count(text.get(..span.end)?); + let mut line: Option<[f64; 4]> = None; + for b in cells.iter().filter(|b| (first..last).contains(&b.vis)) { + let [l, base, r, top] = b.local.map(|v| v as f64 * anno); + let h = top - base; + line = Some(match line { + Some(c) if (c[1] - base).abs() <= 1e-3 * h.max(c[3]) => { + [c[0].min(l), c[1], c[2].max(r), c[3].max(h)] + } + Some(c) => { + lines.push((origin, rotation, c)); + [l, base, r, h] + } + None => [l, base, r, h], + }); + } + lines.extend(line.map(|c| (origin, rotation, c))); + } + Some(()) + }; + match entity { EntityType::MText(m) => { let spans = codec::fields::field_spans(document, handle, &m.value)?; - let mut local = m.clone(); - local.insertion_point = codec::types::Vector3::new(0.0, 0.0, 0.0); - local.rotation = 0.0; - let boxes = crate::entities::mtext::glyph_boxes(&local, document); - for span in spans { - let first = mtext_visible_count(m.value.get(..span.start)?); - let last = mtext_visible_count(m.value.get(..span.end)?); - let mut line: Option<[f64; 4]> = None; - for b in boxes.iter().filter(|b| (first..last).contains(&b.vis)) { - let (l, base, r, h) = (b.xmin as f64, b.ymin as f64, b.xmax as f64, (b.ymax - b.ymin) as f64); - line = Some(match line { - Some(c) if (c[1] - base).abs() <= 1e-3 * h.max(c[3]) => { - [c[0].min(l), c[1], c[2].max(r), c[3].max(h)] - } - Some(c) => { - lines.push(c); - [l, base, r, h] - } - None => [l, base, r, h], - }); - } - lines.extend(line); - } - let rotation = stroke_groups - .iter() - .find_map(|g| g.run.as_ref().map(|run| run.rotation as f64)) - .unwrap_or(m.rotation); - for line in &mut lines { - *line = line.map(|v| v * anno); - } - ([m.insertion_point.x, m.insertion_point.y], rotation) + let cells = crate::entities::mtext::glyph_boxes(m, document); + let flip = if resolve_text_style(&m.style, document).is_upside_down { std::f64::consts::PI } else { 0.0 }; + from_cells( + &mut lines, + &cells, + &m.value, + &spans, + [m.insertion_point.x, m.insertion_point.y], + m.rotation + flip, + )?; + } + EntityType::AttributeEntity(a) if crate::entities::attribute::attribute_cells(a, document).is_some() => { + let spans = codec::fields::field_spans(document, handle, &a.value)?; + let (cells, origin, rotation) = crate::entities::attribute::attribute_cells(a, document)?; + from_cells(&mut lines, &cells, &a.value, &spans, origin, rotation as f64)?; } EntityType::Text(_) | EntityType::AttributeEntity(_) => { let text = match entity { @@ -144,28 +163,38 @@ fn field_run_corner_groups( _ => return None, }; let spans = codec::fields::field_spans(document, handle, text)?; - // One laid-out line: a multi-line attribute keeps the whole box. - let [group] = stroke_groups else { return None }; - let run = group.run.as_ref()?; - let advance = |s: &str| { - text_local_bounds(&run.font, &resolve_dxf_special_chars(s), run.height, run.width_factor, run.oblique) - .map_or(0.0, |b| b.advance as f64) - }; - for span in spans { - let (l, r) = (advance(text.get(..span.start)?), advance(text.get(..span.end)?)); - lines.push([l * anno, 0.0, r * anno, run.height as f64 * anno]); + // One laid-out line per `\P`-separated line, in order. + let mut start = 0; + for (line_text, group) in text.split("\\P").zip(stroke_groups) { + let range = start..start + line_text.len(); + start = range.end + 2; + let run = group.run.as_ref()?; + let resolved = resolve_dxf_special_chars(line_text); + let cells = text_char_cells(&run.font, &resolved, run.height, run.width_factor); + let char_at = |byte: usize| resolve_dxf_special_chars(&line_text[..byte]).chars().count(); + for span in &spans { + let (a, b) = (span.start.max(range.start), span.end.min(range.end)); + if a >= b { + continue; + } + let picked = cells.get(char_at(a - range.start)..char_at(b - range.start)).unwrap_or_default(); + let l = picked.iter().map(|c| c.0).fold(f32::INFINITY, f32::min) as f64; + let r = picked.iter().map(|c| c.1).fold(f32::NEG_INFINITY, f32::max) as f64; + if l.is_finite() && r.is_finite() { + lines.push((group.origin, run.rotation as f64, [l * anno, 0.0, r * anno, run.height as f64 * anno])); + } + } } - (group.origin, run.rotation as f64) } _ => return None, - }; - let (sin_r, cos_r) = rotation.sin_cos(); - let to_world = |x: f64, y: f64| [origin[0] + x * cos_r - y * sin_r, origin[1] + x * sin_r + y * cos_r]; + } Some( lines .into_iter() - .filter(|[l, _, r, _]| r > l) - .map(|[l, base, r, h]| { + .filter(|(_, _, [l, _, r, _])| r > l) + .map(|(origin, rotation, [l, base, r, h])| { + let (sin_r, cos_r) = rotation.sin_cos(); + let to_world = |x: f64, y: f64| [origin[0] + x * cos_r - y * sin_r, origin[1] + x * sin_r + y * cos_r]; let (b, t) = (base - FIELD_BOX_BELOW * h, base + FIELD_BOX_ABOVE * h); [to_world(l, b), to_world(r, b), to_world(r, t), to_world(l, t)] }) @@ -1508,17 +1537,16 @@ pub fn tessellate( // entity still has a hit-test target via snap_pts. With SDF on // this is the normal path (strokes suppressed) and the wire // also carries the glyph quads built above. - if bins.is_empty() { - let mut wires: Vec = Vec::new(); - // FIELDDISPLAY: a field shows on a gray box behind its - // glyphs, on screen only. - if text_aabb != WireModel::UNBOUNDED_AABB - && crate::entities::field::display() + // FIELDDISPLAY: a field shows on a gray box behind its glyphs, + // on screen only — for SDF glyphs and shaped stroke text alike. + let mut field_background: Option = None; + if crate::entities::field::display() && crate::entities::field::hosts_field(document, entity) { let runs = field_run_corner_groups(document, entity, &stroke_groups, anno); let corner_groups = match entity { _ if runs.is_some() => runs.unwrap_or_default(), + _ if text_aabb == WireModel::UNBOUNDED_AABB => Vec::new(), EntityType::MText(m) => { let rotation = stroke_groups .iter() @@ -1550,7 +1578,7 @@ pub fn tessellate( } } if !ft.is_empty() { - wires.push(WireModel { + field_background = Some(WireModel { display_visible: true, plot_visible: false, name: name.clone(), @@ -1565,6 +1593,9 @@ pub fn tessellate( }); } } + if bins.is_empty() { + let mut wires: Vec = Vec::new(); + wires.extend(field_background.take()); // MTEXT background and frame follow the glyph bounds. if text_aabb != WireModel::UNBOUNDED_AABB { if let EntityType::MText(m) = entity { @@ -1801,6 +1832,7 @@ pub fn tessellate( } let mut out: Vec = Vec::new(); + out.extend(field_background); let mut is_first = true; for bin in bins { let wire_color = match bin.color { diff --git a/src/scene/text/ttf_glyph.rs b/src/scene/text/ttf_glyph.rs index b9fdd6be1..50ec1ab85 100644 --- a/src/scene/text/ttf_glyph.rs +++ b/src/scene/text/ttf_glyph.rs @@ -577,6 +577,30 @@ pub struct ShapedRun { pub glyphs: Vec, /// Total pen advance of the run (9-unit). pub advance: f32, + /// Every laid-out glyph (blank ones too) as the byte range of the text it + /// shows and its visual [left, right] pen cell, 9-unit. + pub cells: Vec<(usize, usize, f32, f32)>, +} + +/// Each character's visual [left, right] cell (9-unit) in logical order: +/// the cell of the glyph that shows it; a glyph showing several characters +/// shares its cell among them in reading order. +pub fn char_cells(run: &ShapedRun, text: &str) -> Vec<(f32, f32)> { + text.char_indices() + .map(|(byte, _)| { + let Some(&(start, end, x0, x1)) = + run.cells.iter().find(|(start, end, _, _)| (*start..(*end).max(start + 1)).contains(&byte)) + else { + return (0.0, 0.0); + }; + let chars: Vec = text[start..end.min(text.len())].char_indices().map(|(i, _)| start + i).collect(); + let (n, k) = (chars.len().max(1), chars.iter().position(|&b| b == byte).unwrap_or(0)); + let rtl = text[start..end.min(text.len())].chars().any(crate::entities::text_support::is_rtl_char); + let slot = (x1 - x0) / n as f32; + let k = if rtl { n - 1 - k } else { k }; + (x0 + slot * k as f32, x0 + slot * (k + 1) as f32) + }) + .collect() } type ShapeCache = HashMap<(String, String), Option>>; @@ -881,10 +905,12 @@ fn build_shaped(_family: &str, text: &str) -> Option { buffer.shape_until_scroll(&mut font_system, false); let mut glyphs = Vec::new(); + let mut cells = Vec::new(); let mut advance = 0.0_f32; for run in buffer.layout_runs() { advance = advance.max(run.line_w * px_to_9); for glyph in run.glyphs.iter() { + cells.push((glyph.start, glyph.end, glyph.x * px_to_9, (glyph.x + glyph.w) * px_to_9)); let face_index = font_system .db_mut() .face(glyph.font_id) @@ -923,7 +949,7 @@ fn build_shaped(_family: &str, text: &str) -> Option { if advance <= 0.0 && glyphs.is_empty() { None } else { - Some(ShapedRun { glyphs, advance }) + Some(ShapedRun { glyphs, advance, cells }) } } @@ -986,10 +1012,12 @@ fn build_shaped(family: &str, text: &str) -> Option { let px_to_9 = CAP_UNITS * upem_p / (SHAPE_FS * cap_p); let mut glyphs: Vec = Vec::new(); + let mut cells = Vec::new(); let mut advance = 0.0_f32; for run in buf.layout_runs() { advance = advance.max(run.line_w * px_to_9); for g in run.glyphs.iter() { + cells.push((g.start, g.end, g.x * px_to_9, (g.x + g.w) * px_to_9)); let face_index = fs.db_mut().face(g.font_id).map(|f| f.index).unwrap_or(0); let Some(font) = fs.get_font(g.font_id, g.font_weight) else { continue; @@ -1028,7 +1056,7 @@ fn build_shaped(family: &str, text: &str) -> Option { if advance <= 0.0 && glyphs.is_empty() { return None; } - Some(ShapedRun { glyphs, advance }) + Some(ShapedRun { glyphs, advance, cells }) } #[cfg(test)]