Skip to main content

blockworx_paint/
renderer.rs

1use blockworx_doc::block_model::Asset;
2use blockworx_geom::{Align2, Angle, Pos2, Rect, Vec2, WorldPx};
3
4use crate::{
5    Font,
6    canvas::Animator,
7    palette::{PaletteStroke, Swatch},
8    text::Layout,
9};
10
11/// A drawing backend that accepts world-space geometry. Implemented by the
12/// on-screen painter, by the SVG exporter, and by the measuring
13/// [`Extent`](crate::Extent). The render path is generic over this trait so the
14/// exact same drawing code drives every backend — the geometry has a single
15/// source of truth.
16///
17/// Every magnitude crossing this boundary — radii, corner rounding, stroke
18/// widths, wrap widths — is a world-space [`WorldPx`]. Each backend converts to
19/// its own space exactly once: the on-screen painter multiplies by the zoom, the
20/// SVG exporter writes world units straight out.
21///
22/// The API speaks only in **palette colors** ([`Swatch`] / [`PaletteStroke`]) —
23/// never an arbitrary [`Color`](crate::Color), and never an app-level "role".
24/// Each backend holds a [`Palette`](crate::Palette) and resolves a `Swatch` to a concrete color
25/// at draw time. App semantics (theme/roles) live one layer up and resolve to
26/// swatches before calling in, which keeps the canvas reusable and
27/// self-contained.
28///
29/// All drawing methods take `&self`; a collecting backend uses interior
30/// mutability. Fading a run of draws is not backend state — it is a swatch
31/// transform, applied one layer up by
32/// [`Style::with_opacity`](crate::theme::Style::with_opacity).
33pub trait Renderer {
34    fn rect(
35        &self,
36        rect: Rect,
37        rounding: WorldPx,
38        fill: impl Into<Swatch>,
39        stroke: impl Into<PaletteStroke>,
40    );
41
42    fn line_segment(&self, points: [Pos2; 2], stroke: impl Into<PaletteStroke>);
43
44    fn line(&self, points: Vec<Pos2>, stroke: impl Into<PaletteStroke>);
45
46    fn circle(
47        &self,
48        center: Pos2,
49        radius: WorldPx,
50        fill: impl Into<Swatch>,
51        stroke: impl Into<PaletteStroke>,
52    );
53
54    fn add_convex_polygon(
55        &self,
56        points: Vec<Pos2>,
57        fill: impl Into<Swatch>,
58        stroke: impl Into<PaletteStroke>,
59    );
60
61    // Mirrors a toolkit painter's `text(pos, anchor, text, font, color)`, extended
62    // with the wrap width / rotation this canvas needs. Grouping these into a
63    // struct would make every call site diverge from the API it wraps.
64    #[expect(clippy::too_many_arguments)]
65    fn text(
66        &self,
67        pos: Pos2,
68        anchor: Align2,
69        text: impl ToString,
70        font: &Font,
71        color: impl Into<Swatch>,
72    ) -> Rect;
73
74    // Mirrors a toolkit painter's `text(pos, anchor, text, font, color)`, extended
75    // with the wrap width / rotation this canvas needs. Grouping these into a
76    // struct would make every call site diverge from the API it wraps.
77    #[expect(clippy::too_many_arguments)]
78    fn rotated_text(
79        &self,
80        pos: Pos2,
81        anchor: Align2,
82        text: impl ToString,
83        font: &Font,
84        color: impl Into<Swatch>,
85        angle: Angle,
86    );
87
88    /// Like [`Renderer::text`] but wraps to `max_width` (word wrap);
89    /// [`WorldPx::UNBOUNDED`] draws unwrapped. The default ignores `max_width`
90    /// and draws unwrapped; backends that lay text out override it. Both the
91    /// on-screen and the SVG backend break the text with the same layout
92    /// engine, so they agree on the break points.
93    // Mirrors a toolkit painter's `text(pos, anchor, text, font, color)`, extended
94    // with the wrap width / rotation this canvas needs. Grouping these into a
95    // struct would make every call site diverge from the API it wraps.
96    #[expect(clippy::too_many_arguments)]
97    fn text_wrapped(
98        &self,
99        pos: Pos2,
100        anchor: Align2,
101        text: impl ToString,
102        font: &Font,
103        color: impl Into<Swatch>,
104        _max_width: WorldPx,
105    ) -> Rect {
106        self.text(pos, anchor, text, font, color)
107    }
108
109    fn text_size(&self, text: impl ToString, font: &Font) -> Vec2;
110
111    /// Like [`Renderer::text_size`] but measured with word wrap at `max_width`.
112    /// The default ignores `max_width`.
113    fn text_size_wrapped(&self, text: impl ToString, font: &Font, _max_width: WorldPx) -> Vec2 {
114        self.text_size(text, font)
115    }
116
117    /// `text` laid out in `font` and word-wrapped at `max_width`, in world
118    /// units: the rows [`Self::text_size_wrapped`] measures.
119    fn text_layout(&self, text: &str, font: &Font, max_width: WorldPx) -> Layout;
120
121    /// Draw `image` (an SVG or PNG payload) filling the world-space `rect`. The
122    /// caller sizes `rect` to honor the image's aspect ratio (see
123    /// [`Renderer::image_intrinsic_size`]), so each backend simply fills `rect`:
124    /// the on-screen painter caches the bytes in its own `hash → handle` table
125    /// and polls the toolkit's loader, while the SVG exporter embeds the bytes
126    /// as a base64 data URI.
127    fn draw_image(&self, rect: Rect, image: &Asset);
128
129    /// The intrinsic point size of `image` (always strictly positive), used by
130    /// the caller for aspect-fit and the resize-ring geometry. `None` if the
131    /// bytes are not a valid SVG/PNG.
132    fn image_intrinsic_size(&self, image: &Asset) -> Option<Vec2>;
133
134    /// The frame animation this backend drives, for interactive overlays drawn
135    /// on the generic render path. The on-screen canvas supplies itself;
136    /// offline backends (the SVG exporter) return `None`, so those overlays
137    /// fall back to a static draw.
138    fn animator(&self) -> Option<&dyn Animator> {
139        None
140    }
141
142    /// The world-space rect currently visible on screen, if this backend paints
143    /// into a bounded viewport. The render path uses it to viewport-cull the
144    /// scene: shapes outside it are skipped. `None` (the default) means "no
145    /// viewport — draw everything", which is what offline backends (the SVG
146    /// exporter) want, so an export is never culled.
147    fn visible_world_bounds(&self) -> Option<Rect> {
148        None
149    }
150}