From 3c3ca89d9787cc2c922f020b28c0b6983af0c4a0 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Thu, 9 Jul 2026 16:41:53 +0300 Subject: [PATCH] =?UTF-8?q?feat(text):=20TEXTFILL=20=E2=80=94=20fill=20or?= =?UTF-8?q?=20hollow=20TrueType=20glyphs=20(#260)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TEXTFILL 1 (default) fills TrueType text; TEXTFILL 0 draws it hollow (outline only). Settable directly or via SETVAR, 0/1 or ON/OFF. SHX/LFF stroke fonts are unaffected. Reuses the existing stroke-SDF path: bake_glyph treats a TTF glyph as a stroke (outline) field instead of a signed fill field when TEXTFILL is off. Toggling resets the glyph atlas and re-tessellates text so quads pick up the re-baked tiles. Persisted in user settings. Co-Authored-By: Claude Opus 4.8 --- src/app/commands/mod.rs | 1 + src/app/commands/styleprops.rs | 23 +++++++++++++++++++++++ src/app/settings.rs | 7 ++++++- src/app/update/file.rs | 2 ++ src/scene/text/sdf_atlas.rs | 29 ++++++++++++++++++++++++++++- 5 files changed, 60 insertions(+), 2 deletions(-) diff --git a/src/app/commands/mod.rs b/src/app/commands/mod.rs index 20643bc33..c32f95ab3 100644 --- a/src/app/commands/mod.rs +++ b/src/app/commands/mod.rs @@ -328,6 +328,7 @@ inventory::submit!(crate::command::CommandRegistration { "TCASE", "TEXTMASK", "TEXTFIT", + "TEXTFILL", "TCOUNT", "ARCTEXT", // Jogged radius dimension. diff --git a/src/app/commands/styleprops.rs b/src/app/commands/styleprops.rs index 952024d6c..f44ba9df5 100644 --- a/src/app/commands/styleprops.rs +++ b/src/app/commands/styleprops.rs @@ -527,6 +527,7 @@ impl OpenCADStudio { cmd if matches!( cmd.split_whitespace().next().unwrap_or(""), "MIRRTEXT" + | "TEXTFILL" | "ATTREQ" | "ATTDIA" | "DIMASSOC" @@ -678,6 +679,23 @@ impl OpenCADStudio { .ok_or_else(|| "SETVAR: 0 or 1 required.".into()), None => Ok((format!("MIRRTEXT = {}", h.mirror_text as i32), false)), }, + // Global (not stored in the drawing): fill vs. hollow + // TrueType text. The active tab re-tessellates below. + "TEXTFILL" => match &value { + Some(v) => parse_bool(v) + .map(|b| { + crate::scene::text::sdf_atlas::set_textfill(b); + (format!("TEXTFILL = {}", b as i32), true) + }) + .ok_or_else(|| "SETVAR: 0 or 1 required.".into()), + None => Ok(( + format!( + "TEXTFILL = {}", + crate::scene::text::sdf_atlas::textfill() as i32 + ), + false, + )), + }, "ATTREQ" => match &value { Some(v) => parse_bool(v) .map(|b| { @@ -1227,6 +1245,11 @@ impl OpenCADStudio { } Err(e) => self.command_line.push_error(&e), } + // TEXTFILL reset the glyph atlas; re-tessellate so text picks + // up the re-baked filled / hollow tiles. + if name == "TEXTFILL" { + self.tabs[i].scene.bump_geometry(); + } } } diff --git a/src/app/settings.rs b/src/app/settings.rs index 42d43bda1..943091ee7 100644 --- a/src/app/settings.rs +++ b/src/app/settings.rs @@ -103,6 +103,8 @@ pub struct UserSettings { pub plugin_repos: Vec, /// Controls whether the TEXTEDIT command repeats automatically (0 = Multiple, 1 = Single). pub texteditmode: bool, + /// TEXTFILL: fill TrueType glyphs (true) or draw them hollow (false). + pub textfill: bool, /// When true, saving over an existing file first copies it to a sibling /// `.bak` so a faulty or accidental save can be recovered (#205). pub backup_on_save: bool, @@ -142,6 +144,7 @@ impl Default for UserSettings { disabled_plugins: Vec::new(), plugin_repos: Vec::new(), texteditmode: false, + textfill: true, backup_on_save: true, file_assoc_enabled: true, savetime_min: 10, @@ -190,6 +193,7 @@ impl UserSettings { } } "backup_on_save" => s.backup_on_save = val == "1", + "textfill" => s.textfill = val == "1", "file_assoc_enabled" => s.file_assoc_enabled = val == "1", "savetime_min" => { if let Ok(v) = val.parse::() { @@ -237,7 +241,7 @@ impl UserSettings { .collect::>() .join(","); let body = format!( - "dyn={}\northo={}\npolar={}\npolar_increment_deg={}\nosnap={}\notrack={}\ndefault_assoc_prompted={}\nsnap_modes={}\ndisabled_plugins={}\nplugin_repos={}\ntexteditmode={}\nbackup_on_save={}\nfile_assoc_enabled={}\nsavetime_min={}\nbg_color={}\npaper_bg_color={}\n", + "dyn={}\northo={}\npolar={}\npolar_increment_deg={}\nosnap={}\notrack={}\ndefault_assoc_prompted={}\nsnap_modes={}\ndisabled_plugins={}\nplugin_repos={}\ntexteditmode={}\ntextfill={}\nbackup_on_save={}\nfile_assoc_enabled={}\nsavetime_min={}\nbg_color={}\npaper_bg_color={}\n", b(self.dyn_input), b(self.ortho), b(self.polar), @@ -249,6 +253,7 @@ impl UserSettings { self.disabled_plugins.join(","), self.plugin_repos.join(","), self.texteditmode, + b(self.textfill), b(self.backup_on_save), b(self.file_assoc_enabled), self.savetime_min, diff --git a/src/app/update/file.rs b/src/app/update/file.rs index bb2515b45..7a9647378 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -79,6 +79,7 @@ impl OpenCADStudio { }, plugin_repos: self.plugin_repos.clone(), texteditmode: self.texteditmode, + textfill: crate::scene::text::sdf_atlas::textfill(), backup_on_save: self.backup_on_save, file_assoc_enabled: self.file_assoc_enabled, savetime_min: self.savetime_min, @@ -100,6 +101,7 @@ impl OpenCADStudio { self.disabled_plugins = s.disabled_plugins.iter().cloned().collect(); self.plugin_repos = s.plugin_repos.clone(); self.texteditmode = s.texteditmode; + crate::scene::text::sdf_atlas::set_textfill(s.textfill); self.backup_on_save = s.backup_on_save; self.file_assoc_enabled = s.file_assoc_enabled; self.savetime_min = s.savetime_min; diff --git a/src/scene/text/sdf_atlas.rs b/src/scene/text/sdf_atlas.rs index c7dc3128a..aabec4d8c 100644 --- a/src/scene/text/sdf_atlas.rs +++ b/src/scene/text/sdf_atlas.rs @@ -39,6 +39,24 @@ pub fn text_atlas() -> &'static Mutex { ATLAS.get_or_init(|| Mutex::new(GlyphAtlas::new(1024, 1024))) } +/// TEXTFILL system variable — `true` fills TrueType glyphs (default), `false` +/// draws them hollow (outline only). Global, like the AutoCAD system variable. +static TEXTFILL: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(true); + +pub fn textfill() -> bool { + TEXTFILL.load(std::sync::atomic::Ordering::Relaxed) +} + +/// Set TEXTFILL and drop the cached glyphs so they re-bake filled / hollow. +/// Callers must re-tessellate text (`bump_geometry`) so quads pick up the +/// re-baked tiles. +pub fn set_textfill(on: bool) { + TEXTFILL.store(on, std::sync::atomic::Ordering::Relaxed); + if let Ok(mut a) = text_atlas().lock() { + a.reset(); + } +} + /// Debug: when env `OCS_TEXT_BOX` is set, each SDF text run also emits a /// rectangle outline around its glyph bounds so the text pick box is visible. /// Read once. @@ -173,6 +191,12 @@ impl GlyphAtlas { self.dirty = false; } + /// Drop every cached glyph and re-init the texture, so glyphs re-bake on + /// next use (e.g. after TEXTFILL toggles between fill and outline). + pub fn reset(&mut self) { + *self = GlyphAtlas::new(self.width, self.height); + } + /// Look up a glyph's atlas placement, baking + packing it on first use. /// Returns `None` for whitespace/empty glyphs (nothing to draw) and when the /// atlas is full (Phase 1 is single-page; callers should treat `None` as @@ -229,7 +253,10 @@ impl GlyphAtlas { /// Rasterize a glyph into an SDF tile. `None` if the glyph has no ink. fn bake_glyph(g: &Glyph) -> Option { - let filled = !g.fill_tris.is_empty(); + // TEXTFILL 0 renders TrueType glyphs hollow: bake them as a stroke field + // (outline) instead of a signed fill field. Stroke fonts (no fill tris) are + // unaffected. + let filled = textfill() && !g.fill_tris.is_empty(); // Ink bounding box over all strokes (and fill triangles, for safety). let (mut min_x, mut min_y) = (f32::INFINITY, f32::INFINITY);