feat(text): parse TEXT via acadrust + extend SDF text to MTEXT

Two related steps for the text pipeline:

- TEXT parsing now goes through acadrust's parse_plain_text (like MTEXT
  uses parse_mtext). text::acad_text_encode parses the value's %% codes —
  including %%u/%%o underline/overline and %%nnn — and re-encodes to the
  stroke tessellator's \L/\O + resolved-Unicode grammar, so OCS no longer
  tokenizes TEXT %% codes itself. (ATTRIB/TOLERANCE/DIMENSION still use the
  local tokenizer for now.) Bumps the acadrust pin to the commit that
  completes the TEXT/MTEXT control-code set.

- SDF text (OCS_TEXT_SDF) now covers MTEXT and every text-producing
  entity, not just top-level TEXT. TextStroke carries a GlyphRun (layout
  params); the collector walks convert()'s TruckObject::Text runs and
  applies annotation scale exactly as tessellate does for strokes.
  layout_glyph_quads shares lff::tokenize_run so %% specials resolve and
  \L/\O decorations are skipped (decoration LINES in SDF are still TODO).

Verified: stroke rendering unchanged with the flag off; SDF glyphs render
correctly for TEXT and MTEXT with OCS_TEXT_SDF=1. 154 lib tests green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
Hakan SevenandClaude Opus 4.8 committed 2026-07-07 00:53:19 +03:00
1 parent b88e5e4ad5
commit d528a3e2a5
9 files changed
+149 -28

No files matched your search

+19
View File
@@ -19,6 +19,25 @@ pub struct TextStroke {
pub origin: [f64; 2],
pub color: Option<[f32; 3]>,
pub fill_tris: Vec<[f32; 2]>,
/// Layout inputs to rebuild this run as per-glyph SDF quads (see
/// `scene::text::glyph_quads`). `Some` on runs wired for the SDF text
/// renderer; `None` leaves the run to the stroke path only. Heights are
/// raw (pre annotation-scale), matching `strokes` — the SDF collector
/// applies annotation scale the same way `tessellate` does for strokes.
pub run: Option<GlyphRun>,
}
/// Per-run text-layout inputs needed to reproduce a run as SDF glyph quads.
#[derive(Clone, Debug)]
pub struct GlyphRun {
pub text: String,
pub font: String,
/// Raw height in drawing units (annotation scale applied later).
pub height: f32,
pub rotation: f32,
pub width_factor: f32,
pub oblique: f32,
pub tracking: f32,
}
#[allow(dead_code)]
+17 -7
View File
@@ -70,11 +70,22 @@ pub fn layout_glyph_quads(
let mut cursor_x = 0.0f32;
let mut quads = Vec::new();
for ch in text.chars() {
if ch == ' ' {
cursor_x += face.word_spacing();
continue;
}
// Same token walk as `tessellate_text_run` (LFF branch): DXF `%%` specials
// resolve to glyphs, spaces/missing advance the pen, decoration toggles emit
// no glyph (they render as lines on the stroke path).
for tok in crate::scene::text::lff::tokenize_run(text) {
let ch = match tok {
crate::scene::text::lff::Tok::Glyph(c) => c,
crate::scene::text::lff::Tok::Space => {
cursor_x += face.word_spacing();
continue;
}
crate::scene::text::lff::Tok::Missing => {
cursor_x += 6.0 + face.letter_spacing() * tracking;
continue;
}
crate::scene::text::lff::Tok::Deco(..) => continue,
};
match atlas.get_or_insert(font_name, ch) {
Some(e) => {
let (lo, hi) = (e.plane_min, e.plane_max);
@@ -91,8 +102,7 @@ pub fn layout_glyph_quads(
cursor_x += e.advance + face.letter_spacing() * tracking;
}
None => {
// No ink (whitespace glyph) or atlas full: advance only, using
// the glyph's own advance when the font knows it.
// No ink (whitespace glyph) or atlas full: advance only.
let adv = face.glyph(ch).map(|g| g.advance).unwrap_or(6.0);
cursor_x += adv + face.letter_spacing() * tracking;
}
+4 -4
View File
@@ -193,7 +193,7 @@ pub fn get_font(name: &str) -> &'static Font {
/// Decoration line a token toggles.
#[derive(Clone, Copy)]
enum Deco {
pub(crate) enum Deco {
Under,
Over,
Strike,
@@ -202,7 +202,7 @@ enum Deco {
/// How a decoration token changes state. `\L`/`\O`/`\K` turn on (idempotent),
/// `\l`/`\o`/`\k` turn off; `%%u`/`%%o` flip.
#[derive(Clone, Copy)]
enum Op {
pub(crate) enum Op {
On,
Off,
Toggle,
@@ -211,7 +211,7 @@ enum Op {
/// One unit of a text run after inline-code resolution. Shared by the stroke
/// (LFF) and shaped (TTF) renderers so both interpret the DXF inline grammar
/// — `\L…\l` decorations and `%%` specials — identically.
enum Tok {
pub(crate) enum Tok {
/// A renderable character (DXF `%%d`/`%%p`/`%%c`/`%%nnn` already resolved).
Glyph(char),
/// A literal space.
@@ -225,7 +225,7 @@ enum Tok {
/// Resolve a run's inline codes into a flat token stream. Mirrors the original
/// inline parser in `tessellate_text_run` exactly (verified by the golden test).
fn tokenize_run(text: &str) -> Vec<Tok> {
pub(crate) fn tokenize_run(text: &str) -> Vec<Tok> {
let mut toks = Vec::new();
let mut chars = text.chars().peekable();
while let Some(ch) = chars.next() {
+50 -12
View File
@@ -792,6 +792,8 @@ impl Scene {
&self,
enabled: bool,
) -> Vec<crate::scene::pipeline::text_gpu::TextVertex> {
use crate::scene::convert::acad_to_truck::{convert, TruckObject};
use crate::scene::convert::tessellate::entity_z;
use crate::scene::pipeline::text_gpu;
use crate::scene::text::{glyph_quads, sdf_atlas};
if !enabled {
@@ -800,26 +802,62 @@ impl Scene {
let Ok(mut atlas) = sdf_atlas::text_atlas().lock() else {
return Vec::new();
};
// Annotation scale matches the stroke path (tessellate): model-space
// text scales, paper/layout text does not.
let anno = if self.current_layout == "Model" {
self.annotation_scale as f64
} else {
1.0
};
let mut out = Vec::new();
for e in self.document.entities() {
if let acadrust::EntityType::Text(t) = e {
let p = crate::entities::text::text_run_placement(t, &self.document);
let color = self.render_style(e).0;
// Only text-producing entities — converting solids/hatches here
// would re-tessellate the whole document every frame.
if !matches!(
e,
acadrust::EntityType::Text(_) | acadrust::EntityType::MText(_)
) {
continue;
}
let Some(te) = convert(e, &self.document) else {
continue;
};
let TruckObject::Text(groups) = te.object else {
continue;
};
let base_color = self.render_style(e).0;
let elev = entity_z(e) as f64;
// Annotation scale anchors at the first run's origin (matches the
// stroke path so multi-line MText spreads identically).
let ref_origin = groups.first().map(|g| g.origin).unwrap_or([0.0, 0.0]);
for g in &groups {
let Some(run) = &g.run else {
continue;
};
let slx = (g.origin[0] - ref_origin[0]) * anno + ref_origin[0];
let sly = (g.origin[1] - ref_origin[1]) * anno + ref_origin[1];
let color = g
.color
.map(|c| [c[0], c[1], c[2], 1.0])
.unwrap_or(base_color);
let quads = glyph_quads::layout_glyph_quads(
&mut atlas,
p.height,
p.rotation,
p.width_factor,
p.oblique_angle,
1.0,
&p.font,
&p.value,
run.height,
run.rotation,
run.width_factor,
run.oblique,
run.tracking,
&run.font,
&run.text,
);
// `anno` scales the run-local quads; `slx/sly` is the
// annotation-scaled run origin — exactly as tessellate places
// the stroke geometry.
text_gpu::push_glyph_vertices(
&mut out,
&quads,
[p.origin[0], p.origin[1], p.elevation],
1.0,
[slx, sly, elev],
anno,
color,
0.0,
);