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}