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 f1e8efe20..0569f6079 100644 --- a/src/app/commands/display.rs +++ b/src/app/commands/display.rs @@ -512,6 +512,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 81a56df1b..d3b8b68bd 100644 --- a/src/app/commands/field.rs +++ b/src/app/commands/field.rs @@ -17,6 +17,80 @@ 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()); + } + // 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 { + 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"); + 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 4e28bdc66..d41d50dc4 100644 --- a/src/app/commands/mod.rs +++ b/src/app/commands/mod.rs @@ -708,6 +708,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 d876c13fb..45d0cf71a 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -2163,6 +2163,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); @@ -3297,7 +3305,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( @@ -3800,8 +3808,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/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/field.rs b/src/entities/field.rs index a5c2fd25b..2891e6ed4 100644 --- a/src/entities/field.rs +++ b/src/entities/field.rs @@ -108,12 +108,36 @@ pub fn resolve(document: &CadDocument, host: Handle) -> Option { codec::fields::resolve(document, host, &OcsFieldContext(Some(document))) } -pub fn resolve_handle( - document: &CadDocument, - field: Handle, - host: Handle, -) -> Option { - codec::fields::resolve_handle(document, field, 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) +} + +/// 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 `%<…>%` @@ -477,7 +501,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/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 88209381b..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` → `±` @@ -488,6 +524,33 @@ fn font_stem(name: &str) -> String { .to_string() } +/// 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() + .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() +} + /// 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 @@ -971,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); @@ -1329,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 @@ -2071,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; } @@ -2297,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) { @@ -2324,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), @@ -2333,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; } @@ -2460,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], }); } } @@ -2576,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/io/mod.rs b/src/io/mod.rs index c414812f1..0ad3565bc 100644 --- a/src/io/mod.rs +++ b/src/io/mod.rs @@ -797,6 +797,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)) } @@ -1050,6 +1052,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 3d2bd2953..7c7e2a720 100644 --- a/src/scene/convert/tessellate.rs +++ b/src/scene/convert/tessellate.rs @@ -78,6 +78,130 @@ fn oriented_text_corners( ] } +/// 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, 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, + entity: &EntityType, + stroke_groups: &[crate::scene::convert::acad_to_render::TextStroke], + anno: f64, +) -> Option> { + use crate::entities::text_support::{ + mtext_visible_count, resolve_dxf_special_chars, resolve_text_style, text_char_cells, GlyphBox, + }; + let handle = entity.common().handle; + // 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 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 { + 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 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])); + } + } + } + } + _ => return None, + } + Some( + lines + .into_iter() + .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)] + }) + .collect(), + ) +} + fn oriented_mtext_corner_groups( verts: &[crate::scene::pipeline::text_gpu::TextVertex], text: &codec::MText, @@ -1431,15 +1555,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() @@ -1471,7 +1596,7 @@ pub fn tessellate( } } if !ft.is_empty() { - wires.push(WireModel { + field_background = Some(WireModel { display_visible: true, plot_visible: false, name: name.clone(), @@ -1486,6 +1611,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 { @@ -1722,6 +1850,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/mod.rs b/src/scene/mod.rs index 7e64c2440..5c8290ecf 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -568,6 +568,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]> { @@ -3408,6 +3411,64 @@ 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; + /// 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()); + } + } + // 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); + } + pub fn bump_entities_with_parametric_driven( &mut self, changes: &[(Handle, ChangeKind)], @@ -11727,6 +11788,7 @@ vis_index={:.1} visible_probe={:.1}", _ => {} } } + index.block_roots = roots; index } 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)]