forked from 1b-agent/opencadstudio-upstream
refactor(text): SDF for all text kinds; remove the legacy stroke text path
SDF is the only text renderer now — the stroke path and its LOD ladder are gone, and the remaining text kinds render as SDF glyph quads too. - ATTDEF / ATTRIB: carry a GlyphRun so single-line attribute text renders as SDF (multi-line already did via layout_mtext). Tolerance / complex-linetype text already SDF. - Remove `sdf_text_enabled()` (the OCS_TEXT_SDF flag / kill switch) and take the SDF path unconditionally everywhere: tessellate Text arm (per-group by GlyphRun presence), hit_test fallbacks, render gather, complex_lt, table, mleader. - Delete the text LOD ladder: tess.rs baseline/greek block and the block_cache greek/baseline path (emit_greeked_text / emit_text_baseline), plus the now-dead helpers text_baseline_points / text_greek_obb_tris / text_obb_corners_native / mtext_line_count and the LocalWire text_height_local / text_obb_local fields. All text is crisp at every zoom; text entities are exempt from the sub-pixel stub cull. - Fix a latent bug: block-internal SDF text was culled because its LocalWire aabb_local was [0,0,0,0] (no points) — fold the glyph-quad extent into aabb_local and exempt text_verts-carrying wires from the sub-pixel cull. - Clear the pre-existing dead-code warnings (TextPlacement.elevation, TabKind, MTextRunKind::Tab). Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
1 parent
67ce45d40b
commit
5c15091e74
12 files changed
+145
-1238
No files matched your search
Vendored
+12
-329
@@ -76,16 +76,6 @@ pub struct LocalWire {
|
||||
/// expand-time: transform corners by the Insert transform → world AABB
|
||||
/// → test against the camera's world-space view rect.
|
||||
pub aabb_local: [f32; 4],
|
||||
/// For Text / MText subs: the entity's anno-scaled glyph height in
|
||||
/// local units. Lets `emit_wire` apply the same LOD ladder used for
|
||||
/// top-level text (cull / greek / full) to text that's been baked into
|
||||
/// a block defn. `None` for non-text entities.
|
||||
pub text_height_local: Option<f32>,
|
||||
/// For Text / MText subs: the 4 OBB corners in block-local coords
|
||||
/// (rotation, anchor offsets and width-approximation already applied).
|
||||
/// Emitted at greek time so the rect matches the text's orientation
|
||||
/// instead of falling back to the axis-aligned bbox.
|
||||
pub text_obb_local: Option<[[f64; 3]; 4]>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -435,35 +425,6 @@ fn tessellate_sub_local(
|
||||
return vec![];
|
||||
}
|
||||
|
||||
// With SDF text on, block-internal TEXT/MTEXT renders as glyph quads
|
||||
// (carried on the wire's `text_verts`), so skip the greek/baseline LOD
|
||||
// ladder — leaving `text_height_local` None routes the sub to `emit_wire`,
|
||||
// which transforms and emits those quads.
|
||||
let text_height_local: Option<f32> = if crate::scene::text::sdf_atlas::sdf_text_enabled() {
|
||||
None
|
||||
} else {
|
||||
match sub {
|
||||
EntityType::Text(t) => Some((t.height * anno_scale as f64) as f32),
|
||||
EntityType::MText(m) => Some((m.height * anno_scale as f64) as f32),
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
|
||||
// Pre-compute the OBB corners (absolute f64) for Text / MText so the greek
|
||||
// path can emit a rect that matches the entity's rotation instead of
|
||||
// falling back to the axis-aligned `aabb_local`. Kept in f64 so it stays
|
||||
// precise at UTM scale (the emit path splits to double-single). For MText
|
||||
// the OBB height is built from the wrap-expanded line count so
|
||||
// emit_greeked_text can split it into per-line rows.
|
||||
let mtext_lines = match sub {
|
||||
EntityType::MText(m) => Some(
|
||||
crate::entities::text_support::mtext_line_count(m, doc, anno_scale),
|
||||
),
|
||||
_ => None,
|
||||
};
|
||||
let text_obb_local: Option<[[f64; 3]; 4]> =
|
||||
crate::entities::text_support::text_obb_corners_native(sub, anno_scale, mtext_lines);
|
||||
|
||||
let mut result = Vec::with_capacity(wires_out.len());
|
||||
for wire in wires_out {
|
||||
// Per-wire point-count cap: a single wire that exceeds this is skipped
|
||||
@@ -475,8 +436,15 @@ fn tessellate_sub_local(
|
||||
|
||||
// Geometry is stored absolute; the double-single (high/low) render path
|
||||
// keeps it precise at UTM scale, so no per-defn offset is subtracted.
|
||||
// SDF text wires have no points/fills — fold in the glyph-quad positions
|
||||
// so the view-frustum cull uses the text's real box, not a degenerate
|
||||
// point at the block origin (which would drop the text entirely).
|
||||
let aabb_local = aabb_from_points_iter(
|
||||
wire.points.iter().copied().chain(wire.fill_tris.iter().copied()),
|
||||
wire.points
|
||||
.iter()
|
||||
.copied()
|
||||
.chain(wire.fill_tris.iter().copied())
|
||||
.chain(wire.text_verts.iter().map(|v| v.pos)),
|
||||
);
|
||||
let is_fill_only = wire.points.is_empty() && !wire.fill_tris.is_empty();
|
||||
|
||||
@@ -503,8 +471,6 @@ fn tessellate_sub_local(
|
||||
lt_l0,
|
||||
lw_l0,
|
||||
aabb_local,
|
||||
text_height_local,
|
||||
text_obb_local,
|
||||
});
|
||||
}
|
||||
result
|
||||
@@ -678,230 +644,6 @@ pub fn expand_insert(
|
||||
Some(batches.finalize(&name, selected, bg_color))
|
||||
}
|
||||
|
||||
/// Emit a greeked filled rect (2 triangles) for a text LocalWire. Stays in
|
||||
/// the text's own color — the face3d pipeline skips its 0.45 dim for wires
|
||||
/// whose `points` are empty, so the rect lands at full intensity. Clamped
|
||||
/// to a single line's height so multi-line MText doesn't blow the box up
|
||||
/// to the full block. Mirrors `text_greek_obb_tris` in scene/mod.rs.
|
||||
/// Falls back to the axis-aligned `local_aabb` when no OBB was cached;
|
||||
/// xref fade still applies via the shared color resolution.
|
||||
fn emit_greeked_text(
|
||||
lw: &LocalWire,
|
||||
local_aabb: [f32; 4],
|
||||
accum_xform: &Transform,
|
||||
ctx: &ExpandCtx,
|
||||
out: &mut Batches,
|
||||
) {
|
||||
// Transform absolute f64 corners and keep the result in f64 so the
|
||||
// per-vertex double-single split below stays precise at UTM-scale coords.
|
||||
let xf = |p: [f64; 3]| -> [f64; 3] {
|
||||
let w = accum_xform.apply(Vector3::new(p[0], p[1], p[2]));
|
||||
[w.x, w.y, w.z]
|
||||
};
|
||||
|
||||
// Per-line rects: split the OBB by `h_local` so each visible wrap
|
||||
// line gets its own box. Falls back to the axis-aligned AABB when no
|
||||
// OBB / height was cached.
|
||||
let tris: Vec<[f64; 3]> = if let (Some(obb), Some(h_local)) =
|
||||
(lw.text_obb_local, lw.text_height_local)
|
||||
{
|
||||
if h_local <= 0.0 {
|
||||
return;
|
||||
}
|
||||
let h = h_local as f64;
|
||||
let (ux, uy, uz) = (
|
||||
obb[3][0] - obb[0][0],
|
||||
obb[3][1] - obb[0][1],
|
||||
obb[3][2] - obb[0][2],
|
||||
);
|
||||
let ulen = (ux * ux + uy * uy + uz * uz).sqrt();
|
||||
if ulen < 1e-9 {
|
||||
return;
|
||||
}
|
||||
let n_lines = ((ulen / h).round() as usize).max(1);
|
||||
let (nx, ny, nz) = (ux / ulen, uy / ulen, uz / ulen);
|
||||
let bl_local = obb[0];
|
||||
let br_local = obb[1];
|
||||
let mut acc = Vec::with_capacity(n_lines * 6);
|
||||
for i in 0..n_lines {
|
||||
let top_along = (ulen - (i as f64) * h).max(0.0);
|
||||
let bot_along = (ulen - ((i + 1) as f64) * h).max(0.0);
|
||||
let tl = xf([
|
||||
bl_local[0] + nx * top_along,
|
||||
bl_local[1] + ny * top_along,
|
||||
bl_local[2] + nz * top_along,
|
||||
]);
|
||||
let tr = xf([
|
||||
br_local[0] + nx * top_along,
|
||||
br_local[1] + ny * top_along,
|
||||
br_local[2] + nz * top_along,
|
||||
]);
|
||||
let bl = xf([
|
||||
bl_local[0] + nx * bot_along,
|
||||
bl_local[1] + ny * bot_along,
|
||||
bl_local[2] + nz * bot_along,
|
||||
]);
|
||||
let br = xf([
|
||||
br_local[0] + nx * bot_along,
|
||||
br_local[1] + ny * bot_along,
|
||||
br_local[2] + nz * bot_along,
|
||||
]);
|
||||
acc.extend_from_slice(&[bl, br, tr, bl, tr, tl]);
|
||||
}
|
||||
acc
|
||||
} else {
|
||||
let [x0, y0, x1, y1] = local_aabb;
|
||||
let bl = xf([x0 as f64, y0 as f64, 0.0]);
|
||||
let br = xf([x1 as f64, y0 as f64, 0.0]);
|
||||
let tr = xf([x1 as f64, y1 as f64, 0.0]);
|
||||
let tl = xf([x0 as f64, y1 as f64, 0.0]);
|
||||
vec![bl, br, tr, bl, tr, tl]
|
||||
};
|
||||
if tris.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let final_color = resolve_wire_color(lw, ctx);
|
||||
|
||||
let key = style_key(final_color, 0.0, [0.0; 8], 1.0, lw.aci, true, true);
|
||||
let entry = out
|
||||
.by_style
|
||||
.entry(key)
|
||||
.or_insert_with(|| BatchEntry::new(final_color, 0.0, [0.0; 8], 1.0, lw.aci, true, true));
|
||||
|
||||
for p in tris {
|
||||
let (hx, lx) = WireModel::split_ds(p[0]);
|
||||
let (hy, ly) = WireModel::split_ds(p[1]);
|
||||
let (hz, lz) = WireModel::split_ds(p[2]);
|
||||
if hx < entry.min_x {
|
||||
entry.min_x = hx;
|
||||
}
|
||||
if hy < entry.min_y {
|
||||
entry.min_y = hy;
|
||||
}
|
||||
if hx > entry.max_x {
|
||||
entry.max_x = hx;
|
||||
}
|
||||
if hy > entry.max_y {
|
||||
entry.max_y = hy;
|
||||
}
|
||||
entry.fill_tris.push([hx, hy, hz]);
|
||||
entry.fill_tris_low.push([lx, ly, lz]);
|
||||
}
|
||||
}
|
||||
|
||||
/// Emit a 2-point baseline line for a sub-pixel text LocalWire — same color
|
||||
/// resolution as `emit_wire`, but the wire's stored glyph points are
|
||||
/// replaced with the OBB's baseline edge (`obb[0] → obb[1]`). Falls back to
|
||||
/// a no-op when no OBB is cached (rotation/attachment unknown).
|
||||
fn emit_text_baseline(
|
||||
lw: &LocalWire,
|
||||
accum_xform: &Transform,
|
||||
ctx: &ExpandCtx,
|
||||
out: &mut Batches,
|
||||
wpp: f32,
|
||||
) {
|
||||
let Some(obb) = lw.text_obb_local else {
|
||||
return;
|
||||
};
|
||||
let Some(h_local) = lw.text_height_local else {
|
||||
return;
|
||||
};
|
||||
if h_local <= 0.0 {
|
||||
return;
|
||||
}
|
||||
let xf = |p: [f64; 3]| -> [f64; 3] {
|
||||
let w = accum_xform.apply(Vector3::new(p[0], p[1], p[2]));
|
||||
[w.x, w.y, w.z]
|
||||
};
|
||||
|
||||
// Single line: just obb[0] → obb[1]. Skip when the projected length
|
||||
// falls under 2 px (single-char text seen edge-on). All wrap lines
|
||||
// share the same baseline length, so checking once is enough.
|
||||
let p0_world = xf(obb[0]);
|
||||
let p1_world = xf(obb[1]);
|
||||
let dx = p1_world[0] - p0_world[0];
|
||||
let dy = p1_world[1] - p0_world[1];
|
||||
let len_px = (dx * dx + dy * dy).sqrt() / wpp as f64;
|
||||
if len_px < 2.0 {
|
||||
return;
|
||||
}
|
||||
|
||||
// Compute the per-line baseline endpoints in *local* frame, then
|
||||
// transform — keeps precision for distant text and matches the
|
||||
// greek/baseline math used by the rect path.
|
||||
let (ux, uy, uz) = (
|
||||
obb[3][0] - obb[0][0],
|
||||
obb[3][1] - obb[0][1],
|
||||
obb[3][2] - obb[0][2],
|
||||
);
|
||||
let ulen = (ux * ux + uy * uy + uz * uz).sqrt();
|
||||
let n_lines = if ulen < 1e-9 {
|
||||
1
|
||||
} else {
|
||||
((ulen / h_local as f64).round() as usize).max(1)
|
||||
};
|
||||
let (nx, ny, nz) = if ulen < 1e-9 {
|
||||
(0.0, 0.0, 0.0)
|
||||
} else {
|
||||
(ux / ulen, uy / ulen, uz / ulen)
|
||||
};
|
||||
|
||||
let final_color = resolve_wire_color(lw, ctx);
|
||||
|
||||
let key = style_key(final_color, 0.0, [0.0; 8], 1.0, lw.aci, true, false);
|
||||
let entry = out
|
||||
.by_style
|
||||
.entry(key)
|
||||
.or_insert_with(|| BatchEntry::new(final_color, 0.0, [0.0; 8], 1.0, lw.aci, true, false));
|
||||
|
||||
let bl_local = obb[0];
|
||||
let br_local = obb[1];
|
||||
for i in 0..n_lines {
|
||||
let along = if ulen < 1e-9 {
|
||||
0.0
|
||||
} else {
|
||||
(ulen - ((i + 1) as f64) * h_local as f64).max(0.0)
|
||||
};
|
||||
let p0 = xf([
|
||||
bl_local[0] + nx * along,
|
||||
bl_local[1] + ny * along,
|
||||
bl_local[2] + nz * along,
|
||||
]);
|
||||
let p1 = xf([
|
||||
br_local[0] + nx * along,
|
||||
br_local[1] + ny * along,
|
||||
br_local[2] + nz * along,
|
||||
]);
|
||||
|
||||
let needs_sep = !entry.points.is_empty()
|
||||
&& !entry.points.last().map(|p| p[0].is_nan()).unwrap_or(false);
|
||||
if needs_sep {
|
||||
entry.points.push([f32::NAN; 3]);
|
||||
entry.points_low.push([0.0; 3]);
|
||||
}
|
||||
for q in [p0, p1] {
|
||||
let (hx, lx) = WireModel::split_ds(q[0]);
|
||||
let (hy, ly) = WireModel::split_ds(q[1]);
|
||||
let (hz, lz) = WireModel::split_ds(q[2]);
|
||||
if hx < entry.min_x {
|
||||
entry.min_x = hx;
|
||||
}
|
||||
if hy < entry.min_y {
|
||||
entry.min_y = hy;
|
||||
}
|
||||
if hx > entry.max_x {
|
||||
entry.max_x = hx;
|
||||
}
|
||||
if hy > entry.max_y {
|
||||
entry.max_y = hy;
|
||||
}
|
||||
entry.points.push([hx, hy, hz]);
|
||||
entry.points_low.push([lx, ly, lz]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn aabb_pixel_size(local_aabb: [f32; 4], world_per_pixel: f32) -> f32 {
|
||||
let w = (local_aabb[2] - local_aabb[0]).abs();
|
||||
let h = (local_aabb[3] - local_aabb[1]).abs();
|
||||
@@ -1135,12 +877,6 @@ fn expand_defn(
|
||||
eprintln!("block_cache: nested-block depth > {MAX_NESTING_DEPTH}, truncating");
|
||||
return;
|
||||
}
|
||||
// Track the last text OBB that received a LOD substitute (baseline line
|
||||
// or greeked box). Colour-split MTEXT produces one outline wire per
|
||||
// \C/\c segment; only the first outline from a source entity should
|
||||
// emit the substitute — subsequent segments use the same full-document
|
||||
// OBB and would draw overlapping rectangles with the wrong colour.
|
||||
let mut last_lod_obb: Option<[[f64; 3]; 4]> = None;
|
||||
for sub in &defn.subs {
|
||||
match sub {
|
||||
LocalSub::Wire(lw) => {
|
||||
@@ -1160,66 +896,13 @@ fn expand_defn(
|
||||
}
|
||||
}
|
||||
if let Some(wpp) = ctx.world_per_pixel {
|
||||
// Text/MText follows its own ladder below (baseline-line
|
||||
// / greek / full) — must reach it even when the wire's
|
||||
// AABB falls under MIN_PIXEL_SIZE.
|
||||
let is_text = lw.text_height_local.is_some();
|
||||
// SDF text wires carry glyph quads but no points; they
|
||||
// render at every zoom (no text LOD), so exempt them from
|
||||
// the sub-pixel cull that drops tiny stroke / fill geometry.
|
||||
let is_text = !lw.text_verts.is_empty();
|
||||
if !is_text && aabb_pixel_size(local, wpp) < MIN_PIXEL_SIZE {
|
||||
continue;
|
||||
}
|
||||
// Text LOD ladder: text inside a block follows the same
|
||||
// 1 / 5 px baseline/greek/full rules as top-level text.
|
||||
// We apply the Insert's transform scale to the stored
|
||||
// local glyph height to get the screen height.
|
||||
if let Some(h_local) = lw.text_height_local {
|
||||
let m = &accum_xform.matrix.m;
|
||||
let sy = ((m[1][0] * m[1][0]
|
||||
+ m[1][1] * m[1][1]
|
||||
+ m[1][2] * m[1][2]) as f64)
|
||||
.sqrt() as f32;
|
||||
let h_world = h_local * sy;
|
||||
let h_px = h_world / wpp;
|
||||
if lw.is_fill_only {
|
||||
// Fill-only wire: suppress at text LOD thresholds.
|
||||
// The outline sibling carries the LOD substitute
|
||||
// (baseline line or greeked box). Emitting every
|
||||
// glyph triangle at sub-pixel zoom defeats the
|
||||
// purpose of the LOD ladder.
|
||||
if h_px < 5.0 {
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
if h_px < 1.0 {
|
||||
// Colour-split MTEXT: only the first outline
|
||||
// wire from a source entity emits the baseline
|
||||
// substitute. Siblings share the same
|
||||
// text_obb_local and would stack identical
|
||||
// overlapping lines (last colour wins) — the
|
||||
// same dedup the greek branch does, one tier
|
||||
// down.
|
||||
if lw.text_obb_local == last_lod_obb {
|
||||
continue;
|
||||
}
|
||||
last_lod_obb = lw.text_obb_local;
|
||||
emit_text_baseline(lw, accum_xform, ctx, out, wpp);
|
||||
continue;
|
||||
}
|
||||
if h_px < 5.0 {
|
||||
// Colour-split MTEXT: only the first outline
|
||||
// wire from a source entity emits the greek
|
||||
// substitute. Subsequent segments share the
|
||||
// same text_obb_local and would draw
|
||||
// overlapping rectangles with the wrong
|
||||
// (last-wins) colour.
|
||||
if lw.text_obb_local == last_lod_obb {
|
||||
continue;
|
||||
}
|
||||
last_lod_obb = lw.text_obb_local;
|
||||
emit_greeked_text(lw, local, accum_xform, ctx, out);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
emit_wire(lw, accum_xform, ctx, out);
|
||||
}
|
||||
|
||||
+14
-135
@@ -93,10 +93,17 @@ pub(crate) fn tessellate_entity(
|
||||
let w_px = (ab[2] - ab[0]).abs();
|
||||
let h_px = (ab[3] - ab[1]).abs();
|
||||
// Keep in sync with `cache::block_cache::MIN_PIXEL_SIZE`.
|
||||
// Text/MText have their own LOD ladder below
|
||||
// (baseline-line / greek / full) and must reach it
|
||||
// even when projected size is sub-5 px.
|
||||
let is_text = matches!(e, EntityType::Text(_) | EntityType::MText(_));
|
||||
// Text entities render as SDF glyph quads (crisp at every
|
||||
// zoom, no LOD), so they must reach the full path even at
|
||||
// sub-5 px — never substitute the stub.
|
||||
let is_text = matches!(
|
||||
e,
|
||||
EntityType::Text(_)
|
||||
| EntityType::MText(_)
|
||||
| EntityType::AttributeDefinition(_)
|
||||
| EntityType::AttributeEntity(_)
|
||||
| EntityType::Tolerance(_)
|
||||
);
|
||||
// Face3D is exempt from the sub-pixel stub: it is trivially
|
||||
// cheap to tessellate (4 corners → 2 tris), so there is no
|
||||
// cost to draw it full at any zoom, and the cube-stub
|
||||
@@ -618,137 +625,9 @@ pub(crate) fn tessellate_entity(
|
||||
|
||||
let aabb = entity_aabb(e);
|
||||
|
||||
// Text-specific LOD ladder, keyed off the entity's glyph height in
|
||||
// pixels (anno-scaled):
|
||||
// < 1 px → baseline line in the text's color (text-here hint)
|
||||
// 1–5 px → greeked OBB rect in the text's color
|
||||
// ≥ 5 px → full per-glyph stroke tessellation
|
||||
//
|
||||
// Applies to every entity that is "primarily a piece of text" — Text,
|
||||
// MText, ATTDEF, ATTRIB, Tolerance — so far-out drawings don't pay the
|
||||
// full glyph-tessellation cost. Composite entities (Dimension, Table,
|
||||
// MultiLeader) carry non-text geometry and have their own LOD paths.
|
||||
if let Some(wpp) = world_per_pixel {
|
||||
let text_height: Option<f64> = match e {
|
||||
EntityType::Text(t) => Some(t.height * anno_scale as f64),
|
||||
EntityType::MText(m) => Some(m.height * anno_scale as f64),
|
||||
EntityType::AttributeDefinition(a) => Some(a.height * anno_scale as f64),
|
||||
EntityType::AttributeEntity(a) => Some(a.height * anno_scale as f64),
|
||||
EntityType::Tolerance(t) => {
|
||||
// Tolerance text_height defaults to 0.18 from creation; treat
|
||||
// 0 as missing and fall back to the AutoCAD default so the
|
||||
// pixel check still kicks in for legitimately tiny dimensions.
|
||||
let raw = if t.text_height > 0.0 { t.text_height } else { 2.5 };
|
||||
Some(raw * anno_scale as f64)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
// SDF text renders crisply at every zoom, so bypass the baseline/greek
|
||||
// LOD ladder entirely — fall through to the full path, which produces
|
||||
// the SDF glyph quads (carried on the wire) plus a snap-only wire while
|
||||
// SDF is on. Applies to every TEXT/MTEXT, top-level or composite.
|
||||
let sdf_here = crate::scene::text::sdf_atlas::sdf_text_enabled()
|
||||
&& matches!(e, EntityType::Text(_) | EntityType::MText(_));
|
||||
let text_height = if sdf_here { None } else { text_height };
|
||||
if let Some(h_world) = text_height {
|
||||
let h_px = (h_world as f32) / wpp;
|
||||
// Wrap-expanded line count for MText (Text = 1).
|
||||
let n_lines = match e {
|
||||
EntityType::MText(m) => {
|
||||
crate::entities::text_support::mtext_line_count(m, document, anno_scale)
|
||||
}
|
||||
_ => 1,
|
||||
};
|
||||
if h_px < 1.0 {
|
||||
let pts = crate::entities::text_support::text_baseline_points(e, anno_scale, n_lines);
|
||||
if pts.len() < 2 {
|
||||
return vec![];
|
||||
}
|
||||
// Skip the baseline too if the line itself projects under
|
||||
// 2 px (e.g. a 1-char text seen edge-on). All wrap lines
|
||||
// share the same baseline length, so the first segment is
|
||||
// a representative sample.
|
||||
let dx = pts[1][0] - pts[0][0];
|
||||
let dy = pts[1][1] - pts[0][1];
|
||||
let len_px = (dx * dx + dy * dy).sqrt() / wpp;
|
||||
if len_px < 2.0 {
|
||||
// Text projects to under 2 px — fall back to the
|
||||
// generic LOD stub so the entity stays visible /
|
||||
// selectable. #19. Text is 2-D in the XY plane so
|
||||
// z_min = z_max = 0 keeps the historical behaviour.
|
||||
return vec![lod_stub_wire(
|
||||
h.value().to_string(),
|
||||
entity_color,
|
||||
sel,
|
||||
aci,
|
||||
aabb,
|
||||
0.0,
|
||||
0.0,
|
||||
)];
|
||||
}
|
||||
return vec![WireModel {
|
||||
text_verts: Vec::new(),
|
||||
name: h.value().to_string(),
|
||||
points: pts,
|
||||
points_low: Vec::new(),
|
||||
color: entity_color,
|
||||
selected: sel,
|
||||
aci,
|
||||
pattern_length: 0.0,
|
||||
pattern: [0.0; 8],
|
||||
line_weight_px: 1.0,
|
||||
snap_pts: vec![],
|
||||
tangent_geoms: vec![],
|
||||
key_vertices: vec![],
|
||||
aabb,
|
||||
plinegen: true,
|
||||
vp_scissor: None,
|
||||
fill_tris: vec![],
|
||||
fill_tris_low: Vec::new(),
|
||||
}];
|
||||
}
|
||||
if h_px < 5.0 && aabb != WireModel::UNBOUNDED_AABB {
|
||||
let fill_tris = crate::entities::text_support::text_greek_obb_tris(e, anno_scale, n_lines);
|
||||
if fill_tris.is_empty() {
|
||||
// Text greek fallback: also 2-D, keep stub at z=0.
|
||||
return vec![lod_stub_wire(
|
||||
h.value().to_string(),
|
||||
entity_color,
|
||||
sel,
|
||||
aci,
|
||||
aabb,
|
||||
0.0,
|
||||
0.0,
|
||||
)];
|
||||
}
|
||||
// Greek text renders via the face3d fill batch, which colours
|
||||
// each tri with `wire.color`. Bake the selected colour in so
|
||||
// a selected text stays highlighted across the LOD boundary.
|
||||
// hit_test's AABB fallback handles window / crossing. #19.
|
||||
let fill_color = if sel { WireModel::SELECTED } else { entity_color };
|
||||
return vec![WireModel {
|
||||
text_verts: Vec::new(),
|
||||
name: h.value().to_string(),
|
||||
points: vec![],
|
||||
points_low: Vec::new(),
|
||||
color: fill_color,
|
||||
selected: sel,
|
||||
aci,
|
||||
pattern_length: 0.0,
|
||||
pattern: [0.0; 8],
|
||||
line_weight_px: 1.0,
|
||||
snap_pts: vec![],
|
||||
tangent_geoms: vec![],
|
||||
key_vertices: vec![],
|
||||
aabb,
|
||||
plinegen: true,
|
||||
vp_scissor: None,
|
||||
fill_tris,
|
||||
fill_tris_low: Vec::new(),
|
||||
}];
|
||||
}
|
||||
}
|
||||
}
|
||||
// TEXT / MTEXT / ATTDEF / ATTRIB / Tolerance all render as SDF glyph quads
|
||||
// (crisp at every zoom), so there is no text LOD ladder — they fall through
|
||||
// to the full tessellation path below.
|
||||
|
||||
let mut bases = convert::tessellate::tessellate(
|
||||
document,
|
||||
|
||||
@@ -210,8 +210,7 @@ pub fn tessellate(
|
||||
// and text into one Text object (a tolerance frame: box lines =
|
||||
// run-less, cell text = run-groups) keeps the geometry as
|
||||
// strokes and draws only the text as SDF.
|
||||
let sdf_on = crate::scene::text::sdf_atlas::sdf_text_enabled();
|
||||
for group in stroke_groups.iter().filter(|g| !(sdf_on && g.run.is_some())) {
|
||||
for group in stroke_groups.iter().filter(|g| g.run.is_none()) {
|
||||
let lx_v = group.origin[0];
|
||||
let ly_v = group.origin[1];
|
||||
let slx_v = (lx_v - ref_lx_v) * anno + ref_lx_v;
|
||||
@@ -249,12 +248,12 @@ pub fn tessellate(
|
||||
}
|
||||
|
||||
// ── SDF glyph quads ──────────────────────────────────────
|
||||
// When SDF text is on, build this run's glyph quads here and
|
||||
// carry them on the wire. They ride with the wire through the
|
||||
// tess memo, hit-materialisation and (for block content) the
|
||||
// block-expand transform — no separate document-wide collector.
|
||||
// Build each run's glyph quads here and carry them on the wire.
|
||||
// They ride with the wire through the tess memo, hit-
|
||||
// materialisation and (for block content) the block-expand
|
||||
// transform — no separate document-wide collector.
|
||||
let mut sdf_verts: Vec<crate::scene::pipeline::text_gpu::TextVertex> = Vec::new();
|
||||
if sdf_on {
|
||||
{
|
||||
if let Ok(mut atlas) = crate::scene::text::sdf_atlas::text_atlas().lock() {
|
||||
// Selection tints the whole run; otherwise inline `\C`
|
||||
// colours (bin key) win, falling back to entity colour.
|
||||
@@ -309,7 +308,7 @@ pub fn tessellate(
|
||||
// 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.
|
||||
let text_aabb = if sdf_on && !sdf_verts.is_empty() {
|
||||
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);
|
||||
for v in &sdf_verts {
|
||||
@@ -337,7 +336,7 @@ pub fn tessellate(
|
||||
// the drawing-window (canvas) colour, which masks geometry
|
||||
// behind the text like a wipeout. Box = glyph bounds padded
|
||||
// by (background_scale - 1) × text height.
|
||||
if sdf_on && text_aabb != WireModel::UNBOUNDED_AABB {
|
||||
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;
|
||||
@@ -429,8 +428,7 @@ pub fn tessellate(
|
||||
// bounds as a separate outline wire so the text box is
|
||||
// visible for testing. The empty text wire below is left
|
||||
// untouched (still the SDF + pick target).
|
||||
if sdf_on
|
||||
&& !sdf_verts.is_empty()
|
||||
if !sdf_verts.is_empty()
|
||||
&& crate::scene::text::sdf_atlas::text_box_debug()
|
||||
{
|
||||
let [nx, ny, xx, xy] = text_aabb;
|
||||
|
||||
+49
-54
@@ -145,37 +145,34 @@ pub fn click_hit<'a>(
|
||||
// text boxes qualify (never some other empty wire). Lowest priority — real
|
||||
// edges and fills above always win. Prefer the tightest box so a click over
|
||||
// overlapping text picks the smallest.
|
||||
if crate::scene::text::sdf_atlas::sdf_text_enabled() {
|
||||
let mut best_area = f32::MAX;
|
||||
let mut best_box: Option<&str> = None;
|
||||
for wire in wires {
|
||||
if wire.text_verts.is_empty()
|
||||
|| !wire.points.is_empty()
|
||||
|| !wire.fill_tris.is_empty()
|
||||
|| wire.aabb == WireModel::UNBOUNDED_AABB
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let [minx, miny, maxx, maxy] = wire.aabb;
|
||||
let (mut sx0, mut sy0, mut sx1, mut sy1) = (f32::MAX, f32::MAX, f32::MIN, f32::MIN);
|
||||
for (cx, cy) in [(minx, miny), (maxx, miny), (maxx, maxy), (minx, maxy)] {
|
||||
let s = world_to_screen(glam::DVec3::new(cx as f64, cy as f64, 0.0), view_rot, eye, bounds);
|
||||
sx0 = sx0.min(s.x);
|
||||
sx1 = sx1.max(s.x);
|
||||
sy0 = sy0.min(s.y);
|
||||
sy1 = sy1.max(s.y);
|
||||
}
|
||||
if cursor.x >= sx0 && cursor.x <= sx1 && cursor.y >= sy0 && cursor.y <= sy1 {
|
||||
let area = (sx1 - sx0) * (sy1 - sy0);
|
||||
if area < best_area {
|
||||
best_area = area;
|
||||
best_box = Some(wire.name.as_str());
|
||||
}
|
||||
let mut best_area = f32::MAX;
|
||||
let mut best_box: Option<&str> = None;
|
||||
for wire in wires {
|
||||
if wire.text_verts.is_empty()
|
||||
|| !wire.points.is_empty()
|
||||
|| !wire.fill_tris.is_empty()
|
||||
|| wire.aabb == WireModel::UNBOUNDED_AABB
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let [minx, miny, maxx, maxy] = wire.aabb;
|
||||
let (mut sx0, mut sy0, mut sx1, mut sy1) = (f32::MAX, f32::MAX, f32::MIN, f32::MIN);
|
||||
for (cx, cy) in [(minx, miny), (maxx, miny), (maxx, maxy), (minx, maxy)] {
|
||||
let s = world_to_screen(glam::DVec3::new(cx as f64, cy as f64, 0.0), view_rot, eye, bounds);
|
||||
sx0 = sx0.min(s.x);
|
||||
sx1 = sx1.max(s.x);
|
||||
sy0 = sy0.min(s.y);
|
||||
sy1 = sy1.max(s.y);
|
||||
}
|
||||
if cursor.x >= sx0 && cursor.x <= sx1 && cursor.y >= sy0 && cursor.y <= sy1 {
|
||||
let area = (sx1 - sx0) * (sy1 - sy0);
|
||||
if area < best_area {
|
||||
best_area = area;
|
||||
best_box = Some(wire.name.as_str());
|
||||
}
|
||||
}
|
||||
return best_box;
|
||||
}
|
||||
None
|
||||
best_box
|
||||
}
|
||||
|
||||
/// Like `click_hit` but returns every wire within the click threshold,
|
||||
@@ -219,32 +216,30 @@ pub fn click_hits_all<'a>(
|
||||
// wire carrying glyph quads) so selection cycling steps through text too —
|
||||
// the same discriminator/fallback `click_hit` uses. Ranked after real
|
||||
// geometry (distance = the click threshold, above every proximity hit).
|
||||
if crate::scene::text::sdf_atlas::sdf_text_enabled() {
|
||||
for wire in wires {
|
||||
if wire.text_verts.is_empty()
|
||||
|| !wire.points.is_empty()
|
||||
|| !wire.fill_tris.is_empty()
|
||||
|| wire.aabb == WireModel::UNBOUNDED_AABB
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let [minx, miny, maxx, maxy] = wire.aabb;
|
||||
let (mut sx0, mut sy0, mut sx1, mut sy1) = (f32::MAX, f32::MAX, f32::MIN, f32::MIN);
|
||||
for (cx, cy) in [(minx, miny), (maxx, miny), (maxx, maxy), (minx, maxy)] {
|
||||
let s = world_to_screen(
|
||||
glam::DVec3::new(cx as f64, cy as f64, 0.0),
|
||||
view_rot,
|
||||
eye,
|
||||
bounds,
|
||||
);
|
||||
sx0 = sx0.min(s.x);
|
||||
sx1 = sx1.max(s.x);
|
||||
sy0 = sy0.min(s.y);
|
||||
sy1 = sy1.max(s.y);
|
||||
}
|
||||
if cursor.x >= sx0 && cursor.x <= sx1 && cursor.y >= sy0 && cursor.y <= sy1 {
|
||||
hits.push((CLICK_THRESHOLD_PX, &wire.name));
|
||||
}
|
||||
for wire in wires {
|
||||
if wire.text_verts.is_empty()
|
||||
|| !wire.points.is_empty()
|
||||
|| !wire.fill_tris.is_empty()
|
||||
|| wire.aabb == WireModel::UNBOUNDED_AABB
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let [minx, miny, maxx, maxy] = wire.aabb;
|
||||
let (mut sx0, mut sy0, mut sx1, mut sy1) = (f32::MAX, f32::MAX, f32::MIN, f32::MIN);
|
||||
for (cx, cy) in [(minx, miny), (maxx, miny), (maxx, maxy), (minx, maxy)] {
|
||||
let s = world_to_screen(
|
||||
glam::DVec3::new(cx as f64, cy as f64, 0.0),
|
||||
view_rot,
|
||||
eye,
|
||||
bounds,
|
||||
);
|
||||
sx0 = sx0.min(s.x);
|
||||
sx1 = sx1.max(s.x);
|
||||
sy0 = sy0.min(s.y);
|
||||
sy1 = sy1.max(s.y);
|
||||
}
|
||||
if cursor.x >= sx0 && cursor.x <= sx1 && cursor.y >= sy0 && cursor.y <= sy1 {
|
||||
hits.push((CLICK_THRESHOLD_PX, &wire.name));
|
||||
}
|
||||
}
|
||||
hits.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
|
||||
|
||||
@@ -69,10 +69,8 @@ pub fn apply_along(
|
||||
|
||||
let mut strokes: Vec<Vec<[f32; 3]>> = Vec::new();
|
||||
let mut cur_stroke: Vec<[f32; 3]> = Vec::new();
|
||||
// When SDF text is on, embedded linetype text (LtSeg::Text) renders as glyph
|
||||
// quads collected here instead of stroke polylines; emitted as one extra
|
||||
// text-carrying WireModel at the end.
|
||||
let sdf_on = crate::scene::text::sdf_atlas::sdf_text_enabled();
|
||||
// Embedded linetype text (LtSeg::Text) renders as SDF glyph quads collected
|
||||
// here; emitted as one extra text-carrying WireModel at the end.
|
||||
let mut text_verts: Vec<crate::scene::pipeline::text_gpu::TextVertex> = Vec::new();
|
||||
|
||||
let mut elem_idx: usize = 0;
|
||||
@@ -180,46 +178,27 @@ pub fn apply_along(
|
||||
let insert = offset_pt(insert, fwd, perp, *x, *y);
|
||||
let fwd_angle = fwd[1].atan2(fwd[0]) + rot_deg.to_radians();
|
||||
let resolved = resolve_dxf_special_chars(text);
|
||||
if sdf_on {
|
||||
// SDF: glyph quads at the insert point (rotation baked in
|
||||
// by layout_glyph_quads), collected for the text wire.
|
||||
if let Ok(mut atlas) = crate::scene::text::sdf_atlas::text_atlas().lock() {
|
||||
let quads = crate::scene::text::glyph_quads::layout_glyph_quads(
|
||||
&mut atlas,
|
||||
*tx_scale,
|
||||
fwd_angle,
|
||||
1.0,
|
||||
0.0,
|
||||
0.0,
|
||||
style,
|
||||
&resolved,
|
||||
);
|
||||
crate::scene::pipeline::text_gpu::push_glyph_vertices(
|
||||
&mut text_verts,
|
||||
&quads,
|
||||
[insert[0] as f64, insert[1] as f64, insert[2] as f64],
|
||||
1.0,
|
||||
color,
|
||||
0.0,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
let (text_strokes, _) = lff::tessellate_text_ex(
|
||||
[insert[0], insert[1]],
|
||||
// SDF: glyph quads at the insert point (rotation baked in by
|
||||
// layout_glyph_quads), collected for the text wire.
|
||||
if let Ok(mut atlas) = crate::scene::text::sdf_atlas::text_atlas().lock() {
|
||||
let quads = crate::scene::text::glyph_quads::layout_glyph_quads(
|
||||
&mut atlas,
|
||||
*tx_scale,
|
||||
fwd_angle,
|
||||
1.0,
|
||||
0.0,
|
||||
0.0,
|
||||
style,
|
||||
&resolved,
|
||||
);
|
||||
for stroke in &text_strokes {
|
||||
if stroke.len() >= 2 {
|
||||
let pts: Vec<[f32; 3]> =
|
||||
stroke.iter().map(|&[sx, sy]| [sx, sy, insert[2]]).collect();
|
||||
strokes.push(pts);
|
||||
}
|
||||
}
|
||||
crate::scene::pipeline::text_gpu::push_glyph_vertices(
|
||||
&mut text_verts,
|
||||
&quads,
|
||||
[insert[0] as f64, insert[1] as f64, insert[2] as f64],
|
||||
1.0,
|
||||
color,
|
||||
0.0,
|
||||
);
|
||||
}
|
||||
|
||||
elem_idx += 1;
|
||||
|
||||
@@ -39,18 +39,9 @@ pub fn text_atlas() -> &'static Mutex<GlyphAtlas> {
|
||||
ATLAS.get_or_init(|| Mutex::new(GlyphAtlas::new(1024, 1024)))
|
||||
}
|
||||
|
||||
/// Whether SDF text rendering is enabled, read once. Default ON — text draws
|
||||
/// as SDF glyph quads (crisp at every zoom, no LOD; the wire path suppresses
|
||||
/// the glyph strokes and keeps the insertion snap). Set `OCS_TEXT_SDF=0` to
|
||||
/// fall back to the legacy stroke path (kill switch for the stroke renderer).
|
||||
pub fn sdf_text_enabled() -> bool {
|
||||
static ON: OnceLock<bool> = OnceLock::new();
|
||||
*ON.get_or_init(|| std::env::var("OCS_TEXT_SDF").map_or(true, |v| v != "0"))
|
||||
}
|
||||
|
||||
/// 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. Only meaningful together with [`sdf_text_enabled`].
|
||||
/// Read once.
|
||||
pub fn text_box_debug() -> bool {
|
||||
static ON: OnceLock<bool> = OnceLock::new();
|
||||
*ON.get_or_init(|| std::env::var_os("OCS_TEXT_BOX").is_some())
|
||||
|
||||
@@ -798,9 +798,6 @@ impl Scene {
|
||||
wire_content_id: u64,
|
||||
) -> std::sync::Arc<Vec<crate::scene::pipeline::text_gpu::TextVertex>> {
|
||||
use std::sync::Arc;
|
||||
if !crate::scene::text::sdf_atlas::sdf_text_enabled() {
|
||||
return Arc::new(Vec::new());
|
||||
}
|
||||
{
|
||||
let cache = self.sdf_text_cache.borrow();
|
||||
if let Some((id, verts)) = cache.as_ref() {
|
||||
|
||||
Reference in new issue
Block a user