diff --git a/src/entities/tolerance.rs b/src/entities/tolerance.rs index 3c62229c9..2f78fd0bc 100644 --- a/src/entities/tolerance.rs +++ b/src/entities/tolerance.rs @@ -3,7 +3,7 @@ use acadrust::entities::Tolerance; use crate::command::EntityTransform; use crate::entities::common::{edit_prop as edit, ro_prop as ro, square_grip}; use crate::entities::traits::{Grippable, PropertyEditable, Transformable, TruckConvertible}; -use crate::scene::convert::acad_to_truck::{TextStroke, TruckEntity, TruckObject}; +use crate::scene::convert::acad_to_truck::{GlyphRun, TextStroke, TruckEntity, TruckObject}; use crate::scene::model::object::{GripApply, GripDef, PropSection}; use crate::scene::model::wire_model::SnapHint; use crate::scene::text::lff; @@ -103,25 +103,34 @@ fn gdt_char_to_ascii(c: char) -> &'static str { // ── Feature-control frame builder ───────────────────────────────────────────── -/// Tessellate a Tolerance entity into CXF-style polyline output. +/// One text cell of a feature-control frame, ready to become a `TextStroke` +/// with a `GlyphRun` so the cell text can render as SDF glyph quads (or, when +/// SDF is off, from `strokes`). `origin` is relative to the tolerance insertion +/// point (already rotated); `strokes` are the glyph polylines rotated about the +/// origin (no origin translation — the wire-builder adds the origin). +struct TolCell { + text: String, + origin: [f32; 2], + strokes: Vec>, + height: f32, + rotation: f32, +} + +/// Tessellate a Tolerance entity's feature-control frame. /// -/// Returns (`box_lines`, `text_strokes`) where: -/// - `box_lines` — 3-D line segments forming the outer border and dividers -/// - `text_strokes` — 2-D polylines from the CXF tessellator -/// -/// Since TruckObject has either Lines (3-D) or Text (2-D CXF), we render the -/// box frame via a separate Lines path (added by the caller) and the text cells -/// via the Text path. For simplicity this function packs everything into a -/// single Vec> by projecting the box lines to 2-D and using NaN -/// polylines as separators — exactly what the Text wire-builder does. -fn tessellate_tolerance(tol: &Tolerance) -> Vec> { +/// Returns (`box_strokes`, `cells`): +/// - `box_strokes` — the outer border, row separators and column dividers as +/// 2-D polylines (rotated; run-less, so they always render as strokes) +/// - `cells` — one [`TolCell`] per non-empty cell, carrying its text + a +/// `GlyphRun` so the cell renders as SDF text (frame stays geometry). +fn tessellate_tolerance(tol: &Tolerance) -> (Vec>, Vec) { if tol.text.is_empty() { - return vec![]; + return (vec![], vec![]); } let rows = parse_gdt_rows(&tol.text); if rows.is_empty() { - return vec![]; + return (vec![], vec![]); } // ── Metrics ────────────────────────────────────────────────────────── @@ -162,10 +171,11 @@ fn tessellate_tolerance(tol: &Tolerance) -> Vec> { // Rotate only; origin is kept as f64 and applied later with full precision. let rot = |x: f32, y: f32| -> [f32; 2] { [x * ca - y * sa, x * sa + y * ca] }; - let mut out: Vec> = Vec::new(); + let mut box_out: Vec> = Vec::new(); + let mut cells: Vec = Vec::new(); // ── Outer border ────────────────────────────────────────────────────── - out.push(vec![ + box_out.push(vec![ rot(0.0, 0.0), rot(total_w, 0.0), rot(total_w, total_h), @@ -176,7 +186,7 @@ fn tessellate_tolerance(tol: &Tolerance) -> Vec> { // ── Row separators ───────────────────────────────────────────────────── for ri in 1..rows.len() { let y = cell_h * ri as f32; - out.push(vec![rot(0.0, y), rot(total_w, y)]); + box_out.push(vec![rot(0.0, y), rot(total_w, y)]); } // ── Column dividers ──────────────────────────────────────────────────── @@ -187,12 +197,16 @@ fn tessellate_tolerance(tol: &Tolerance) -> Vec> { if ci + 1 < rows[ri].len() { let y0 = cell_h * ri as f32; let y1 = y0 + cell_h; - out.push(vec![rot(x_cursor, y0), rot(x_cursor, y1)]); + box_out.push(vec![rot(x_cursor, y0), rot(x_cursor, y1)]); } } } // ── Text content per cell ───────────────────────────────────────────── + // Each cell becomes a TolCell: its origin is the (rotated) cell position + // relative to the insertion point; its `strokes` are the glyph polylines + // rotated about that origin (used only when SDF is off). The GlyphRun the + // caller attaches lets the cell render as SDF text. for (ri, row) in rows.iter().enumerate() { let row_y = cell_h * ri as f32 + gap; let mut cell_x = 0.0_f32; @@ -205,24 +219,28 @@ fn tessellate_tolerance(tol: &Tolerance) -> Vec> { if !cell.is_empty() { let text_w = cell.len() as f32 * char_w; let tx = cell_x + (cw - text_w) * 0.5; - // Tessellate text in local frame then transform let (local_strokes, _) = lff::tessellate_text_ex([0.0, 0.0], h, 0.0, 1.0, 0.0, "txt", cell); - for polyline in local_strokes { - let transformed: Vec<[f32; 2]> = polyline - .into_iter() - .map(|[px, py]| rot(px + tx, py + row_y)) - .collect(); - if !transformed.is_empty() { - out.push(transformed); - } - } + // Glyph polylines rotated about the cell origin (no origin + // translation — the wire-builder adds `origin`). + let strokes: Vec> = local_strokes + .into_iter() + .map(|pl| pl.into_iter().map(|[px, py]| rot(px, py)).collect()) + .filter(|pl: &Vec<[f32; 2]>| !pl.is_empty()) + .collect(); + cells.push(TolCell { + text: cell.clone(), + origin: rot(tx, row_y), + strokes, + height: h, + rotation: angle, + }); } cell_x += cw; } } - out + (box_out, cells) } // ── TruckConvertible ────────────────────────────────────────────────────────── @@ -240,17 +258,46 @@ impl TruckConvertible for Tolerance { ); // Build the feature-control frame in local space; origin stored as f64. - let strokes = tessellate_tolerance(self); - let origin = [self.insertion_point.x, self.insertion_point.y]; + let (box_strokes, cells) = tessellate_tolerance(self); + let ins = [self.insertion_point.x, self.insertion_point.y]; - Some(TruckEntity { - object: TruckObject::Text(vec![TextStroke { - strokes, - origin, + // Frame geometry first (run-less → always strokes; also the anchor + // group so its origin = the insertion point), then one run-group per + // text cell so the cell text renders as SDF glyphs (or strokes when + // SDF is off). + let mut groups: Vec = Vec::with_capacity(1 + cells.len()); + if !box_strokes.is_empty() { + groups.push(TextStroke { + strokes: box_strokes, + origin: ins, color: None, fill_tris: vec![], run: None, - }]), + }); + } + for cell in cells { + groups.push(TextStroke { + strokes: cell.strokes, + origin: [ins[0] + cell.origin[0] as f64, ins[1] + cell.origin[1] as f64], + color: None, + fill_tris: vec![], + run: Some(GlyphRun { + text: cell.text, + font: "txt".to_string(), + height: cell.height, + rotation: cell.rotation, + width_factor: 1.0, + oblique: 0.0, + tracking: 0.0, + }), + }); + } + if groups.is_empty() { + return None; + } + + Some(TruckEntity { + object: TruckObject::Text(groups), snap_pts: vec![(snap_pt, SnapHint::Insertion)], tangent_geoms: vec![], key_vertices: vec![], diff --git a/src/scene/convert/tessellate.rs b/src/scene/convert/tessellate.rs index d26dde6e3..bfa532826 100644 --- a/src/scene/convert/tessellate.rs +++ b/src/scene/convert/tessellate.rs @@ -202,17 +202,16 @@ pub fn tessellate( }; let anno = anno_scale as f64; - // With SDF text on, glyphs render as textured quads carried on - // this entity's own wire (built below) — skip the stroke - // geometry so text is not double-drawn. `bins` stays empty, so - // the empty-wire path emits the insertion snap point and the - // glyph quads. Applies to every TEXT/MTEXT the tessellator - // sees, whether a top-level entity, a dimension's text, or a - // block-internal string — each owns its glyphs, no central - // collector, no re-explosion. - let sdf_text = crate::scene::text::sdf_atlas::sdf_text_enabled() - && matches!(entity, EntityType::Text(_) | EntityType::MText(_)); - for group in stroke_groups.iter().filter(|_| !sdf_text) { + // SDF text is decided PER GROUP by whether the group carries a + // `GlyphRun`: a run-group renders as textured quads (built + // below) so its strokes are suppressed; a run-less group keeps + // its strokes. TEXT / MTEXT / dim / block / mleader text are all + // run-groups → fully SDF; a composite object that packs geometry + // 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())) { let lx_v = group.origin[0]; let ly_v = group.origin[1]; let slx_v = (lx_v - ref_lx_v) * anno + ref_lx_v; @@ -255,7 +254,7 @@ pub fn tessellate( // tess memo, hit-materialisation and (for block content) the // block-expand transform — no separate document-wide collector. let mut sdf_verts: Vec = Vec::new(); - if sdf_text { + 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. @@ -302,42 +301,43 @@ pub fn tessellate( .map(|[x, y, z]| [x, y, z]) .collect(); + // Pick box straight from the rendered glyph quads — the true + // text extent. entity_aabb is unreliable for MTEXT (its box sits + // beside the laid-out glyphs), so derive the AABB from + // `sdf_verts` (accumulate in f64, reconstruct high+low, then cast + // to f32 once) and stop the generic stamp clobbering it (tess.rs + // 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 (mut nx, mut ny, mut xx, mut xy) = + (f64::MAX, f64::MAX, f64::MIN, f64::MIN); + for v in &sdf_verts { + let x = v.pos[0] as f64 + v.pos_low[0] as f64; + let y = v.pos[1] as f64 + v.pos_low[1] as f64; + nx = nx.min(x); + xx = xx.max(x); + ny = ny.min(y); + xy = xy.max(y); + } + [nx as f32, ny as f32, xx as f32, xy as f32] + } else { + WireModel::UNBOUNDED_AABB + }; + // Empty input (no glyphs) → emit a single empty wire so the // entity still has a hit-test target via snap_pts. With SDF on // this is the normal path (strokes suppressed) and the wire // also carries the glyph quads built above. if bins.is_empty() { let mut wires: Vec = Vec::new(); - // Pick box straight from the rendered glyph quads — the true - // text extent. entity_aabb is unreliable for MTEXT (its box - // sits beside the laid-out glyphs), so we derive the AABB - // from `sdf_verts` and stop the generic stamp from - // clobbering it (tess.rs guards on `text_verts`). - let text_aabb = if sdf_text && !sdf_verts.is_empty() { - // Accumulate in f64 (reconstruct high + low) so the box - // stays accurate at UTM scale, then cast the corners to - // f32 once for storage. - let (mut nx, mut ny, mut xx, mut xy) = - (f64::MAX, f64::MAX, f64::MIN, f64::MIN); - for v in &sdf_verts { - let x = v.pos[0] as f64 + v.pos_low[0] as f64; - let y = v.pos[1] as f64 + v.pos_low[1] as f64; - nx = nx.min(x); - xx = xx.max(x); - ny = ny.min(y); - xy = xy.max(y); - } - [nx as f32, ny as f32, xx as f32, xy as f32] - } else { - WireModel::UNBOUNDED_AABB - }; // MTEXT background fill / mask: an opaque rectangle behind // the glyphs, emitted first so it renders under the text. // Flag 0x01 → the entity's background-fill colour; 0x02 → // 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_text && text_aabb != WireModel::UNBOUNDED_AABB { + if sdf_on && 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,7 +429,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_text + if sdf_on && !sdf_verts.is_empty() && crate::scene::text::sdf_atlas::text_box_debug() { @@ -561,6 +561,35 @@ pub fn tessellate( } } + // Composite Text objects (e.g. a tolerance frame) keep geometry + // in `bins` (run-less groups) and text in `sdf_verts` (run + // groups). Emit the glyphs on their own wire carrying the tight + // glyph-box AABB so the text draws + picks alongside the box + // strokes. TEXT / MTEXT never reach here with SDF on (their bins + // are empty → the early-return path above). + if !sdf_verts.is_empty() { + out.push(WireModel { + text_verts: sdf_verts, + name: name.clone(), + points: Vec::new(), + points_low: Vec::new(), + color, + selected, + pattern_length: 0.0, + pattern: [0.0; 8], + line_weight_px, + snap_pts: Vec::new(), + tangent_geoms: Vec::new(), + aci: 0, + key_vertices: Vec::new(), + aabb: text_aabb, + plinegen: true, + vp_scissor: None, + fill_tris: vec![], + fill_tris_low: Vec::new(), + }); + } + if out.is_empty() { out.push(WireModel { text_verts: Vec::new(), diff --git a/src/scene/pick/hit_test.rs b/src/scene/pick/hit_test.rs index f1d55f0b3..1eff22856 100644 --- a/src/scene/pick/hit_test.rs +++ b/src/scene/pick/hit_test.rs @@ -215,6 +215,38 @@ pub fn click_hits_all<'a>( hits.push((best_for_wire, &wire.name)); } } + // SDF text: include a text whose box contains the cursor (an empty-stroke + // 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)); + } + } + } hits.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal)); hits.into_iter().map(|(_, name)| name).collect() } diff --git a/src/scene/text/complex_lt.rs b/src/scene/text/complex_lt.rs index b9818d926..159cc345f 100644 --- a/src/scene/text/complex_lt.rs +++ b/src/scene/text/complex_lt.rs @@ -69,6 +69,11 @@ pub fn apply_along( let mut strokes: Vec> = 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(); + let mut text_verts: Vec = Vec::new(); let mut elem_idx: usize = 0; let mut elem_consumed: f32 = 0.0; @@ -175,20 +180,45 @@ 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); - let (text_strokes, _) = lff::tessellate_text_ex( - [insert[0], insert[1]], - *tx_scale, - fwd_angle, - 1.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); + 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]], + *tx_scale, + fwd_angle, + 1.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); + } } } @@ -204,7 +234,7 @@ pub fn apply_along( flush(&mut cur_stroke, &mut strokes); - strokes + let mut out: Vec = strokes .into_iter() .filter(|s| s.len() >= 2) .map(|pts| WireModel { @@ -227,7 +257,43 @@ pub fn apply_along( fill_tris: vec![], fill_tris_low: Vec::new(), }) - .collect() + .collect(); + + // Embedded linetype text (SDF): one extra wire carrying the glyph quads, + // with a glyph-bounds AABB (f64 accumulate → f32) so it stays precise at + // UTM scale. Empty points so it doesn't add stroke geometry. + if !text_verts.is_empty() { + let (mut nx, mut ny, mut xx, mut xy) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN); + for v in &text_verts { + let x = v.pos[0] as f64 + v.pos_low[0] as f64; + let y = v.pos[1] as f64 + v.pos_low[1] as f64; + nx = nx.min(x); + xx = xx.max(x); + ny = ny.min(y); + xy = xy.max(y); + } + out.push(WireModel { + text_verts, + name: name.to_string(), + points: Vec::new(), + points_low: Vec::new(), + color, + selected, + pattern_length: 0.0, + pattern: [0.0; 8], + line_weight_px, + snap_pts: vec![], + tangent_geoms: vec![], + aci: 0, + key_vertices: vec![], + aabb: [nx as f32, ny as f32, xx as f32, xy as f32], + plinegen: true, + vp_scissor: None, + fill_tris: vec![], + fill_tris_low: Vec::new(), + }); + } + out } // ── Helpers ───────────────────────────────────────────────────────────────