Skip to main content

dotloom_render/
tess.rs

1//! CPU tessellation of scene items into GPU-ready meshes.
2//!
3//! Coordinates are `f32` relative to a per-mesh origin (`f64`), so large world
4//! coordinates keep sub-micrometre precision on the GPU.
5
6use bytemuck::{Pod, Zeroable};
7use dotloom_geometry::{Aabb, FlattenTolerance, Point, Shape, Text};
8use dotloom_scene::{Primitive, SceneItem, Stroke, flags};
9use lyon_tessellation::math::point as lpoint;
10use lyon_tessellation::path::Path;
11use lyon_tessellation::{
12    BuffersBuilder, FillOptions, FillRule, FillTessellator, FillVertex as LyonVertex, VertexBuffers,
13};
14
15use crate::color::{Rgba, Theme, premul_bytes, with_alpha};
16use crate::text::TextSystem;
17
18/// Screen-constant line segment (instanced; expanded in the vertex shader).
19#[repr(C)]
20#[derive(Debug, Clone, Copy, PartialEq, Pod, Zeroable)]
21pub struct LineInstance {
22    /// Start (local units).
23    pub p0: [f32; 2],
24    /// End (local units).
25    pub p1: [f32; 2],
26    /// Premultiplied RGBA8.
27    pub color: u32,
28    /// Width in CSS pixels.
29    pub width: f32,
30    /// Distance along the path at `p0` (local units), for dash phase.
31    pub dist0: f32,
32    /// Dash pattern in CSS pixels (`[on, off, on, off]`; `x <= 0` = solid).
33    pub dash: [f32; 4],
34}
35
36/// Filled triangle vertex.
37#[repr(C)]
38#[derive(Debug, Clone, Copy, PartialEq, Pod, Zeroable)]
39pub struct FillVertex {
40    /// Position (local units).
41    pub pos: [f32; 2],
42    /// Premultiplied RGBA8.
43    pub color: u32,
44}
45
46/// One glyph quad (instanced).
47#[repr(C)]
48#[derive(Debug, Clone, Copy, PartialEq, Pod, Zeroable)]
49pub struct GlyphInstance {
50    /// Bottom-left corner (local units).
51    pub origin: [f32; 2],
52    /// Quad x edge (local units).
53    pub axis_x: [f32; 2],
54    /// Quad y edge (local units).
55    pub axis_y: [f32; 2],
56    /// Atlas rectangle `[u0, v0, u1, v1]`.
57    pub uv: [f32; 4],
58    /// Premultiplied RGBA8.
59    pub color: u32,
60    /// Text height (local units); glyphs smaller than a pixel are skipped.
61    pub height: f32,
62}
63
64/// Tessellated geometry of one item or overlay.
65#[derive(Debug, Clone, Default)]
66pub struct Mesh {
67    /// World origin of the local coordinates.
68    pub origin: [f64; 2],
69    /// Line instances.
70    pub lines: Vec<LineInstance>,
71    /// Fill vertices.
72    pub fill_vertices: Vec<FillVertex>,
73    /// Fill triangle indices.
74    pub fill_indices: Vec<u32>,
75    /// Glyph instances.
76    pub glyphs: Vec<GlyphInstance>,
77}
78
79impl Mesh {
80    /// Empty mesh with an origin.
81    #[must_use]
82    pub fn at(origin: [f64; 2]) -> Self {
83        Self { origin, ..Self::default() }
84    }
85
86    /// Whether nothing would be drawn.
87    #[must_use]
88    pub fn is_empty(&self) -> bool {
89        self.lines.is_empty() && self.fill_indices.is_empty() && self.glyphs.is_empty()
90    }
91
92    fn local(&self, p: Point) -> [f32; 2] {
93        [(p.x - self.origin[0]) as f32, (p.y - self.origin[1]) as f32]
94    }
95
96    /// Append a stroked polyline (`closed` adds the closing segment).
97    pub fn polyline(&mut self, points: &[Point], closed: bool, color: u32, width: f32, dash: [f32; 4]) {
98        let pts: Vec<Point> = points.iter().copied().filter(|p| p.is_finite()).collect();
99        match pts.len() {
100            0 => {}
101            1 => {
102                let p = self.local(pts[0]);
103                self.lines.push(LineInstance {
104                    p0: p,
105                    p1: p,
106                    color,
107                    width: width.max(3.0),
108                    dist0: 0.0,
109                    dash: [0.0; 4],
110                });
111            }
112            n => {
113                let mut dist = 0.0f64;
114                let segs = if closed { n } else { n - 1 };
115                for i in 0..segs {
116                    let a = pts[i];
117                    let b = pts[(i + 1) % n];
118                    self.lines.push(LineInstance {
119                        p0: self.local(a),
120                        p1: self.local(b),
121                        color,
122                        width,
123                        dist0: dist as f32,
124                        dash,
125                    });
126                    dist += a.distance(b);
127                }
128            }
129        }
130    }
131
132    /// Append filled rings (even-odd rule; holes are rings inside rings).
133    pub fn fill_rings(&mut self, rings: &[Vec<Point>], color: u32) {
134        let mut b = Path::builder();
135        let mut any = false;
136        for ring in rings {
137            let pts: Vec<[f32; 2]> = ring.iter().filter(|p| p.is_finite()).map(|&p| self.local(p)).collect();
138            if pts.len() < 3 {
139                continue;
140            }
141            b.begin(lpoint(pts[0][0], pts[0][1]));
142            for p in &pts[1..] {
143                b.line_to(lpoint(p[0], p[1]));
144            }
145            b.end(true);
146            any = true;
147        }
148        if !any {
149            return;
150        }
151        let path = b.build();
152        let mut buffers: VertexBuffers<FillVertex, u32> = VertexBuffers::new();
153        let options = FillOptions::tolerance(0.01).with_fill_rule(FillRule::EvenOdd);
154        let ok = FillTessellator::new()
155            .tessellate_path(
156                &path,
157                &options,
158                &mut BuffersBuilder::new(&mut buffers, |v: LyonVertex| FillVertex {
159                    pos: v.position().to_array(),
160                    color,
161                }),
162            )
163            .is_ok();
164        if !ok {
165            return;
166        }
167        let base = self.fill_vertices.len() as u32;
168        self.fill_vertices.extend_from_slice(&buffers.vertices);
169        self.fill_indices.extend(buffers.indices.iter().map(|i| i + base));
170    }
171
172    /// Append a filled triangle.
173    pub fn triangle(&mut self, a: Point, b: Point, c: Point, color: u32) {
174        if !(a.is_finite() && b.is_finite() && c.is_finite()) {
175            return;
176        }
177        let base = self.fill_vertices.len() as u32;
178        for p in [a, b, c] {
179            self.fill_vertices.push(FillVertex { pos: self.local(p), color });
180        }
181        self.fill_indices.extend_from_slice(&[base, base + 1, base + 2]);
182    }
183
184    /// Append laid-out text. Returns `false` if the glyph atlas was reset during
185    /// layout (the caller re-tessellates everything with text).
186    pub fn text(&mut self, ts: &mut TextSystem, t: &Text, color: u32) -> bool {
187        if !t.position.is_finite() {
188            return true;
189        }
190        let Some(glyphs) = ts.layout(t) else { return false };
191        let (sin, cos) = if t.rotation.is_finite() { dotloom_geometry::math::sin_cos(t.rotation) } else { (0.0, 1.0) };
192        let rot = |x: f64, y: f64| Point::new(t.position.x + x * cos - y * sin, t.position.y + x * sin + y * cos);
193        for g in glyphs {
194            let o = rot(g.x0, g.y0);
195            let ox = rot(g.x1, g.y0);
196            let oy = rot(g.x0, g.y1);
197            let origin = self.local(o);
198            self.glyphs.push(GlyphInstance {
199                origin,
200                axis_x: [(ox.x - o.x) as f32, (ox.y - o.y) as f32],
201                axis_y: [(oy.x - o.x) as f32, (oy.y - o.y) as f32],
202                uv: g.uv,
203                color,
204                height: t.height as f32,
205            });
206        }
207        true
208    }
209}
210
211/// Visual state applied on top of the scene styles.
212#[derive(Debug, Clone, Copy)]
213pub(crate) struct Look {
214    pub flags: u8,
215}
216
217/// Normalize a dash list (CSS px) to the shader's 4 entries (SVG repeats odd lists).
218#[must_use]
219pub fn dash4(dash: &[f32]) -> [f32; 4] {
220    let vals: Vec<f32> = dash.iter().copied().filter(|v| v.is_finite() && *v >= 0.0).collect();
221    if vals.is_empty() || vals.iter().all(|v| *v == 0.0) {
222        return [0.0; 4];
223    }
224    let mut out = [0.0f32; 4];
225    let src: Vec<f32> = if vals.len() % 2 == 1 { vals.iter().chain(vals.iter()).copied().collect() } else { vals };
226    for (o, v) in out.iter_mut().zip(src.iter()) {
227        *o = *v;
228    }
229    if out[0] <= 0.0 {
230        // A leading zero "on" length would read as solid; use a dot.
231        out[0] = 0.01;
232    }
233    out
234}
235
236/// Flatten tolerance actually used for a shape: never finer than 1e-5 of its size
237/// (bounds the vertex count of huge curves at extreme zoom).
238pub(crate) fn effective_tolerance(lod_tol: f64, bbox: Aabb) -> f64 {
239    let size = if bbox.is_empty() { 0.0 } else { bbox.width().max(bbox.height()) };
240    let floor = if size.is_finite() { size * 1e-5 } else { 0.0 };
241    lod_tol.max(floor).max(FlattenTolerance::MIN)
242}
243
244/// Whether a shape's tessellation depends on the zoom level.
245pub(crate) fn shape_has_curves(s: &Shape) -> bool {
246    match s {
247        Shape::Circle(_) | Shape::Arc(_) | Shape::Path(_) => true,
248        Shape::Polyline(p) => p.bulges.iter().any(|b| *b != 0.0),
249        _ => false,
250    }
251}
252
253/// Whether an item's tessellation depends on the zoom level.
254pub(crate) fn item_has_curves(item: &SceneItem) -> bool {
255    item.prims.iter().any(|p| matches!(p, Primitive::Shape { shape, .. } if shape_has_curves(shape)))
256}
257
258/// Whether an item draws text.
259pub(crate) fn item_has_text(item: &SceneItem) -> bool {
260    item.prims.iter().any(|p| matches!(p, Primitive::Text { .. } | Primitive::Shape { shape: Shape::Text(_), .. }))
261}
262
263fn origin_of(item: &SceneItem) -> [f64; 2] {
264    let b = item.bbox;
265    if !b.is_empty() && b.min.is_finite() && b.max.is_finite() {
266        let c = b.center();
267        return [c.x, c.y];
268    }
269    for p in &item.prims {
270        let at = match p {
271            Primitive::Shape { shape, .. } => {
272                shape.flatten(FlattenTolerance(1.0)).first().and_then(|f| f.points.first().copied())
273            }
274            Primitive::Text { text, .. } => Some(text.position),
275            Primitive::Arrow { tip, .. } => Some(*tip),
276        };
277        if let Some(at) = at.filter(|p| p.is_finite()) {
278            return [at.x, at.y];
279        }
280    }
281    [0.0, 0.0]
282}
283
284/// Tessellate a scene item. Returns `None` if the glyph atlas was reset (retry).
285pub(crate) fn tessellate_item(
286    item: &SceneItem,
287    look: Look,
288    lod_tol: f64,
289    theme: &Theme,
290    ts: &mut TextSystem,
291) -> Option<Mesh> {
292    let mut m = Mesh::at(origin_of(item));
293    let f = look.flags;
294    let mut alpha = 1.0f32;
295    if f & flags::PREVIEW != 0 {
296        alpha *= 0.8;
297    }
298    if f & flags::LOCKED != 0 {
299        alpha *= 0.6;
300    }
301    if f & flags::READONLY != 0 {
302        alpha *= 0.6;
303    }
304    let emphasis = |c: Rgba| -> Rgba {
305        if f & flags::SELECTED != 0 {
306            theme.selection
307        } else if f & flags::PROBLEM != 0 {
308            theme.problem
309        } else if f & flags::HOVER != 0 {
310            theme.hover
311        } else {
312            theme.resolve(c)
313        }
314    };
315    let extra_width = if f & (flags::SELECTED | flags::HOVER) != 0 { 1.0 } else { 0.0 };
316    let readonly_dash = f & flags::READONLY != 0;
317    let tol = effective_tolerance(lod_tol, item.bbox);
318    for prim in &item.prims {
319        match prim {
320            Primitive::Shape { shape, stroke, fill } => {
321                if let Shape::Text(t) = shape {
322                    let c = stroke.as_ref().map(|s| s.color).or(*fill).unwrap_or(0);
323                    if !m.text(ts, t, premul_bytes(with_alpha(emphasis(c), alpha))) {
324                        return None;
325                    }
326                    continue;
327                }
328                let flat = shape.flatten(FlattenTolerance(tol));
329                if let Some(fc) = fill
330                    && shape.is_region()
331                {
332                    let rings: Vec<Vec<Point>> = flat.iter().filter(|p| p.closed).map(|p| p.points.clone()).collect();
333                    let mut fc = theme.resolve(*fc);
334                    if f & flags::SELECTED != 0 {
335                        fc = mix_toward(fc, theme.selection, 0.25);
336                    }
337                    m.fill_rings(&rings, premul_bytes(with_alpha(fc, alpha)));
338                }
339                let stroke = match stroke {
340                    Some(s) => Some(s.clone()),
341                    // Selected fill-only regions get an outline so selection is visible.
342                    None if f & (flags::SELECTED | flags::HOVER | flags::PROBLEM) != 0 => {
343                        Some(Stroke { color: 0, width: 1.0, dash: Vec::new() })
344                    }
345                    None => None,
346                };
347                if let Some(s) = stroke {
348                    let color = premul_bytes(with_alpha(emphasis(s.color), alpha));
349                    let width = if s.width.is_finite() { s.width.max(0.0) } else { 1.0 } + extra_width;
350                    let dash = if readonly_dash && s.dash.is_empty() { dash4(&[6.0, 4.0]) } else { dash4(&s.dash) };
351                    if let Shape::Point(p) = shape {
352                        m.polyline(&[p.at], false, color, (width + 2.0).max(4.0), [0.0; 4]);
353                        continue;
354                    }
355                    for fp in &flat {
356                        m.polyline(&fp.points, fp.closed, color, width, dash);
357                    }
358                }
359            }
360            Primitive::Text { text, color } => {
361                if !m.text(ts, text, premul_bytes(with_alpha(emphasis(*color), alpha))) {
362                    return None;
363                }
364            }
365            Primitive::Arrow { tip, direction, size, color } => {
366                let Some(d) = direction.normalize() else { continue };
367                if !(size.is_finite() && *size > 0.0) {
368                    continue;
369                }
370                let base = *tip - d * *size;
371                let n = dotloom_geometry::Vector::new(-d.y, d.x) * (*size * 0.22);
372                m.triangle(*tip, base + n, base - n, premul_bytes(with_alpha(emphasis(*color), alpha)));
373            }
374        }
375    }
376    Some(m)
377}
378
379fn mix_toward(a: Rgba, b: Rgba, t: f32) -> Rgba {
380    let (x, y) = (crate::color::unpack(a), crate::color::unpack(b));
381    crate::color::pack([
382        x[0] + (y[0] - x[0]) * t,
383        x[1] + (y[1] - x[1]) * t,
384        x[2] + (y[2] - x[2]) * t,
385        x[3].max(y[3] * 0.25),
386    ])
387}
388
389#[cfg(test)]
390mod tests {
391    use dotloom_geometry::{Circle, Rect, Segment};
392
393    use super::*;
394
395    fn item(prims: Vec<Primitive>) -> SceneItem {
396        let mut bbox = Aabb::EMPTY;
397        for p in &prims {
398            if let Primitive::Shape { shape, .. } = p {
399                bbox = bbox.union(shape.bbox());
400            }
401        }
402        SceneItem { id: 1, layer: 0, bbox, flags: 0, prims }
403    }
404
405    fn stroke() -> Option<Stroke> {
406        Some(Stroke { color: 0, width: 1.0, dash: Vec::new() })
407    }
408
409    #[test]
410    fn line_becomes_one_instance_relative_to_origin() {
411        let it = item(vec![Primitive::Shape {
412            shape: Shape::Line(Segment::new(Point::new(1.0e7, 5.0), Point::new(1.0e7 + 10.0, 5.0))),
413            stroke: stroke(),
414            fill: None,
415        }]);
416        let mut ts = TextSystem::with_default_font().unwrap();
417        let m = tessellate_item(&it, Look { flags: 0 }, 0.01, &Theme::light(), &mut ts).unwrap();
418        assert_eq!(m.lines.len(), 1);
419        assert_eq!(m.lines[0].p0, [-5.0, 0.0]);
420        assert_eq!(m.lines[0].p1, [5.0, 0.0]);
421    }
422
423    #[test]
424    fn circle_density_follows_lod() {
425        let it = item(vec![Primitive::Shape {
426            shape: Shape::Circle(Circle::new(Point::new(0.0, 0.0), 100.0).unwrap()),
427            stroke: stroke(),
428            fill: Some(0xff00_00ff),
429        }]);
430        let mut ts = TextSystem::with_default_font().unwrap();
431        let coarse = tessellate_item(&it, Look { flags: 0 }, 1.0, &Theme::light(), &mut ts).unwrap();
432        let fine = tessellate_item(&it, Look { flags: 0 }, 0.01, &Theme::light(), &mut ts).unwrap();
433        assert!(fine.lines.len() > coarse.lines.len() * 5);
434        assert!(!fine.fill_indices.is_empty());
435        // Huge circles are capped by the relative floor.
436        let huge = item(vec![Primitive::Shape {
437            shape: Shape::Circle(Circle::new(Point::new(0.0, 0.0), 1.0e6).unwrap()),
438            stroke: stroke(),
439            fill: None,
440        }]);
441        let h = tessellate_item(&huge, Look { flags: 0 }, 1e-6, &Theme::light(), &mut ts).unwrap();
442        assert!(h.lines.len() < 2000, "{}", h.lines.len());
443    }
444
445    #[test]
446    fn polygon_with_hole_and_selection_outline() {
447        let outer = vec![Point::new(0.0, 0.0), Point::new(10.0, 0.0), Point::new(10.0, 10.0), Point::new(0.0, 10.0)];
448        let hole = vec![Point::new(3.0, 3.0), Point::new(7.0, 3.0), Point::new(7.0, 7.0), Point::new(3.0, 7.0)];
449        let mut it = item(vec![Primitive::Shape {
450            shape: Shape::Polygon(dotloom_geometry::Polygon { outer, holes: vec![hole] }),
451            stroke: None,
452            fill: Some(0x00ff_00ff),
453        }]);
454        let mut ts = TextSystem::with_default_font().unwrap();
455        let m = tessellate_item(&it, Look { flags: 0 }, 0.1, &Theme::light(), &mut ts).unwrap();
456        // A square ring: 8 triangles.
457        assert_eq!(m.fill_indices.len(), 8 * 3);
458        assert!(m.lines.is_empty());
459        it.flags = flags::SELECTED;
460        let s = tessellate_item(&it, Look { flags: it.flags }, 0.1, &Theme::light(), &mut ts).unwrap();
461        assert_eq!(s.lines.len(), 8);
462        assert_eq!(s.lines[0].color, premul_bytes(Theme::light().selection));
463    }
464
465    #[test]
466    fn text_arrow_point_and_bad_input() {
467        let t = Text {
468            position: Point::new(0.0, 0.0),
469            content: "Ölçü 1200".into(),
470            height: 5.0,
471            rotation: core::f64::consts::FRAC_PI_2,
472            halign: Default::default(),
473            valign: Default::default(),
474        };
475        let it = item(vec![
476            Primitive::Text { text: t, color: 0 },
477            Primitive::Arrow {
478                tip: Point::new(0.0, 0.0),
479                direction: dotloom_geometry::Vector::new(1.0, 0.0),
480                size: 3.0,
481                color: 0,
482            },
483            Primitive::Shape {
484                shape: Shape::Point(dotloom_geometry::PointShape { at: Point::new(1.0, 1.0) }),
485                stroke: stroke(),
486                fill: None,
487            },
488            Primitive::Shape {
489                shape: Shape::Rect(Rect { origin: Point::new(f64::NAN, 0.0), width: 1.0, height: 1.0 }),
490                stroke: stroke(),
491                fill: Some(1),
492            },
493        ]);
494        let mut ts = TextSystem::with_default_font().unwrap();
495        let m = tessellate_item(&it, Look { flags: 0 }, 0.1, &Theme::light(), &mut ts).unwrap();
496        assert_eq!(m.glyphs.len(), 8); // spaces have no quad
497        // Rotated 90°: glyph x axis points up.
498        assert!(m.glyphs[0].axis_x[0].abs() < 1e-5 && m.glyphs[0].axis_x[1] > 0.0);
499        assert_eq!(m.fill_indices.len(), 3);
500        assert_eq!(m.lines.len(), 1);
501        assert!(m.lines[0].width >= 4.0);
502    }
503
504    #[test]
505    fn dash_normalization() {
506        assert_eq!(dash4(&[]), [0.0; 4]);
507        assert_eq!(dash4(&[4.0, 2.0]), [4.0, 2.0, 0.0, 0.0]);
508        assert_eq!(dash4(&[3.0]), [3.0, 3.0, 0.0, 0.0]);
509        assert_eq!(dash4(&[5.0, 1.0, 1.0]), [5.0, 1.0, 1.0, 5.0]);
510        assert_eq!(dash4(&[0.0, 0.0]), [0.0; 4]);
511    }
512}