feat(mtext): integrate every inline format code in MTEXT renderer

Replace the line-based MTEXT pipeline with a run-aware parser + renderer.
Every DXF MTEXT inline code now reaches the stroke output: \H \W \Q \T \A
\C \c \f \F \M \X, the \p block (q/i/l/r/t/s), brace-scoped state, and
the \L\l \O\o \K\k decoration toggles.

Parser ([text_support]): emits Vec<MTextLine{ runs, align, indent_*,
tab_stops }> with per-run RunState; braces scope character state, paragraph
props persist across them.

Renderer ([mtext]): atomise → wrap → render. Per-run height/width/oblique/
tracking/font/colour/decorations/valign; paragraph indents and tab stops
honoured; default 4-em tab fallback.

Plumbing: tessellate() now returns Vec<WireModel>; TextStroke gains an
optional inline-colour override so per-run \C/\c emits one wire per colour.
cxf::tessellate_text_run adds a tracking multiplier. block_cache folds
multi-wire results into a single LocalWire (colour split preserved at
top-level, flattened inside cached defns).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Hakan SevenandClaude Opus 4.7 committed 2026-05-21 23:42:22 +03:00
1 parent ff31a07213
commit f12e864aa6
10 files changed
+1223 -148

No files matched your search

+6
View File
@@ -10,9 +10,15 @@ use crate::scene::wire_model::{SnapHint, TangentGeom};
/// One group of glyph strokes with its world-space origin stored in f64.
/// Strokes are in glyph-local space (origin = [0,0]) so that the large
/// world offset can be subtracted with f64 precision in tessellate.rs.
///
/// `color`, when set, overrides the entity colour for just this group — used
/// by MTEXT inline `\C` / `\c` per-run colour. Strokes sharing the same
/// (color, None) override are merged into one WireModel downstream; runs with
/// distinct colours emit their own WireModel.
pub struct TextStroke {
pub strokes: Vec<Vec<[f32; 2]>>,
pub origin: [f64; 2],
pub color: Option<[f32; 3]>,
}
#[allow(dead_code)]
+24 -1
View File
@@ -401,9 +401,32 @@ fn tessellate_sub_local(
// Pass `local_offset` as the f64 world-offset so tessellate subtracts it
// before casting to f32 — same precision-preservation trick used for
// top-level entities, applied per-defn.
let wire = tessellate::tessellate(
//
// tessellate() may emit multiple WireModels for a single sub-entity (e.g.
// MTEXT with inline `\C` / `\c` colour switches splits one entity into
// one wire per colour group). The block-defn cache stores a single
// LocalWire per sub-entity, so we fold the wires into one — points get
// concatenated with NaN separators, the primary colour comes from the
// first wire. Per-segment colour is lost inside cached block defns; for
// top-level MTEXT outside blocks the colour split is preserved via the
// hot path in `tessellate_entity`.
let mut wires_out = tessellate::tessellate(
doc, h, sub, false, sub_color, pat_len, pat, lw_px, local_offset, anno_scale,
);
if wires_out.is_empty() {
return None;
}
let mut wire = wires_out.remove(0);
for extra in wires_out {
if !wire.points.is_empty() && !extra.points.is_empty() {
wire.points.push([f32::NAN, f32::NAN, f32::NAN]);
}
wire.points.extend(extra.points);
wire.key_vertices.extend(extra.key_vertices);
wire.snap_pts.extend(extra.snap_pts);
wire.tangent_geoms.extend(extra.tangent_geoms);
wire.fill_tris.extend(extra.fill_tris);
}
if wire.points.len() > 100_000 {
return None;
+30 -3
View File
@@ -226,6 +226,33 @@ pub fn tessellate_text_ex(
oblique_angle: f32,
font_name: &str,
text: &str,
) -> Vec<Vec<[f32; 2]>> {
tessellate_text_run(
origin,
height,
rotation,
width_factor,
oblique_angle,
1.0,
font_name,
text,
)
}
/// Like [`tessellate_text_ex`] but takes a `tracking` multiplier applied to
/// the per-glyph `letter_spacing` advance. `tracking = 1.0` keeps the legacy
/// behaviour; MTEXT's `\T<v>;` inline code routes here so per-run character
/// spacing scales independently of the width factor.
#[allow(clippy::too_many_arguments)]
pub fn tessellate_text_run(
origin: [f32; 2],
height: f32,
rotation: f32,
width_factor: f32,
oblique_angle: f32,
tracking: f32,
font_name: &str,
text: &str,
) -> Vec<Vec<[f32; 2]>> {
if text.is_empty() || height <= 0.0 {
return vec![];
@@ -388,7 +415,7 @@ pub fn tessellate_text_ex(
}
} else {
// Partial digit sequence — advance as unknown glyph and move on
cursor_x += (6.0 + font.letter_spacing) * wf;
cursor_x += (6.0 + font.letter_spacing * tracking) * wf;
continue;
}
}
@@ -417,11 +444,11 @@ pub fn tessellate_text_ex(
.collect(),
);
}
cursor_x += (glyph.advance + font.letter_spacing) * wf;
cursor_x += (glyph.advance + font.letter_spacing * tracking) * wf;
}
None => {
warn_missing_glyph(font_name, render_ch);
cursor_x += (6.0 + font.letter_spacing) * wf;
cursor_x += (6.0 + font.letter_spacing * tracking) * wf;
}
}
}
+46 -30
View File
@@ -4777,7 +4777,7 @@ fn tessellate_entity(
} else {
(0.0_f32, [0.0f32; 8])
};
let mut wire = tessellate::tessellate(
let mut wires = tessellate::tessellate(
document,
h,
e,
@@ -4789,8 +4789,11 @@ fn tessellate_entity(
world_offset,
1.0,
);
wire.aabb = entity_aabb(e, world_offset);
return vec![wire];
let ab = entity_aabb(e, world_offset);
for w in &mut wires {
w.aabb = ab;
}
return wires;
}
let (entity_color, pattern_length, pattern, line_weight_px, aci) =
@@ -5066,7 +5069,7 @@ fn tessellate_entity(
let sub_aabb = entity_aabb(&sub, world_offset);
let sub_pattern_length = sub_pattern_length * pslt_factor;
let sub_pattern = sub_pattern.map(|v| v * pslt_factor);
let mut wire = tessellate::tessellate(
let mut wires = tessellate::tessellate(
document,
h,
&sub,
@@ -5078,10 +5081,12 @@ fn tessellate_entity(
world_offset,
anno_scale,
);
wire.name = h.value().to_string();
wire.aci = sub_aci;
wire.aabb = sub_aabb;
vec![wire]
for w in &mut wires {
w.name = h.value().to_string();
w.aci = sub_aci;
w.aabb = sub_aabb;
}
wires
})
.collect();
append_insert_attribute_wires(
@@ -5219,7 +5224,7 @@ fn tessellate_entity(
}
}
let mut base = tessellate::tessellate(
let mut bases = tessellate::tessellate(
document,
h,
e,
@@ -5231,28 +5236,37 @@ fn tessellate_entity(
world_offset,
anno_scale,
);
base.aci = aci;
base.aabb = aabb;
for b in &mut bases {
b.aci = aci;
b.aabb = aabb;
}
// Complex linetypes (with embedded shapes / text) expand the *base*
// polyline along its tangent. Text-type entities never have a complex
// linetype assigned, so we only consult the first wire here — multi-wire
// returns come exclusively from MTEXT colour splits which can't trigger
// this path.
if let Some(clt) = crate::linetypes::complex_lt(lt_name) {
let mut wires = complex_lt::apply_along(
&base.name,
&base.points,
clt,
(lt_scale * pslt_factor).max(1e-4),
entity_color,
sel,
base.line_weight_px,
);
if !wires.is_empty() {
for w in &mut wires {
w.aabb = aabb;
if let Some(base) = bases.first() {
let mut wires = complex_lt::apply_along(
&base.name,
&base.points,
clt,
(lt_scale * pslt_factor).max(1e-4),
entity_color,
sel,
base.line_weight_px,
);
if !wires.is_empty() {
for w in &mut wires {
w.aabb = aabb;
}
return wires;
}
return wires;
}
}
vec![base]
bases
}
/// Build the 4 OBB corners (CCW: bl, br, tr, tl) of a Text / MText entity
@@ -5565,7 +5579,7 @@ fn append_insert_attribute_wires(
sub_color
};
let sub_aabb = entity_aabb(&attr_entity, world_offset);
let mut wire = tessellate::tessellate(
let mut attr_wires = tessellate::tessellate(
document,
insert_handle,
&attr_entity,
@@ -5579,10 +5593,12 @@ fn append_insert_attribute_wires(
);
// Use the INSERT's handle so selection / picking groups attribute
// text with the parent insert instead of treating it as a stray text.
wire.name = insert_handle.value().to_string();
wire.aci = sub_aci;
wire.aabb = sub_aabb;
wires.push(wire);
for w in &mut attr_wires {
w.name = insert_handle.value().to_string();
w.aci = sub_aci;
w.aabb = sub_aabb;
}
wires.extend(attr_wires);
}
}
+118 -49
View File
@@ -112,7 +112,7 @@ pub fn tessellate(
line_weight_px: f32,
world_offset: [f64; 3],
anno_scale: f32,
) -> WireModel {
) -> Vec<WireModel> {
let color = if selected {
WireModel::SELECTED
} else {
@@ -145,7 +145,7 @@ pub fn tessellate(
// MultiLeader is handled by scene/mod.rs since it emits multiple WireModels
// (leader, text, frame, fill) with distinct colors.
if let EntityType::Leader(leader) = entity {
return tessellate_leader_single(
return vec![tessellate_leader_single(
document,
handle,
leader,
@@ -154,74 +154,141 @@ pub fn tessellate(
line_weight_px,
world_offset,
anno_scale,
);
)];
}
// ── Try the truck path first ───────────────────────────────────────────
if let Some(te) = convert(entity, document) {
match te.object {
// ── Text / MText: pre-tessellated glyph strokes ───────────────
//
// Strokes are pre-grouped by world origin (one TextStroke per
// line / per run / per fragment), each carrying an optional
// colour override produced by MTEXT inline `\C` / `\c`. We bin
// groups by override colour and emit one WireModel per bin so a
// single MTEXT can hand back N colour-distinct wires when the
// value mixes inline colours.
TruckObject::Text(stroke_groups) => {
// Each TextStroke keeps its strokes in glyph-local space and
// its world origin as f64. Subtract world_offset in f64 before
// casting to f32 so large UTM coordinates don't crush precision.
let [ox, oy, oz] = world_offset;
let elev = entity_z(entity) - oz as f32;
let mut points: Vec<[f32; 3]> = Vec::new();
let mut first = true;
// Annotation scale: scale glyph strokes relative to the text
// insertion point (first group's origin) so multi-line MText
// lines spread apart correctly as well as growing in size.
// anno_scale anchors at the first group's origin so multi-line
// MText lines spread apart correctly as they grow.
let ref_origin = stroke_groups
.first()
.map(|g| g.origin)
.unwrap_or([0.0, 0.0]);
let ref_lx = (ref_origin[0] - ox) as f32;
let ref_ly = (ref_origin[1] - oy) as f32;
// Selection forces a single uniform colour — never split.
let split_by_color = !selected;
// Bins: key = Some(rgb) or None (= inherit entity colour).
// Preserve insertion order so wire-emit ordering tracks the
// original glyph stream (useful for stable hit-test indexing).
let mut bins: Vec<(Option<[f32; 3]>, Vec<[f32; 3]>)> = Vec::new();
let mut bin_first: Vec<bool> = Vec::new();
let find_or_make =
|key: Option<[f32; 3]>, bins: &mut Vec<(Option<[f32; 3]>, Vec<[f32; 3]>)>, firsts: &mut Vec<bool>| -> usize {
if let Some(i) = bins.iter().position(|(k, _)| *k == key) {
i
} else {
bins.push((key, Vec::new()));
firsts.push(true);
bins.len() - 1
}
};
for group in &stroke_groups {
let lx = (group.origin[0] - ox) as f32;
let ly = (group.origin[1] - oy) as f32;
let slx = (lx - ref_lx) * anno_scale + ref_lx;
let sly = (ly - ref_ly) * anno_scale + ref_ly;
let bin_key = if split_by_color { group.color } else { None };
let bi = find_or_make(bin_key, &mut bins, &mut bin_first);
let pts = &mut bins[bi].1;
for stroke in &group.strokes {
if stroke.len() < 2 {
continue;
}
if !first && !points.is_empty() {
points.push([f32::NAN, f32::NAN, f32::NAN]);
if !bin_first[bi] && !pts.is_empty() {
pts.push([f32::NAN, f32::NAN, f32::NAN]);
}
first = false;
bin_first[bi] = false;
for &[x, y] in stroke {
points.push([x * anno_scale + slx, y * anno_scale + sly, elev]);
pts.push([x * anno_scale + slx, y * anno_scale + sly, elev]);
}
}
}
let snap_pts = offset_snap_pts(te.snap_pts, world_offset);
let [ox, oy, oz] = world_offset;
let key_vertices: Vec<[f32; 3]> = te
.key_vertices
.into_iter()
.map(|[x, y, z]| [(x - ox) as f32, (y - oy) as f32, (z - oz) as f32])
.collect();
return WireModel {
name,
points,
color,
selected,
pattern_length: 0.0,
pattern: [0.0; 8],
line_weight_px,
snap_pts,
tangent_geoms: te.tangent_geoms,
aci: 0,
key_vertices,
aabb: WireModel::UNBOUNDED_AABB,
plinegen: true,
vp_scissor: None,
fill_tris: vec![],
};
// Empty input (no glyphs) → emit a single empty wire so the
// entity still has a hit-test target via snap_pts.
if bins.is_empty() {
return vec![WireModel {
name,
points: Vec::new(),
color,
selected,
pattern_length: 0.0,
pattern: [0.0; 8],
line_weight_px,
snap_pts,
tangent_geoms: te.tangent_geoms,
aci: 0,
key_vertices,
aabb: WireModel::UNBOUNDED_AABB,
plinegen: true,
vp_scissor: None,
fill_tris: vec![],
}];
}
let bin_count = bins.len();
let mut out: Vec<WireModel> = Vec::with_capacity(bin_count);
for (idx, (override_rgb, pts)) in bins.into_iter().enumerate() {
let wire_color = match override_rgb {
Some([r, g, b]) => [r, g, b, color[3]],
None => color,
};
// Snap points and key vertices belong to the entity as a
// whole — attach them only to the first emitted wire so
// pickers / hover don't double-count.
let (snap, keys, tangents) = if idx == 0 {
(
snap_pts.clone(),
key_vertices.clone(),
te.tangent_geoms.clone(),
)
} else {
(Vec::new(), Vec::new(), Vec::new())
};
out.push(WireModel {
name: name.clone(),
points: pts,
color: wire_color,
selected,
pattern_length: 0.0,
pattern: [0.0; 8],
line_weight_px,
snap_pts: snap,
tangent_geoms: tangents,
aci: 0,
key_vertices: keys,
aabb: WireModel::UNBOUNDED_AABB,
plinegen: true,
vp_scissor: None,
fill_tris: vec![],
});
}
return out;
}
// ── Standard topology objects ─────────────────────────────────
@@ -239,7 +306,7 @@ pub fn tessellate(
[(kx - ox) as f32, (ky - oy) as f32, (kz - oz) as f32]
})
.collect();
return WireModel {
return vec![WireModel {
name,
points: vec![
[x - s, y, z],
@@ -260,7 +327,7 @@ pub fn tessellate(
plinegen: true,
vp_scissor: None,
fill_tris: vec![],
};
}];
}
_ => {}
}
@@ -275,7 +342,7 @@ pub fn tessellate(
.into_iter()
.map(|[x, y, z]| [(x - ox) as f32, (y - oy) as f32, (z - oz) as f32])
.collect();
return WireModel {
return vec![WireModel {
name,
points,
color,
@@ -291,7 +358,7 @@ pub fn tessellate(
plinegen: true,
vp_scissor: None,
fill_tris: vec![],
};
}];
}
}
@@ -304,7 +371,7 @@ pub fn tessellate(
.into_iter()
.map(|[x, y, z]| [(x - ox) as f32, (y - oy) as f32, (z - oz) as f32])
.collect();
return WireModel {
return vec![WireModel {
name,
points,
color,
@@ -320,7 +387,7 @@ pub fn tessellate(
plinegen: true,
vp_scissor: None,
fill_tris: vec![],
};
}];
}
}
@@ -351,7 +418,7 @@ pub fn tessellate(
.into_iter()
.map(|[x, y, z]| [(x - ox) as f32, (y - oy) as f32, (z - oz) as f32])
.collect();
return WireModel {
return vec![WireModel {
name,
points: local_pts,
color,
@@ -367,7 +434,7 @@ pub fn tessellate(
plinegen: true,
vp_scissor: None,
fill_tris,
};
}];
}
TruckObject::SegmentedLines(points) => {
@@ -388,7 +455,7 @@ pub fn tessellate(
.into_iter()
.map(|[x, y, z]| [(x - ox) as f32, (y - oy) as f32, (z - oz) as f32])
.collect();
return WireModel {
return vec![WireModel {
name,
points: local_pts,
color,
@@ -404,7 +471,7 @@ pub fn tessellate(
vp_scissor: None,
aabb: WireModel::UNBOUNDED_AABB,
fill_tris: vec![],
};
}];
}
TruckObject::Volume(_) => {
@@ -427,14 +494,14 @@ pub fn tessellate(
let sp = Vec3::new((p.x - ox) as f32, (p.y - oy) as f32, (p.z - oz) as f32);
wm.snap_pts.push((sp, SnapHint::Insertion));
}
return wm;
return vec![wm];
}
}
}
// ── Legacy fallback for Viewport and other unhandled types ────────────
let (points, snap_pts, tangent_geoms, key_vertices) = legacy_geometry(entity, world_offset);
WireModel {
vec![WireModel {
name,
points,
color,
@@ -450,7 +517,7 @@ pub fn tessellate(
plinegen: true,
vp_scissor: None,
fill_tris: vec![],
}
}]
}
pub fn tessellate_dimension(
@@ -1539,7 +1606,7 @@ pub fn tessellate_multileader(
for sub in synth_ins.explode_from_document(document) {
let normalized =
crate::modules::home::modify::explode::normalize_insert_entity(sub);
let mut wire = tessellate(
let mut sub_wires = tessellate(
document,
handle,
&normalized,
@@ -1551,8 +1618,10 @@ pub fn tessellate_multileader(
world_offset,
1.0,
);
wire.name = name.clone();
wires.push(wire);
for w in &mut sub_wires {
w.name = name.clone();
}
wires.extend(sub_wires);
}
// Block attributes attached to the multileader — render each as
// its own attribute entity at WCS location like INSERT does.