From 0b5d4bd311f3b1134aa8b06f1ba2224bc1138c2a Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Sun, 16 Aug 2026 23:57:11 +0300 Subject: [PATCH] fix(mtext): honor background contrast Preserve stored dimension fills and rotate text backgrounds with their labels. Refs #774 --- src/scene/cache/block_cache.rs | 71 ++++++++++++++++-- src/scene/convert/tess.rs | 43 +++++------ src/scene/convert/tessellate.rs | 129 ++++++++++++++++++++++---------- 3 files changed, 171 insertions(+), 72 deletions(-) diff --git a/src/scene/cache/block_cache.rs b/src/scene/cache/block_cache.rs index c860e938a..14d57accb 100644 --- a/src/scene/cache/block_cache.rs +++ b/src/scene/cache/block_cache.rs @@ -60,6 +60,9 @@ pub struct LocalWire { /// equals the sub-entity's resolved colour. For colour-split MTEXT /// (`\C`/`\c` inline overrides) each wire carries its own override colour. pub color: [f32; 4], + pub contrast_bg: Option<[f32; 4]>, + pub preserve_color: bool, + pub canvas_color: bool, pub aci: u8, pub pattern_length: f32, pub pattern: [f32; 8], @@ -752,6 +755,23 @@ fn tessellate_sub_local( .chain(wire.text_verts.iter().map(|v| v.pos)), ); let is_fill_only = wire.points.is_empty() && !wire.fill_tris.is_empty(); + let mtext_has_background = matches!( + sub, + EntityType::MText(text) if text.background_fill_flags & 0x03 != 0 + ); + let preserve_color = mtext_has_background && is_fill_only; + let canvas_color = is_fill_only + && matches!( + sub, + EntityType::MText(text) if text.background_fill_flags & 0x02 != 0 + ); + let contrast_bg = if !preserve_color + && (!wire.text_verts.is_empty() || !wire.points.is_empty()) + { + tessellate::explicit_mtext_background(sub) + } else { + None + }; // A wire whose colour differs from the entity's resolved base colour // carries an explicit per-segment override (e.g. an MTEXT `\C1;` inline // colour). ByBlock / layer-0 inheritance applies only to wires still on @@ -773,6 +793,9 @@ fn tessellate_sub_local( pick_tris: wire.pick_tris, pick_tris_low: wire.pick_tris_low, color: wire.color, + contrast_bg, + preserve_color, + canvas_color, aci, pattern_length: pat_len, pattern: pat, @@ -1316,6 +1339,9 @@ pub(crate) fn fade_toward_bg(color: [f32; 4], bg: [f32; 4]) -> [f32; 4] { #[derive(Clone, Debug, PartialEq, Eq, Hash)] struct StyleKey { color: [u32; 4], + contrast_bg: Option<[u32; 4]>, + preserve_color: bool, + canvas_color: bool, pattern_length: u32, pattern: [u32; 8], line_weight_px: u32, @@ -1342,6 +1368,9 @@ struct StyleKey { #[derive(Default, Debug)] struct BatchEntry { color: [f32; 4], + contrast_bg: Option<[f32; 4]>, + preserve_color: bool, + canvas_color: bool, pattern_length: f32, pattern: [f32; 8], line_weight_px: f32, @@ -1415,6 +1444,9 @@ struct Batches { impl BatchEntry { fn new( color: [f32; 4], + contrast_bg: Option<[f32; 4]>, + preserve_color: bool, + canvas_color: bool, pat_len: f32, pat: [f32; 8], lw_px: f32, @@ -1431,6 +1463,9 @@ impl BatchEntry { // itself — the empty `points` field is enough at finalize time. Self { color, + contrast_bg, + preserve_color, + canvas_color, pattern_length: pat_len, pattern: pat, line_weight_px: lw_px, @@ -1454,18 +1489,28 @@ impl Batches { .closed .into_iter() .chain(self.by_style.into_values()) - .map(|b| { + .map(|mut b| { let aabb = if b.min_x.is_infinite() { WireModel::UNBOUNDED_AABB } else { [b.min_x, b.min_y, b.max_x, b.max_y] }; - // RAW colour came from `tessellate_sub_local` (and from - // `expand_defn`'s ByBlock fallbacks); apply `adapt_to_bg` - // now so each render against a different bg gets the - // right pure-black ↔ pure-white flip without rebuilding - // the cached defn. - let color = crate::scene::view::render::adapt_to_bg(b.color, bg_color); + let contrast_bg = b.contrast_bg.unwrap_or(bg_color); + let color = if b.canvas_color { + bg_color + } else if b.preserve_color { + b.color + } else { + crate::scene::view::render::adapt_to_bg(b.color, contrast_bg) + }; + if !b.preserve_color { + for vertex in &mut b.text_verts { + vertex.color = crate::scene::view::render::adapt_to_bg( + vertex.color, + contrast_bg, + ); + } + } WireModel { taper_widths: Vec::new(), world_width: b.world_width, @@ -1510,6 +1555,9 @@ impl Batches { fn style_key( color: [f32; 4], + contrast_bg: Option<[f32; 4]>, + preserve_color: bool, + canvas_color: bool, pat_len: f32, pat: [f32; 8], lw_px: f32, @@ -1527,6 +1575,9 @@ fn style_key( color[2].to_bits(), color[3].to_bits(), ], + contrast_bg: contrast_bg.map(|color| color.map(f32::to_bits)), + preserve_color, + canvas_color, pattern_length: pat_len.to_bits(), pattern: [ pat[0].to_bits(), @@ -1943,6 +1994,9 @@ fn emit_wire( let key = style_key( final_color, + lw.contrast_bg, + lw.preserve_color, + lw.canvas_color, final_pat_len, final_pat, final_lw_px, @@ -1966,6 +2020,9 @@ fn emit_wire( let entry = out.by_style.entry(key).or_insert_with(|| { BatchEntry::new( final_color, + lw.contrast_bg, + lw.preserve_color, + lw.canvas_color, final_pat_len, final_pat, final_lw_px, diff --git a/src/scene/convert/tess.rs b/src/scene/convert/tess.rs index f57b025fa..2d9e42c65 100644 --- a/src/scene/convert/tess.rs +++ b/src/scene/convert/tess.rs @@ -530,7 +530,8 @@ pub(crate) fn tessellate_entity( let (entity_color, pattern_length, pattern, line_weight_px, aci) = view::render::render_style_for_viewport(document, e, active_viewport); - let entity_color = view::render::adapt_to_bg(entity_color, bg_color); + let contrast_bg = convert::tessellate::text_contrast_background(e, bg_color); + let entity_color = view::render::adapt_to_bg(entity_color, contrast_bg); let entity_color = fade_if_locked(document, e, entity_color, bg_color); let lt_scale = document.header.linetype_scale as f32 * e.common().linetype_scale as f32; let lt_name = view::render::linetype_name_for_viewport(document, e, active_viewport); @@ -540,8 +541,7 @@ pub(crate) fn tessellate_entity( let pslt_factor = 1.0_f32; // ── Proxy entity: draw its cached preview ─────────────────────────────── // - // An entity from an application we have no reader for (e.g. an Autodesk - // Raster Design embedded raster image) arrives as `Unknown`. Its own data is + // An unsupported application-specific entity arrives as `Unknown`. Its data is // a private format we cannot decode — but it usually ships a proxy-graphics // blob, the vector preview its author cached for exactly this case. Draw it // when the object enabler is missing, so the entity @@ -738,26 +738,8 @@ pub(crate) fn tessellate_entity( }]; } - // ── Dimension baked-block fast path ───────────────────────────────────── - // - // A DIMENSION carries the block "that contains the entities that make up - // the dimension picture" (DXF group 2), and that block IS the picture: - // AutoCAD requires it and draws it, BricsCAD draws it when present and only - // falls back to rendering from the dimension variables when it is missing. - // OCS re-derived the picture from DIMVARS every time instead, which means a - // drawing whose style disagrees with what it actually drew comes out wrong - // — a DIMTXT stored in different units from the DIMSCALE applied to it, or - // a per-object override that is already in drawing units, and the text and - // extension lines land hundreds of times too large. - // - // Drawing the block puts OCS on the same footing as the CAD that wrote the - // file: it shows what the file says it looks like. Re-deriving stays as the - // fallback, for a dimension with no block (one OCS just created, or one - // whose block was dropped because it was edited). - // - // Annotative dimensions keep the old path: their several representations - // are separate blocks, and choosing between them is what the annotation - // machinery already does. The doctrine above assumes one picture. + // Render non-annotative dimensions from their stored picture block. + // Rebuild geometry only when no usable block exists. if let EntityType::Dimension(dim) = e { let baked = Some(dim.base().block_name.trim()) .filter(|name| !name.is_empty()) @@ -800,7 +782,18 @@ pub(crate) fn tessellate_entity( let color_layer0 = !has_book_color && view::render::is_effective_layer_zero(&sub.common().layer) && sub.common().color == acadrust::types::Color::ByLayer; + let contrast_bg = convert::tessellate::text_contrast_background(sub, bg_color); + let sub_color = view::render::adapt_to_bg( + view::render::render_style_for_viewport( + document, + sub, + active_viewport, + ) + .0, + contrast_bg, + ); let style = context.style_for(document, sub); + let resolved_color = view::render::adapt_to_bg(style.0, contrast_bg); let mut placed = sub.clone(); placed.apply_transform(&context.transform); let sub_wires = tessellate_entity( @@ -823,8 +816,8 @@ pub(crate) fn tessellate_entity( if sel { wire.selected = true; wire.color = WireModel::SELECTED; - } else if color_byblock || color_layer0 { - wire.color = view::render::adapt_to_bg(style.0, bg_color); + } else if (color_byblock || color_layer0) && wire.color == sub_color { + wire.color = resolved_color; wire.aci = style.4; } wires.push(wire); diff --git a/src/scene/convert/tessellate.rs b/src/scene/convert/tessellate.rs index 770632a3f..7dec844ce 100644 --- a/src/scene/convert/tessellate.rs +++ b/src/scene/convert/tessellate.rs @@ -40,6 +40,68 @@ fn split_ds_xyz(x: f64, y: f64, z: f64) -> ([f32; 3], [f32; 3]) { ([xh, yh, zh], [xl, yl, zl]) } +fn oriented_text_corners( + verts: &[crate::scene::pipeline::text_gpu::TextVertex], + origin: [f64; 2], + rotation: f64, + pad: f64, +) -> [[f64; 2]; 4] { + let (sin_r, cos_r) = rotation.sin_cos(); + let mut bounds = [f64::MAX, f64::MAX, f64::MIN, f64::MIN]; + for vertex in verts { + let x = vertex.pos[0] as f64 + vertex.pos_low[0] as f64 - origin[0]; + let y = vertex.pos[1] as f64 + vertex.pos_low[1] as f64 - origin[1]; + let local_x = x * cos_r + y * sin_r; + let local_y = -x * sin_r + y * cos_r; + bounds[0] = bounds[0].min(local_x); + bounds[1] = bounds[1].min(local_y); + bounds[2] = bounds[2].max(local_x); + bounds[3] = bounds[3].max(local_y); + } + let [left, bottom, right, top] = [ + bounds[0] - pad, + bounds[1] - pad, + bounds[2] + pad, + bounds[3] + pad, + ]; + let to_world = |x: f64, y: f64| { + [ + origin[0] + x * cos_r - y * sin_r, + origin[1] + x * sin_r + y * cos_r, + ] + }; + [ + to_world(left, bottom), + to_world(right, bottom), + to_world(right, top), + to_world(left, top), + ] +} + +pub(crate) fn explicit_mtext_background(entity: &EntityType) -> Option<[f32; 4]> { + let EntityType::MText(text) = entity else { + return None; + }; + if text.background_fill_flags & 0x01 == 0 || text.background_fill_flags & 0x02 != 0 { + return None; + } + text.background_color.rgb().map(|(r, g, b)| { + [ + r as f32 / 255.0, + g as f32 / 255.0, + b as f32 / 255.0, + 1.0, + ] + }) +} + +pub(crate) fn text_contrast_background( + entity: &EntityType, + canvas: [f32; 4], +) -> [f32; 4] { + explicit_mtext_background(entity).unwrap_or(canvas) +} + /// Split each absolute f64 source point into double-single (high, low) f32 /// buffers in one pass — the relative-to-eye residual the GPU/CPU reconstruct /// to f64 precision at UTM-scale coordinates. @@ -440,13 +502,7 @@ pub fn tessellate( let entity_zf = entity_z(entity) as f64; let elev_v = entity_zf; - // Annotation scaling must preserve the entity's geometric attachment point. - // - // MTEXT's insertion_point is its attachment anchor (TopLeft, TopCenter, - // BottomRight, etc.). Scale every laid-out run around that point so the - // displayed text remains attached to the same grip at every annotation scale. - // - // Other text-like entities keep the existing first-run origin behaviour. + // Scale MTEXT around its attachment point. let ref_origin = match entity { EntityType::MText(m) => [ m.insertion_point.x, @@ -576,6 +632,10 @@ pub fn tessellate( .color .map(|c| [c[0], c[1], c[2], entity_color[3]]) .unwrap_or(entity_color); + let gcolor = crate::scene::view::render::adapt_to_bg( + gcolor, + text_contrast_background(entity, bg_color), + ); let quads = crate::scene::text::glyph_quads::layout_glyph_quads( &mut atlas, run.height, @@ -606,14 +666,7 @@ pub fn tessellate( .map(|[x, y, z]| [x, y, z]) .collect(); - // Pick box straight from the rendered glyph quads — the true - // text extent. entity_aabb is unreliable for MTEXT (its box sits - // beside the laid-out glyphs), so derive the AABB from - // `sdf_verts` (accumulate in f64, reconstruct high+low, then cast - // to f32 once) and stop the generic stamp clobbering it (tess.rs - // guards on `text_verts`). Computed here so both the bins-empty - // and the bins-non-empty (tolerance box + text) paths can stamp - // it on the SDF text wire. + // Derive the pick box from the rendered glyph quads. let text_aabb = if !sdf_verts.is_empty() { let (mut nx, mut ny, mut xx, mut xy) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN); @@ -636,42 +689,33 @@ pub fn tessellate( // also carries the glyph quads built above. if bins.is_empty() { let mut wires: Vec = Vec::new(); - // MTEXT background fill / mask: an opaque rectangle behind - // the glyphs, emitted first so it renders under the text. - // Flag 0x01 → the entity's background-fill colour; 0x02 → - // the drawing-window (canvas) colour, which masks geometry - // behind the text like a wipeout. Box = glyph bounds padded - // by (background_scale - 1) × text height. + // MTEXT background and frame follow the glyph bounds. if text_aabb != WireModel::UNBOUNDED_AABB { if let EntityType::MText(m) = entity { let has_fill = m.background_fill_flags & 0x03 != 0; let has_frame = m.background_fill_flags & 0x10 != 0; if has_fill || has_frame { - // Padded box shared by the fill and the frame. - let th = (m.height * anno) as f32; - let pad = ((m.background_scale as f32) - 1.0).max(0.0) * th; - let [bnx, bny, bxx, bxy] = text_aabb; - let (l, b, r, t) = ( - (bnx - pad) as f64, - (bny - pad) as f64, - (bxx + pad) as f64, - (bxy + pad) as f64, + let text_rotation = stroke_groups + .iter() + .find_map(|group| { + group.run.as_ref().map(|run| run.rotation as f64) + }) + .unwrap_or(m.rotation); + let text_height = m.height * anno; + let pad = (m.background_scale - 1.0).max(0.0) * text_height; + let corners = oriented_text_corners( + &sdf_verts, + [m.insertion_point.x, m.insertion_point.y], + text_rotation, + pad, ); // Fill / mask — two triangles behind the glyphs. if has_fill { - // 0x02 (use the drawing-window colour) is a - // MASK — it wins when set, even alongside - // 0x01, so text flagged "drawing background" - // erases what's behind it (a dark box on a - // dark canvas = no visible colour), instead - // of painting the stored background_color. A - // plain 0x01 fill paints that colour. let fill_color = if m.background_fill_flags & 0x02 != 0 { bg_color } else { color_or_inherit(&m.background_color, bg_color) }; - let corners = [[l, b], [r, b], [r, t], [l, t]]; let mut ft = Vec::with_capacity(6); let mut ftl = Vec::with_capacity(6); for &k in &[0usize, 1, 2, 0, 2, 3] { @@ -713,8 +757,13 @@ pub fn tessellate( // Text frame — a closed rectangle in the text // colour around the same box. if has_frame { - let loop_xy = - [[l, b], [r, b], [r, t], [l, t], [l, b]]; + let loop_xy = [ + corners[0], + corners[1], + corners[2], + corners[3], + corners[0], + ]; let mut fp = Vec::with_capacity(5); let mut fpl = Vec::with_capacity(5); for &[x, y] in &loop_xy {