From 896b6c385c3bda95edf9dc0a0916b5e7de07bfd4 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Mon, 6 Jul 2026 23:02:34 +0300 Subject: [PATCH] feat(text): render TEXT as SDF quads behind OCS_TEXT_SDF flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the SDF glyph atlas into the render loop end-to-end for top-level TEXT entities, gated by the OCS_TEXT_SDF env var so the default path is unchanged. Verified rendering on a real drawing: glyph quads land exactly over the existing stroke text (position, size, rotation), confirming the atlas → shader → pipeline → screen path and the RTE / UV math. - text::text_run_placement — extracted from to_truck (behaviour-preserved) so the stroke and SDF-quad paths compute an identical run placement. - sdf_atlas::text_atlas — process-wide GlyphAtlas (Mutex/OnceLock), shared by the collector (bakes glyphs) and the GPU upload (reads texels). - Scene::sdf_text_vertices — collects per-glyph TextVertex for the frame's TEXT entities via layout_glyph_quads + push_glyph_vertices. Empty unless OCS_TEXT_SDF is set. - ViewportData.text_verts + Pipeline text_pipeline/atlas/vbuf, upload_text (rebuilds the R8 atlas texture when it grows), and a text render pass drawn over the wires. Still additive over the stroke text; only top-level TEXT is wired (MTEXT, attributes, and block-internal text are next), and the collector re-runs per frame (vertex caching by geometry epoch is a follow-up). Co-Authored-By: Claude Opus 4.8 --- src/entities/text.rs | 74 +++++++++++++++++++++++-------- src/scene/pipeline/mod.rs | 79 ++++++++++++++++++++++++++++++++++ src/scene/pipeline/text_gpu.rs | 2 +- src/scene/text/sdf_atlas.rs | 9 ++++ src/scene/view/render.rs | 64 +++++++++++++++++++++++++++ 5 files changed, 208 insertions(+), 20 deletions(-) diff --git a/src/entities/text.rs b/src/entities/text.rs index d1a950176..c32508147 100644 --- a/src/entities/text.rs +++ b/src/entities/text.rs @@ -47,7 +47,55 @@ pub(crate) fn sync_text_alignment_point(t: &mut Text) { } } +/// Resolved placement of a TEXT run: the baseline-anchored run origin (WCS xy) +/// plus every parameter needed to lay the glyphs out. Shared by `to_truck` (the +/// stroke path) and the SDF-quad text collector so both place text identically. +pub struct TextPlacement { + /// Run-local origin (glyph space `[0,0]` maps here), WCS xy, kept f64. + pub origin: [f64; 2], + /// Entity elevation (WCS z). + pub elevation: f64, + pub height: f32, + pub rotation: f32, + pub width_factor: f32, + pub oblique_angle: f32, + pub font: String, + /// Raw entity value (as passed to the tessellator). + pub value: String, + /// Full WCS insertion point, for the Insertion snap. + pub wcs_insertion: [f64; 3], +} + fn to_truck(t: &Text, document: &acadrust::CadDocument) -> TruckEntity { + let p = text_run_placement(t, document); + let snap_pt = glam::DVec3::new(p.wcs_insertion[0], p.wcs_insertion[1], p.wcs_insertion[2]); + // Strokes are in glyph-local space (origin = [0,0]). + let (strokes, fill_tris) = lff::tessellate_text_ex( + [0.0, 0.0], + p.height, + p.rotation, + p.width_factor, + p.oblique_angle, + &p.font, + &p.value, + ); + TruckEntity { + object: TruckObject::Text(vec![TextStroke { + strokes, + origin: p.origin, + color: None, + fill_tris, + }]), + snap_pts: vec![(snap_pt, SnapHint::Insertion)], + tangent_geoms: vec![], + key_vertices: vec![], + fill_tris: vec![], + } +} + +/// Compute a TEXT entity's run placement (origin + layout params). Extracted +/// from `to_truck` verbatim so the stroke and SDF-quad paths agree exactly. +pub fn text_run_placement(t: &Text, document: &acadrust::CadDocument) -> TextPlacement { let normal = (t.normal.x, t.normal.y, t.normal.z); let (wsx, wsy, wsz) = crate::scene::view::transform::ocs_point_to_wcs( ( @@ -57,7 +105,6 @@ fn to_truck(t: &Text, document: &acadrust::CadDocument) -> TruckEntity { ), normal, ); - let snap_pt = glam::DVec3::new(wsx, wsy, wsz); let resolved_style = resolve_text_style(&t.style, document); let font_name = resolved_style.font_name; // AutoCAD text geometry rule: the entity stores the FINAL width factor / @@ -142,27 +189,16 @@ fn to_truck(t: &Text, document: &acadrust::CadDocument) -> TruckEntity { anchor_f64[0] - (anchor_local_x as f64 * cos_r - anchor_local_y as f64 * sin_r), anchor_f64[1] - (anchor_local_x as f64 * sin_r + anchor_local_y as f64 * cos_r), ]; - // Strokes are in glyph-local space (origin = [0,0]). - let (strokes, fill_tris) = lff::tessellate_text_ex( - [0.0, 0.0], - t.height as f32, + TextPlacement { + origin, + elevation: wsz, + height: t.height as f32, rotation, width_factor, oblique_angle, - &font_name, - &t.value, - ); - TruckEntity { - object: TruckObject::Text(vec![TextStroke { - strokes, - origin, - color: None, - fill_tris, - }]), - snap_pts: vec![(snap_pt, SnapHint::Insertion)], - tangent_geoms: vec![], - key_vertices: vec![], - fill_tris: vec![], + font: font_name, + value: t.value.clone(), + wcs_insertion: [wsx, wsy, wsz], } } diff --git a/src/scene/pipeline/mod.rs b/src/scene/pipeline/mod.rs index bc8ee6ee3..d0dad43a2 100644 --- a/src/scene/pipeline/mod.rs +++ b/src/scene/pipeline/mod.rs @@ -48,6 +48,9 @@ pub struct Pipeline { /// vertex-stage storage buffers. hatch_batched_pipeline: Option, image_pipeline: wgpu::RenderPipeline, + /// SDF text-quad pipeline (Phase 2b): draws per-glyph quads sampling the + /// shared glyph atlas. Fed only when `OCS_TEXT_SDF` is set (else no verts). + text_pipeline: wgpu::RenderPipeline, mesh_pipeline: wgpu::RenderPipeline, /// Depth-write-disabled variant of `mesh_pipeline` for non-opaque solids. mesh_transparent_pipeline: wgpu::RenderPipeline, @@ -74,6 +77,14 @@ pub struct Pipeline { /// for instances / boundary / families / dashes). `None` on WebGL2. hatch_batched_bgl1: Option, image_bgl1: wgpu::BindGroupLayout, + /// Group-1 layout for the text pipeline (atlas texture + sampler). + text_atlas_bgl: wgpu::BindGroupLayout, + /// GPU glyph atlas (texture + sampler + bind group). Rebuilt when the shared + /// CPU atlas grows (new glyphs baked). `None` until the first text upload. + text_atlas_gpu: Option, + /// All glyph-quad vertices for the frame, one buffer, `None` when empty. + text_vbuf: Option, + text_vcount: u32, depth_texture_size: Size, depth_view: wgpu::TextureView, /// 4× MSAA color buffer for the main drawing passes. @@ -986,6 +997,11 @@ impl Pipeline { cache: None, }); + // ── Text (SDF glyph quads) ───────────────────────────────────────── + let text_atlas_bgl = text_gpu::TextAtlasGpu::bind_group_layout(device); + let text_pipeline = + text_gpu::create_pipeline(device, &frame_bgl, &text_atlas_bgl, format, MSAA_SAMPLES); + let viewcube = ViewCubePipeline::new(device, queue, format); let init_size = Size::new(1, 1); @@ -1125,6 +1141,11 @@ impl Pipeline { hatch_pipeline, hatch_batched_pipeline, image_pipeline, + text_pipeline, + text_atlas_bgl, + text_atlas_gpu: None, + text_vbuf: None, + text_vcount: 0, mesh_pipeline, mesh_transparent_pipeline, mesh_highlight_pipeline, @@ -1528,6 +1549,30 @@ impl Pipeline { .collect(); } + /// Upload the frame's SDF text-quad vertices, and (re)build the GPU glyph + /// atlas from the shared CPU atlas when it grew (new glyphs baked by the + /// text collector). `verts` empty (flag off) leaves nothing to draw. + pub fn upload_text( + &mut self, + device: &wgpu::Device, + queue: &wgpu::Queue, + verts: &[text_gpu::TextVertex], + ) { + if let Ok(mut atlas) = crate::scene::text::sdf_atlas::text_atlas().lock() { + if self.text_atlas_gpu.is_none() || atlas.is_dirty() { + self.text_atlas_gpu = Some(text_gpu::TextAtlasGpu::upload( + device, + queue, + &atlas, + &self.text_atlas_bgl, + )); + atlas.clear_dirty(); + } + } + self.text_vbuf = text_gpu::upload_vertices(device, verts); + self.text_vcount = verts.len() as u32; + } + pub fn upload_uniforms(&self, queue: &wgpu::Queue, uniforms: &Uniforms) { queue.write_buffer(&self.uniform_buffer, 0, bytemuck::bytes_of(uniforms)); } @@ -2020,6 +2065,40 @@ impl Pipeline { } } + // ── Pass 5c: SDF text quads (drawn over wires) ──────────────────── + if let (Some(vbuf), Some(atlas)) = (&self.text_vbuf, &self.text_atlas_gpu) { + if self.text_vcount > 0 { + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("text.render_pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: msaa, + depth_slice: None, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Load, + store: wgpu::StoreOp::Store, + }, + })], + depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment { + view: &self.depth_view, + depth_ops: Some(wgpu::Operations { + load: wgpu::LoadOp::Load, + store: wgpu::StoreOp::Store, + }), + stencil_ops: None, + }), + timestamp_writes: None, + occlusion_query_set: None, + }); + pass.set_viewport(0.0, 0.0, vp.width as f32, vp.height as f32, 0.0, 1.0); + pass.set_pipeline(&self.text_pipeline); + pass.set_bind_group(0, &self.uniform_bind_group, &[]); + pass.set_bind_group(1, &atlas.bind_group, &[]); + pass.set_vertex_buffer(0, vbuf.slice(..)); + pass.draw(0..self.text_vcount, 0..1); + } + } + // ── Pass 6: wipeout fills (drawn after wires to mask them) ──────── if !self.gpu_wipeouts.is_empty() { let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { diff --git a/src/scene/pipeline/text_gpu.rs b/src/scene/pipeline/text_gpu.rs index 56b6bc577..7cc996ac1 100644 --- a/src/scene/pipeline/text_gpu.rs +++ b/src/scene/pipeline/text_gpu.rs @@ -22,7 +22,7 @@ use crate::scene::text::sdf_atlas::GlyphAtlas; // ── Vertex ──────────────────────────────────────────────────────────────── #[repr(C)] -#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] +#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)] pub struct TextVertex { pub pos: [f32; 3], pub pos_low: [f32; 3], diff --git a/src/scene/text/sdf_atlas.rs b/src/scene/text/sdf_atlas.rs index 4fd7921ed..05d2b64f8 100644 --- a/src/scene/text/sdf_atlas.rs +++ b/src/scene/text/sdf_atlas.rs @@ -26,10 +26,19 @@ #![allow(dead_code)] use std::collections::HashMap; +use std::sync::{Mutex, OnceLock}; use crate::scene::text::font_face::Face; use crate::scene::text::lff::Glyph; +/// Process-wide glyph atlas, shared by the text collector (which bakes glyphs +/// while building draw data) and the GPU upload (which reads the texels). +/// Mirrors the existing global font-glyph caches (see `ttf_glyph`). +pub fn text_atlas() -> &'static Mutex { + static ATLAS: OnceLock> = OnceLock::new(); + ATLAS.get_or_init(|| Mutex::new(GlyphAtlas::new(1024, 1024))) +} + // ── Bake configuration ────────────────────────────────────────────────────── /// Texels per glyph unit. Glyphs live in a 9-unit cap-height space, so a cap diff --git a/src/scene/view/render.rs b/src/scene/view/render.rs index d94a6f018..bd0eb0ce5 100644 --- a/src/scene/view/render.rs +++ b/src/scene/view/render.rs @@ -39,6 +39,8 @@ pub struct ViewportData { /// 3DFACE entity wires — separated so they are uploaded to the dedicated /// face3d pipeline (fill + batched edges) instead of N individual WireGpu. pub(in crate::scene) face3d_wires: Arc>, + /// SDF text-quad vertices (Phase 2b). Empty unless `OCS_TEXT_SDF` is set. + pub(in crate::scene) text_verts: Arc>, /// Per-entity normalized draw-order depth (handle.value() → (0,1)), used /// by the wire / face3d pipelines as a clip-z bias. WireModels carry no /// depth field (84 construction sites); the bias is looked up by handle @@ -298,6 +300,7 @@ impl shader::Primitive for Primitive { } if geo_changed { inner.upload_images(device, queue, &vp.images[..]); + inner.upload_text(device, queue, &vp.text_verts[..]); } inner.cached_epoch = cur_key; } @@ -772,6 +775,59 @@ pub(crate) fn adapt_to_bg(color: [f32; 4], bg: [f32; 4]) -> [f32; 4] { // ── Primitive builder helpers (called by ViewportPane's shader::Program impl) ── impl Scene { + /// SDF text-quad vertices for the current document's TEXT entities. Behind + /// the `OCS_TEXT_SDF` env flag (Phase 2b bring-up) so the default render + /// path is untouched; empty otherwise. Glyphs are baked into the shared + /// atlas as a side effect. Both the stroke path and this share + /// `text_run_placement`, so quads land exactly where the strokes do. + #[allow(dead_code)] // consumed by the text render pass in the GPU integration step + pub(in crate::scene) fn sdf_text_vertices( + &self, + ) -> Vec { + self.sdf_text_vertices_enabled(std::env::var_os("OCS_TEXT_SDF").is_some()) + } + + #[allow(dead_code)] + fn sdf_text_vertices_enabled( + &self, + enabled: bool, + ) -> Vec { + use crate::scene::pipeline::text_gpu; + use crate::scene::text::{glyph_quads, sdf_atlas}; + if !enabled { + return Vec::new(); + } + let Ok(mut atlas) = sdf_atlas::text_atlas().lock() else { + return Vec::new(); + }; + 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; + 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, + ); + text_gpu::push_glyph_vertices( + &mut out, + &quads, + [p.origin[0], p.origin[1], p.elevation], + 1.0, + color, + 0.0, + ); + } + } + out + } + /// Build the unified multi-viewport `Primitive` for the current layout. /// Model layout → one full-window viewport (more once tiled); paper /// layout → one viewport per floating content viewport. Each entry is @@ -1053,10 +1109,18 @@ impl Scene { self.meshes_arc() }; + // SDF text quads (Phase 2b, behind OCS_TEXT_SDF). Model/content only — + // the paper sheet must not draw model-space text onto the sheet. + let text_verts = if inst.paper_sheet { + Arc::new(Vec::new()) + } else { + Arc::new(self.sdf_text_vertices()) + }; Some(ViewportData { wires: all_wires, preview_wires, face3d_wires, + text_verts, draw_depths: self.draw_depth_map(), hatches, wipeout_hatches,