feat(text): TEXTFILL — fill or hollow TrueType glyphs (#260)
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 <noreply@anthropic.com>
This commit is contained in:
parent
694c5b5a7f
commit
3c3ca89d97
5 changed files with 60 additions and 2 deletions
|
|
@ -328,6 +328,7 @@ inventory::submit!(crate::command::CommandRegistration {
|
||||||
"TCASE",
|
"TCASE",
|
||||||
"TEXTMASK",
|
"TEXTMASK",
|
||||||
"TEXTFIT",
|
"TEXTFIT",
|
||||||
|
"TEXTFILL",
|
||||||
"TCOUNT",
|
"TCOUNT",
|
||||||
"ARCTEXT",
|
"ARCTEXT",
|
||||||
// Jogged radius dimension.
|
// Jogged radius dimension.
|
||||||
|
|
|
||||||
|
|
@ -527,6 +527,7 @@ impl OpenCADStudio {
|
||||||
cmd if matches!(
|
cmd if matches!(
|
||||||
cmd.split_whitespace().next().unwrap_or(""),
|
cmd.split_whitespace().next().unwrap_or(""),
|
||||||
"MIRRTEXT"
|
"MIRRTEXT"
|
||||||
|
| "TEXTFILL"
|
||||||
| "ATTREQ"
|
| "ATTREQ"
|
||||||
| "ATTDIA"
|
| "ATTDIA"
|
||||||
| "DIMASSOC"
|
| "DIMASSOC"
|
||||||
|
|
@ -678,6 +679,23 @@ impl OpenCADStudio {
|
||||||
.ok_or_else(|| "SETVAR: 0 or 1 required.".into()),
|
.ok_or_else(|| "SETVAR: 0 or 1 required.".into()),
|
||||||
None => Ok((format!("MIRRTEXT = {}", h.mirror_text as i32), false)),
|
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 {
|
"ATTREQ" => match &value {
|
||||||
Some(v) => parse_bool(v)
|
Some(v) => parse_bool(v)
|
||||||
.map(|b| {
|
.map(|b| {
|
||||||
|
|
@ -1227,6 +1245,11 @@ impl OpenCADStudio {
|
||||||
}
|
}
|
||||||
Err(e) => self.command_line.push_error(&e),
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -103,6 +103,8 @@ pub struct UserSettings {
|
||||||
pub plugin_repos: Vec<String>,
|
pub plugin_repos: Vec<String>,
|
||||||
/// Controls whether the TEXTEDIT command repeats automatically (0 = Multiple, 1 = Single).
|
/// Controls whether the TEXTEDIT command repeats automatically (0 = Multiple, 1 = Single).
|
||||||
pub texteditmode: bool,
|
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
|
/// When true, saving over an existing file first copies it to a sibling
|
||||||
/// `<name>.bak` so a faulty or accidental save can be recovered (#205).
|
/// `<name>.bak` so a faulty or accidental save can be recovered (#205).
|
||||||
pub backup_on_save: bool,
|
pub backup_on_save: bool,
|
||||||
|
|
@ -142,6 +144,7 @@ impl Default for UserSettings {
|
||||||
disabled_plugins: Vec::new(),
|
disabled_plugins: Vec::new(),
|
||||||
plugin_repos: Vec::new(),
|
plugin_repos: Vec::new(),
|
||||||
texteditmode: false,
|
texteditmode: false,
|
||||||
|
textfill: true,
|
||||||
backup_on_save: true,
|
backup_on_save: true,
|
||||||
file_assoc_enabled: true,
|
file_assoc_enabled: true,
|
||||||
savetime_min: 10,
|
savetime_min: 10,
|
||||||
|
|
@ -190,6 +193,7 @@ impl UserSettings {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"backup_on_save" => s.backup_on_save = val == "1",
|
"backup_on_save" => s.backup_on_save = val == "1",
|
||||||
|
"textfill" => s.textfill = val == "1",
|
||||||
"file_assoc_enabled" => s.file_assoc_enabled = val == "1",
|
"file_assoc_enabled" => s.file_assoc_enabled = val == "1",
|
||||||
"savetime_min" => {
|
"savetime_min" => {
|
||||||
if let Ok(v) = val.parse::<i32>() {
|
if let Ok(v) = val.parse::<i32>() {
|
||||||
|
|
@ -237,7 +241,7 @@ impl UserSettings {
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join(",");
|
.join(",");
|
||||||
let body = format!(
|
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.dyn_input),
|
||||||
b(self.ortho),
|
b(self.ortho),
|
||||||
b(self.polar),
|
b(self.polar),
|
||||||
|
|
@ -249,6 +253,7 @@ impl UserSettings {
|
||||||
self.disabled_plugins.join(","),
|
self.disabled_plugins.join(","),
|
||||||
self.plugin_repos.join(","),
|
self.plugin_repos.join(","),
|
||||||
self.texteditmode,
|
self.texteditmode,
|
||||||
|
b(self.textfill),
|
||||||
b(self.backup_on_save),
|
b(self.backup_on_save),
|
||||||
b(self.file_assoc_enabled),
|
b(self.file_assoc_enabled),
|
||||||
self.savetime_min,
|
self.savetime_min,
|
||||||
|
|
|
||||||
|
|
@ -79,6 +79,7 @@ impl OpenCADStudio {
|
||||||
},
|
},
|
||||||
plugin_repos: self.plugin_repos.clone(),
|
plugin_repos: self.plugin_repos.clone(),
|
||||||
texteditmode: self.texteditmode,
|
texteditmode: self.texteditmode,
|
||||||
|
textfill: crate::scene::text::sdf_atlas::textfill(),
|
||||||
backup_on_save: self.backup_on_save,
|
backup_on_save: self.backup_on_save,
|
||||||
file_assoc_enabled: self.file_assoc_enabled,
|
file_assoc_enabled: self.file_assoc_enabled,
|
||||||
savetime_min: self.savetime_min,
|
savetime_min: self.savetime_min,
|
||||||
|
|
@ -100,6 +101,7 @@ impl OpenCADStudio {
|
||||||
self.disabled_plugins = s.disabled_plugins.iter().cloned().collect();
|
self.disabled_plugins = s.disabled_plugins.iter().cloned().collect();
|
||||||
self.plugin_repos = s.plugin_repos.clone();
|
self.plugin_repos = s.plugin_repos.clone();
|
||||||
self.texteditmode = s.texteditmode;
|
self.texteditmode = s.texteditmode;
|
||||||
|
crate::scene::text::sdf_atlas::set_textfill(s.textfill);
|
||||||
self.backup_on_save = s.backup_on_save;
|
self.backup_on_save = s.backup_on_save;
|
||||||
self.file_assoc_enabled = s.file_assoc_enabled;
|
self.file_assoc_enabled = s.file_assoc_enabled;
|
||||||
self.savetime_min = s.savetime_min;
|
self.savetime_min = s.savetime_min;
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,24 @@ pub fn text_atlas() -> &'static Mutex<GlyphAtlas> {
|
||||||
ATLAS.get_or_init(|| Mutex::new(GlyphAtlas::new(1024, 1024)))
|
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
|
/// 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.
|
/// rectangle outline around its glyph bounds so the text pick box is visible.
|
||||||
/// Read once.
|
/// Read once.
|
||||||
|
|
@ -173,6 +191,12 @@ impl GlyphAtlas {
|
||||||
self.dirty = false;
|
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.
|
/// 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
|
/// 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
|
/// 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.
|
/// Rasterize a glyph into an SDF tile. `None` if the glyph has no ink.
|
||||||
fn bake_glyph(g: &Glyph) -> Option<BakedTile> {
|
fn bake_glyph(g: &Glyph) -> Option<BakedTile> {
|
||||||
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).
|
// Ink bounding box over all strokes (and fill triangles, for safety).
|
||||||
let (mut min_x, mut min_y) = (f32::INFINITY, f32::INFINITY);
|
let (mut min_x, mut min_y) = (f32::INFINITY, f32::INFINITY);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue