all_is_cubes/block/
text.rs

1//! Support for [`Primitive::Text`].
2
3#![expect(
4    clippy::module_name_repetitions,
5    reason = "module is private; https://github.com/rust-lang/rust-clippy/issues/8524"
6)]
7
8use alloc::boxed::Box;
9use core::iter;
10use eg::mono_font::MonoFont;
11use embedded_graphics::mono_font::mapping::GlyphMapping;
12
13use arcstr::ArcStr;
14use embedded_graphics as eg;
15use embedded_graphics::{Drawable as _, prelude::Dimensions as _};
16use euclid::vec3;
17
18use crate::block::{self, Block, BlockAttributes, Evoxel, MinEval, Resolution};
19use crate::content::palette;
20use crate::drawing::{DrawingPlane, rectangle_to_aab};
21use crate::math::{
22    FaceMap, GridAab, GridCoordinate, GridVector, Gridgid, Rgb, Rgba, Vol, rgba_const,
23};
24use crate::space::{self, SpaceTransaction};
25use crate::universe;
26
27#[cfg(doc)]
28use crate::block::{Modifier, Primitive};
29
30use super::Evoxels;
31
32/// A piece of text rendered as voxels.
33///
34/// Each `Text` contains:
35///
36/// * A string, as [`ArcStr`].
37/// * A [`Font`].
38/// * [`Block`]s defining voxel colors and attributes used.
39/// * A [`Resolution`] defining the font size.
40/// * A bounding box within which the text is positioned (but may overflow).
41/// * A [`Positioning`] specifying how the text is positioned within the box.
42///
43/// To create a block or multiblock group from this, use [`Primitive::Text`].
44/// To combine the text with other shapes, use [`Modifier::Composite`].
45///
46//--
47// TODO: Each `Text` instance should memoize glyph layout so that layout work can be shared among
48// blocks. We don't really have much to do there yet, though.
49#[derive(Clone, Debug, Eq, Hash, PartialEq)]
50pub struct Text {
51    string: ArcStr,
52
53    font: Font,
54
55    foreground: Block,
56
57    outline: Option<Block>,
58
59    resolution: Resolution,
60
61    /// Voxel-scale bounds in which the text is positioned (not necessarily actual drawing bounds).
62    layout_bounds: GridAab,
63
64    positioning: Positioning,
65
66    debug: bool,
67}
68
69/// Builder for [`Text`] values.
70#[derive(Debug, Clone)]
71#[must_use]
72pub struct TextBuilder {
73    string: ArcStr,
74
75    font: Font,
76
77    foreground: Block,
78
79    outline: Option<Block>,
80
81    resolution: Resolution,
82
83    layout_bounds: Option<GridAab>,
84
85    positioning: Positioning,
86
87    debug: bool,
88}
89
90impl Text {
91    /// Returns a [`TextBuilder`] which may be used to construct a [`Text`] value with explicit
92    /// or default options.
93    pub fn builder() -> TextBuilder {
94        TextBuilder::default()
95    }
96
97    /// Converts this into a [`TextBuilder`] so that it may be modified.
98    pub fn into_builder(self) -> TextBuilder {
99        let Self {
100            string,
101            font,
102            foreground,
103            outline,
104            resolution,
105            layout_bounds,
106            positioning,
107            debug,
108        } = self;
109        TextBuilder {
110            string,
111            font,
112            foreground,
113            outline,
114            resolution,
115            layout_bounds: Some(layout_bounds),
116            positioning,
117            debug,
118        }
119    }
120
121    /// Returns the string which this displays.
122    pub fn string(&self) -> &ArcStr {
123        &self.string
124    }
125    /// Returns the font which this uses to display the text.
126    pub fn font(&self) -> &Font {
127        &self.font
128    }
129
130    /// Returns the voxel resolution which the text blocks will have.
131    pub fn resolution(&self) -> &Font {
132        &self.font
133    }
134
135    /// Returns the bounding box, within the blocks at the specified resolution, of the text.
136    ///
137    /// This does not reflect the actual size of the text but the configuration with which this
138    /// [`Text`] value was constructed.
139    /// The text may overflow this bounding box depending on its length and the
140    /// [`positioning()`](Self::positioning).
141    pub fn layout_bounds(&self) -> GridAab {
142        self.layout_bounds
143    }
144
145    /// Returns the [`Positioning`] parameters this uses.
146    pub fn positioning(&self) -> Positioning {
147        self.positioning
148    }
149
150    /// Returns the debug-rendering flag.
151    pub fn debug(&self) -> bool {
152        self.debug
153    }
154
155    /// Returns the bounding box of the text as displayed, in voxels at the
156    /// [`resolution()`](Self::resolution).
157    ///
158    /// This box is in the same units as [`Self::layout_bounds()`] but reflects the actual text
159    /// layout rather than the configuration.
160    pub fn bounding_voxels(&self) -> GridAab {
161        // TODO: Memoize this “layout calculation”.
162
163        let dummy_voxel = Evoxel::from_color(Rgba::BLACK); // could be anything
164        self.with_transform_and_drawable(
165            match self.outline {
166                Some(_) => Brush::Outline {
167                    foreground: dummy_voxel,
168                    outline: dummy_voxel,
169                },
170                None => Brush::Plain(dummy_voxel),
171            },
172            GridVector::zero(),
173            |_text_obj, text_aab, _drawing_transform| text_aab,
174        )
175    }
176    /// Returns the bounding box of the text, in blocks — the set of [`Primitive::Text`] offsets
177    /// that will render all of it.
178    ///
179    /// This is identical to [`Self::bounding_voxels()`] scaled down by [`Self::resolution()`].
180    pub fn bounding_blocks(&self) -> GridAab {
181        self.bounding_voxels().divide(self.resolution.into())
182    }
183
184    /// Returns a transaction which places [`Primitive::Text`] blocks containing this text.
185    ///
186    /// The text lies within the volume [`Self::bounding_blocks()`] transformed by `transform`.
187    ///
188    /// Each individual block is given to `block_fn` to allow alterations.
189    ///
190    /// The transaction has no preconditions.
191    ///
192    /// Panics if `transform` causes coordinate overflow.
193    pub fn installation(
194        &self,
195        transform: Gridgid,
196        block_fn: impl Fn(Block) -> Block,
197    ) -> SpaceTransaction {
198        let dst_to_src_transform = transform.inverse();
199        let block_rotation = transform.rotation;
200        SpaceTransaction::filling(
201            self.bounding_blocks().transform(transform).unwrap(),
202            |cube| {
203                space::CubeTransaction::replacing(
204                    None,
205                    Some(block_fn(
206                        Block::from_primitive(block::Primitive::Text {
207                            text: self.clone(),
208                            offset: dst_to_src_transform
209                                .transform_cube(cube)
210                                .lower_bounds()
211                                .to_vector(),
212                        })
213                        .rotate(block_rotation),
214                    )),
215                )
216            },
217        )
218    }
219
220    /// Returns a `Block` whose primitive is this text with no offset,
221    /// for quickly creating blocks from text that fits in one block.
222    pub fn single_block(self) -> Block {
223        Block::from_primitive(block::Primitive::Text {
224            text: self,
225            offset: GridVector::zero(),
226        })
227    }
228
229    /// Called by [`Primitive::Text`] evaluation to actually produce the voxels for a specific
230    /// [`Block`] of text.
231    pub(in crate::block) fn evaluate(
232        &self,
233        block_offset: GridVector,
234        filter: &super::EvalFilter<'_>,
235    ) -> Result<MinEval, block::InEvalError> {
236        if filter.skip_eval {
237            // TODO: Once we have a `Handle<FontDef>` or something, this will need to
238            // check that before returning.
239            return Ok(block::AIR_EVALUATED_MIN); // placeholder value
240        }
241
242        // Evaluate blocks making up the brush
243        let brush = {
244            let _recursion_scope = block::Budget::recurse(&filter.budget)?;
245
246            // TODO: We could save a small amount of work by not building the full
247            // `EvaluatedBlock` here and throwing it away.
248            let ev_foreground = self.foreground.evaluate_to_evoxel_internal(filter)?;
249            match self.outline {
250                Some(ref block) => Brush::Outline {
251                    foreground: ev_foreground,
252                    outline: block.evaluate_to_evoxel_internal(filter)?,
253                },
254                None => Brush::Plain(ev_foreground),
255            }
256        };
257
258        self.with_transform_and_drawable(
259            brush,
260            block_offset,
261            |text_obj, text_aab, drawing_transform| {
262                let voxels: Evoxels =
263                    match text_aab.intersection_cubes(GridAab::for_block(self.resolution)) {
264                        Some(bounds_in_this_block) => {
265                            let fill = if self.debug {
266                                DEBUG_TEXT_BOUNDS_VOXEL
267                            } else {
268                                Evoxel::AIR
269                            };
270                            let mut voxels: Vol<Box<[Evoxel]>> =
271                                Vol::from_fn(bounds_in_this_block, |_| fill);
272
273                            text_obj
274                                .draw(&mut DrawingPlane::new(&mut voxels, drawing_transform))
275                                .unwrap();
276
277                            Evoxels::from_many(self.resolution, voxels.map_container(Into::into))
278                        }
279
280                        None => Evoxels::from_one(if self.debug {
281                            DEBUG_NO_INTERSECTION_VOXEL
282                        } else {
283                            Evoxel::AIR
284                        }),
285                    };
286
287                Ok(MinEval::new(
288                    BlockAttributes {
289                        display_name: self.string.clone(),
290                        ..BlockAttributes::default()
291                    },
292                    voxels,
293                ))
294            },
295        )
296    }
297
298    fn thickness(&self) -> GridCoordinate {
299        match self.outline {
300            Some(_) => 2,
301            None => 1,
302        }
303    }
304
305    fn with_transform_and_drawable<R>(
306        &self,
307        brush: Brush,
308        block_offset: GridVector,
309        f: impl FnOnce(
310            &'_ eg::text::Text<'_, eg::mono_font::MonoTextStyle<'_, Brush>>,
311            GridAab,
312            Gridgid,
313        ) -> R,
314    ) -> R {
315        let resolution_g = GridCoordinate::from(self.resolution);
316        let Positioning {
317            x: positioning_x,
318            line_y,
319            z: positioning_z,
320        } = self.positioning;
321
322        let lb = self.layout_bounds;
323        // TODO: The subtractions of 1 here are dubious.
324        // I believe they are correct on the principle that embedded-graphics uses
325        // "a point labels a pixel" coordinates, so even leftward-extending text
326        // *includes* the identified pixel, but I'm not certain about that.
327        let layout_offset = vec3(
328            match positioning_x {
329                PositioningX::Left => lb.lower_bounds().x,
330                // 0.75 is a fudge factor that empirically gets the *rounding* behavior we want.
331                // though I'm still suspicious that we need to account for the text width too
332                PositioningX::Center => libm::round(lb.center().x - 0.75) as GridCoordinate,
333                PositioningX::Right => lb.upper_bounds().x - 1,
334            },
335            match line_y {
336                PositioningY::BodyBottom | PositioningY::Baseline => lb.lower_bounds().y,
337                PositioningY::BodyMiddle => libm::round(lb.center().y - 0.75) as GridCoordinate,
338                PositioningY::BodyTop => lb.upper_bounds().y - 1,
339            },
340            match positioning_z {
341                PositioningZ::Back => lb.lower_bounds().z,
342                PositioningZ::Front => lb.upper_bounds().z.saturating_sub(self.thickness()),
343            },
344        );
345
346        let drawing_transform =
347            Gridgid::from_translation(layout_offset - (block_offset * resolution_g))
348                * Gridgid::FLIP_Y;
349
350        let character_style = eg::mono_font::MonoTextStyle::new(self.font.eg_font(), brush);
351        let text_style = eg::text::TextStyleBuilder::new()
352            .alignment(match positioning_x {
353                PositioningX::Left => eg::text::Alignment::Left,
354                PositioningX::Center => eg::text::Alignment::Center,
355                PositioningX::Right => eg::text::Alignment::Right,
356            })
357            .baseline(match line_y {
358                PositioningY::BodyTop => eg::text::Baseline::Top,
359                PositioningY::BodyMiddle => eg::text::Baseline::Middle,
360                PositioningY::Baseline => eg::text::Baseline::Alphabetic,
361                PositioningY::BodyBottom => eg::text::Baseline::Bottom,
362            })
363            .build();
364        let text_obj = &eg::text::Text::with_text_style(
365            self.string.as_str(),
366            eg::prelude::Point::new(0, 0),
367            character_style,
368            text_style,
369        );
370        let text_aab = brush.expand(rectangle_to_aab(
371            text_obj.bounding_box(),
372            drawing_transform,
373            GridAab::ORIGIN_CUBE,
374        ));
375
376        f(text_obj, text_aab, drawing_transform)
377    }
378}
379
380impl universe::VisitHandles for Text {
381    fn visit_handles(&self, visitor: &mut dyn universe::HandleVisitor) {
382        let Self {
383            string: _,
384            font,
385            foreground,
386            outline,
387            resolution: _,
388            layout_bounds: _,
389            positioning: _,
390            debug: _,
391        } = self;
392        font.visit_handles(visitor);
393        foreground.visit_handles(visitor);
394        outline.visit_handles(visitor);
395    }
396}
397
398#[cfg(feature = "arbitrary")]
399impl<'a> arbitrary::Arbitrary<'a> for Text {
400    fn arbitrary(u: &mut arbitrary::Unstructured<'a>) -> arbitrary::Result<Self> {
401        // As a temporary measure, restrict GridAab coordinate range to avoid overflows.
402        // Eventually we should fix the overflows, but let's do that after we build our own
403        // text rendering because that'll be easier. Once that’s done, replace this with a
404        // derived implementation.
405        //
406        // (Another possibility would be to actually restrict `layout_bounds` itself.)
407        let layout_bounds = GridAab::checked_from_lower_upper(
408            <[i16; 3]>::arbitrary(u)?.map(i32::from),
409            <[u16; 3]>::arbitrary(u)?.map(i32::from),
410        )
411        .map_err(|_volume_error| arbitrary::Error::IncorrectFormat)?;
412
413        Ok(Self {
414            // ArcStr doesn't implement Arbitrary
415            string: alloc::string::String::arbitrary(u)?.into(),
416            font: Font::arbitrary(u)?,
417            foreground: Block::arbitrary(u)?,
418            outline: Option::<Block>::arbitrary(u)?,
419            resolution: Resolution::arbitrary(u)?,
420            layout_bounds,
421            positioning: Positioning::arbitrary(u)?,
422            debug: bool::arbitrary(u)?,
423        })
424    }
425
426    fn size_hint(depth: usize) -> (usize, Option<usize>) {
427        // recommended impl from trait documentation
428        Self::try_size_hint(depth).unwrap_or_default()
429    }
430
431    fn try_size_hint(
432        depth: usize,
433    ) -> Result<(usize, Option<usize>), arbitrary::MaxRecursionReached> {
434        // Note that `Text` is recursive because `Text` contains `Block` and `Block` contains
435        // `Text`. However, this will still produce a useful, cheap result because
436        // `Block::size_hint()` has an explicitly set bound.
437
438        arbitrary::size_hint::try_recursion_guard(depth, |depth| {
439            Ok(arbitrary::size_hint::and_all(&[
440                alloc::string::String::size_hint(depth),
441                Font::size_hint(depth),
442                Block::size_hint(depth),
443                Option::<Block>::size_hint(depth),
444                Resolution::size_hint(depth),
445                GridAab::size_hint(depth),
446                bool::size_hint(depth),
447            ]))
448        })
449    }
450}
451
452impl TextBuilder {
453    /// Converts this builder into a [`Text`] value.
454    pub fn build(self) -> Text {
455        let Self {
456            string,
457            font,
458            foreground,
459            outline,
460            resolution,
461            layout_bounds,
462            positioning,
463            debug,
464        } = self;
465        Text {
466            string,
467            font,
468            foreground,
469            outline,
470            resolution,
471            layout_bounds: layout_bounds.unwrap_or(GridAab::for_block(resolution)),
472            positioning,
473            debug,
474        }
475    }
476
477    /// Sets the string to be displayed.
478    ///
479    /// The string may contain newlines, but is not automatically wrapped beyond that.
480    ///
481    /// The default is the empty string.
482    pub fn string(mut self, string: ArcStr) -> Self {
483        self.string = string;
484        self
485    }
486
487    /// Sets the font to use.
488    ///
489    /// The default is [`Font::System16`].
490    pub fn font(mut self, font: Font) -> Self {
491        self.font = font;
492        self
493    }
494
495    /// Sets the “foreground color”, or rather voxel, for the text.
496    ///
497    /// This block is interpreted as a voxel in the same way as a block in a [`Primitive::Recur`]
498    /// space would be. However, the result of evaluating this block not cached. Therefore, it is
499    /// highly recommended that the block be stored in a [`block::BlockDef`] (which does cache)
500    /// if it is not trivial, particularly if this text is going to span multiple blocks.
501    ///
502    /// The default value is `Block::from(all_is_cubes_content::palette::ALMOST_BLACK)`.
503    pub fn foreground(mut self, foreground: Block) -> Self {
504        self.foreground = foreground;
505        self
506    }
507
508    /// Sets the outline color for the text.
509    ///
510    /// This appears 1 voxel behind and to the side of the main color; making the overall text
511    /// 2 voxels thick.
512    ///
513    /// See [`Self::foreground()`] for information about how the block is used.
514    ///
515    /// The default value is [`None`].
516    pub fn outline(mut self, outline: Option<Block>) -> Self {
517        self.outline = outline;
518        self
519    }
520
521    /// Sets the voxel resolution to use.
522    ///
523    /// This affects the size of the text, including its thickness, and the units of
524    /// [`layout_bounds`](Self::layout_bounds).
525    ///
526    /// The default is [16](Resolution::R16).
527    pub fn resolution(mut self, resolution: Resolution) -> Self {
528        self.resolution = resolution;
529        self
530    }
531
532    /// Sets the position of the text within (or extending out of) the
533    /// [`layout_bounds`](Self::layout_bounds).
534    ///
535    /// The default is:
536    ///
537    /// ```rust
538    /// # use all_is_cubes::block::text::*;
539    /// # let p =
540    /// Positioning {
541    ///     x: PositioningX::Center,
542    ///     line_y: PositioningY::BodyMiddle,
543    ///     z: PositioningZ::Back,
544    /// }
545    /// # ;
546    /// # assert_eq!(p, TextBuilder::default().build().positioning());
547    /// ```
548    pub fn positioning(mut self, positioning: Positioning) -> Self {
549        self.positioning = positioning;
550        self
551    }
552
553    /// Sets the voxel bounding box within which the text is positioned,
554    /// as well as the resolution since that determines the coordinate system scale.
555    ///
556    /// The text might overflow this box; it is currently used only to choose the positioning of the
557    /// text and cannot constrain it.
558    pub fn layout_bounds(mut self, resolution: Resolution, bounds: GridAab) -> Self {
559        self.layout_bounds = Some(bounds);
560        self.resolution = resolution;
561        self
562    }
563    /// Sets the debug-rendering flag.
564    ///
565    /// If true, then the text rendering is modified in an unspecified way which is intended to
566    /// assist in diagnosing issues with text layout configuration. In particular, this currently
567    /// includes:
568    ///
569    /// * The region which is in bounds but not filled by a character is filled with a
570    ///   semi-transparent marker color instead of being transparent.
571    /// * If a [`Primitive::Text`] block's bounds fail to intersect its [`Text`], then the block
572    ///   is filled with a marker color.
573    pub fn debug(mut self, debug: bool) -> Self {
574        self.debug = debug;
575        self
576    }
577}
578
579impl Default for TextBuilder {
580    fn default() -> Self {
581        Self {
582            string: ArcStr::new(),
583            font: Font::System16,
584            foreground: block::from_color!(palette::ALMOST_BLACK),
585            outline: None,
586            resolution: Resolution::R16,
587            layout_bounds: None,
588            positioning: Positioning {
589                x: PositioningX::Center,
590                line_y: PositioningY::BodyMiddle,
591                z: PositioningZ::Back,
592            },
593            debug: false,
594        }
595    }
596}
597
598/// A font that may be used with [`Text`] blocks.
599#[derive(Clone, Debug, Eq, Hash, PartialEq)]
600#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
601#[non_exhaustive]
602pub enum Font {
603    /// A font whose characteristics are unspecified, other than that it is general-purpose and
604    /// has a line height (vertical distance from a point on one line to the corresponding
605    /// point of the next line) of 16 voxels.
606    ///
607    /// This is a placeholder for further improvement in the font system.
608    System16,
609
610    #[doc(hidden)]
611    // experimental while we figure things out. Probably to be replaced with fonts stored in Universe
612    Logo,
613
614    #[doc(hidden)]
615    // experimental while we figure things out. Probably to be replaced with fonts stored in Universe
616    SmallerBodyText,
617}
618impl Font {
619    /// Do not use. This will be removed if and when we change font renderers.
620    #[doc(hidden)]
621    pub fn eg_font(&self) -> &MonoFont<'static> {
622        use eg::mono_font::iso_8859_1 as f;
623        match self {
624            Self::System16 => &MonoFont {
625                glyph_mapping: &RemapTo8859_1(f::FONT_8X13_BOLD.glyph_mapping),
626                ..f::FONT_8X13_BOLD
627            },
628            Self::Logo => &MonoFont {
629                glyph_mapping: &RemapTo8859_1(f::FONT_9X15_BOLD.glyph_mapping),
630                ..f::FONT_9X15_BOLD
631            },
632            Self::SmallerBodyText => &MonoFont {
633                glyph_mapping: &RemapTo8859_1(f::FONT_6X10.glyph_mapping),
634                ..f::FONT_6X10
635            },
636        }
637    }
638}
639
640impl universe::VisitHandles for Font {
641    fn visit_handles(&self, _: &mut dyn universe::HandleVisitor) {
642        match self {
643            Self::System16 | Self::SmallerBodyText | Self::Logo => {}
644        }
645    }
646}
647
648/// How a [`Text`] is to be positioned within a block.
649#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
650#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
651#[expect(
652    clippy::exhaustive_structs,
653    reason = "TODO: probably want to do something else"
654)]
655pub struct Positioning {
656    /// How to place the text horizontally relative to the anchor point.
657    pub x: PositioningX,
658
659    // TODO: implement this
660    // /// How to place the text's first or last line relative to the anchor point.
661    // pub total_y: (),
662    /// How to place the characters of the first line relative to the anchor point.
663    pub line_y: PositioningY,
664
665    /// How to place the text depthwise.
666    ///
667    /// For example, 0 means the voxels will be fill the `0..1` range (in front of the
668    /// anchor point), and `-1` means they will fill the `-1..0` range (behind the anchor point).
669    ///
670    /// This is in units of whatever voxel resolution the font itself uses. Therefore, it should
671    /// not be used for positioning the text overall,
672    /// but rather for voxel-level effects like engraving vs. embossing.
673    pub z: PositioningZ,
674}
675
676/// How a [`Text`] is to be positioned within the layout bounds, along the X axis (horizontally).
677///
678/// A component of [`Positioning`].
679#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
680#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
681#[non_exhaustive]
682pub enum PositioningX {
683    // TODO: Distinguish 'end of graphic' (last bit of ink) from 'nominal character spacing'?
684    /// Left (most negative X) end of the line of text is positioned at the left edge of the
685    /// layout bounds.
686    ///
687    /// In the event that RTL text support is added, this is not necessarily the start of the text.
688    Left,
689
690    /// Center the text within the layout bounds.
691    Center,
692
693    /// Right (most positive X) end of the line of text is positioned at the right edge of the
694    /// layout bounds.
695    ///
696    /// In the event that RTL text support is added, this is not necessarily the end of the text.
697    Right,
698}
699
700/// How a [`Text`] is to be positioned within the layout bounds, along the Y axis (vertically).
701///
702/// A component of [`Positioning`].
703#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
704#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
705#[non_exhaustive]
706pub enum PositioningY {
707    /// The top of a line of text (past which no voxels extend) is aligned with the top edge
708    /// of the layout bounds.
709    BodyTop,
710
711    /// The text is positioned halfway between `BodyTop` and `BodyBottom`, centered within the
712    /// layout bounds.
713    /// This may not necessarily visually center the font, but it will leave the most actually
714    /// blank margin.
715    BodyMiddle,
716
717    /// The bottom edge (of most characters, excluding descenders and accents) is positioned
718    /// at the bottom edge of the layout bounds.
719    Baseline,
720
721    /// The bottom of a line of text (past which no voxels extend) is aligned with the bottom edge
722    /// of the layout bounds.
723    BodyBottom,
724}
725
726/// How a [`Text`] is to be positioned within the layout bounds, along the Z axis (depth).
727///
728/// A component of [`Positioning`].
729#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
730#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
731#[non_exhaustive]
732pub enum PositioningZ {
733    /// Against the back (negative Z) face of the layout bounds.
734    Back,
735
736    /// Against the front (positive Z) face of the layout bounds.
737    Front,
738}
739
740#[cfg(feature = "save")]
741mod serialization {
742    use crate::block::text;
743    use crate::save::schema;
744
745    impl From<&text::Text> for schema::TextSer {
746        fn from(value: &text::Text) -> Self {
747            let &text::Text {
748                ref string,
749                ref font,
750                ref foreground,
751                ref outline,
752                resolution,
753                layout_bounds,
754                positioning,
755                debug,
756            } = value;
757            schema::TextSer::TextV1 {
758                string: string.clone(),
759                font: font.into(),
760                foreground: foreground.clone(),
761                outline: outline.clone(),
762                resolution,
763                layout_bounds,
764                positioning: positioning.into(),
765                debug,
766            }
767        }
768    }
769
770    impl From<schema::TextSer> for text::Text {
771        fn from(value: schema::TextSer) -> Self {
772            match value {
773                schema::TextSer::TextV1 {
774                    string,
775                    font,
776                    foreground,
777                    outline,
778                    resolution,
779                    layout_bounds,
780                    positioning,
781                    debug,
782                } => text::Text::builder()
783                    .string(string)
784                    .font(font.into())
785                    .foreground(foreground)
786                    .outline(outline)
787                    .layout_bounds(resolution, layout_bounds)
788                    .positioning(positioning.into())
789                    .debug(debug)
790                    .build(),
791            }
792        }
793    }
794}
795
796impl Positioning {
797    #[doc(hidden)] // not sure if good idea
798    pub const LOW: Self = Positioning {
799        x: PositioningX::Left,
800        line_y: PositioningY::BodyBottom,
801        z: PositioningZ::Back,
802    };
803}
804
805const DEBUG_NO_INTERSECTION_VOXEL: Evoxel = Evoxel {
806    color: rgba_const!(1.0, 0.0, 0.0, 0.5),
807    emission: Rgb::ZERO,
808    selectable: true,
809    collision: block::BlockCollision::None,
810};
811const DEBUG_TEXT_BOUNDS_VOXEL: Evoxel = Evoxel {
812    color: rgba_const!(0.0, 1.0, 0.0, 0.5),
813    emission: Rgb::ZERO,
814    selectable: true,
815    collision: block::BlockCollision::None,
816};
817
818/// Type which can be used on a `DrawingPlane` of `Evoxel`s to render text.
819#[derive(Clone, Copy, Debug, Eq, PartialEq)]
820pub(crate) enum Brush {
821    Plain(Evoxel),
822    Outline { foreground: Evoxel, outline: Evoxel },
823}
824
825impl Brush {
826    fn expand(&self, aab: GridAab) -> GridAab {
827        match self {
828            Brush::Plain(_) => aab,
829            Brush::Outline { .. } => aab.expand(FaceMap {
830                // TODO: This *should* expand bounds left, right, up, and down, but for now, that
831                // causes way too much trouble with positioning. As a workaround, pretend those
832                // expansions don't exist.
833                nx: 0, // should be 1
834                ny: 0, // should be 1
835                nz: 0,
836                px: 0, // should be 1
837                py: 0, // should be 1
838                pz: 1,
839            }),
840        }
841    }
842
843    pub(crate) fn iter(&self) -> impl Iterator<Item = (GridVector, Evoxel)> + '_ {
844        use itertools::Either::{Left, Right};
845        match *self {
846            Brush::Plain(foreground) => Left(iter::once((GridVector::zero(), foreground))),
847            Brush::Outline {
848                foreground,
849                outline,
850            } => Right(
851                [
852                    (vec3(0, 0, 1), foreground),
853                    (vec3(0, 0, 0), outline),
854                    (vec3(-1, 0, 0), outline),
855                    (vec3(1, 0, 0), outline),
856                    (vec3(0, -1, 0), outline),
857                    (vec3(0, 1, 0), outline),
858                ]
859                .into_iter(),
860            ),
861        }
862    }
863}
864
865// Kludge to pretend to have slightly greater character coverage than the fonts actually do:
866// remap selected Unicode characters to the `iso_8859_1` subset.
867struct RemapTo8859_1<'a>(&'a dyn GlyphMapping);
868
869impl GlyphMapping for RemapTo8859_1<'_> {
870    fn index(&self, c: char) -> usize {
871        self.0.index(match c {
872            '‘' | '’' => '\'',
873            '“' | '”' => '"',
874            c => c,
875        })
876    }
877}
878
879#[cfg(test)]
880mod tests {
881    use super::*;
882    use crate::block::Primitive;
883    use crate::math::Cube;
884    use crate::raytracer::print_space;
885    use crate::space::Space;
886    use crate::universe::Universe;
887    use alloc::string::String;
888    use alloc::vec::Vec;
889    use arcstr::literal;
890    use pretty_assertions::assert_eq;
891
892    /// Convert voxels with z range = 1 to a string for readable comparisons.
893    fn plane_to_text(voxels: Vol<&[Evoxel]>) -> Vec<String> {
894        fn convert_voxel(v: &Evoxel) -> char {
895            if v.color.fully_transparent() {
896                '.'
897            } else {
898                '#'
899            }
900        }
901
902        let z = voxels.bounds().lower_bounds().z;
903        assert_eq!(voxels.bounds().z_range().len(), 1);
904        voxels
905            .bounds()
906            .y_range()
907            .rev() // flip Y axis
908            .map(|y| {
909                voxels
910                    .bounds()
911                    .x_range()
912                    .map(|x| convert_voxel(&voxels[Cube::new(x, y, z)]))
913                    .collect::<String>()
914            })
915            .collect()
916    }
917
918    fn single_block_test_case(text: Text) -> (Box<Universe>, Block) {
919        // This universe is not really used now except to provide a `ReadTicket`,
920        // but I currently expect to add future restrictions on `Block` and `Space` usage
921        // that will make it necessary.
922        let universe = Universe::new();
923
924        //assert_eq!(text.bounding_blocks(), GridAab::ORIGIN_CUBE);
925
926        let block = Block::from_primitive(Primitive::Text {
927            text,
928            offset: GridVector::zero(),
929        });
930
931        // Print for debugging
932        {
933            let space = Space::builder(GridAab::ORIGIN_CUBE)
934                .read_ticket(universe.read_ticket())
935                .filled_with(block.clone())
936                .build();
937            print_space(&space.read(), [0., 0., 1.]);
938        }
939
940        (universe, block)
941    }
942
943    #[test]
944    fn single_line_text_smoke_test() {
945        let text = Text::builder()
946            .string(literal!("ab"))
947            .font(Font::System16)
948            .resolution(Resolution::R16)
949            .positioning(Positioning {
950                x: PositioningX::Left,
951                line_y: PositioningY::BodyBottom,
952                z: PositioningZ::Back,
953            })
954            .build();
955        let (universe, block) = single_block_test_case(text.clone());
956
957        let ev = block.evaluate(universe.read_ticket()).unwrap();
958        assert_eq!(
959            ev.attributes,
960            BlockAttributes {
961                display_name: arcstr::literal!("ab"),
962                ..BlockAttributes::default()
963            }
964        );
965        assert_eq!(
966            ev.voxels.bounds(),
967            GridAab::from_lower_size([0, 0, 0], [16, 13, 1])
968        );
969        assert_eq!(ev.voxels.bounds(), text.bounding_voxels());
970
971        assert_eq!(
972            plane_to_text(ev.voxels.as_vol_ref()),
973            vec![
974                "................",
975                "........##......",
976                "........##......",
977                "........##......",
978                ".#####..##.###..",
979                ".....##.###..##.",
980                ".######.##...##.",
981                "##...##.##...##.",
982                "##...##.##...##.",
983                "##..###.###..##.",
984                ".###.##.##.###..",
985                "................",
986                "................",
987            ]
988        )
989    }
990
991    #[test]
992    fn multiple_line() {
993        let (universe, block) = single_block_test_case(
994            Text::builder()
995                .resolution(Resolution::R32)
996                .string(literal!("abcd\nabcd"))
997                .font(Font::System16)
998                .positioning(Positioning {
999                    x: PositioningX::Left,
1000                    line_y: PositioningY::BodyTop, // TODO: test case for BodyBottom, which we may want to fix
1001                    z: PositioningZ::Back,
1002                })
1003                .build(),
1004        );
1005
1006        assert_eq!(
1007            plane_to_text(block.evaluate(universe.read_ticket()).unwrap().voxels.as_vol_ref()),
1008            vec![
1009                "................................",
1010                "........##...................##.",
1011                "........##...................##.",
1012                "........##...................##.",
1013                ".#####..##.###...#####...###.##.",
1014                ".....##.###..##.###..##.##..###.",
1015                ".######.##...##.##......##...##.",
1016                "##...##.##...##.##......##...##.",
1017                "##...##.##...##.##......##...##.",
1018                "##..###.###..##.###..##.##..###.",
1019                ".###.##.##.###...#####...###.##.",
1020                "................................",
1021                "................................",
1022                "................................",
1023                "........##...................##.",
1024                "........##...................##.",
1025                "........##...................##.",
1026                ".#####..##.###...#####...###.##.",
1027                ".....##.###..##.###..##.##..###.",
1028                ".######.##...##.##......##...##.",
1029                "##...##.##...##.##......##...##.",
1030                "##...##.##...##.##......##...##.",
1031                "##..###.###..##.###..##.##..###.",
1032                ".###.##.##.###...#####...###.##.",
1033                "................................",
1034                "................................",
1035            ]
1036        )
1037    }
1038
1039    /// Test that the high-coordinate positioning options correctly meet the
1040    /// upper corner of the block.
1041    #[test]
1042    fn bounding_voxels_of_positioning_high() {
1043        let text = Text::builder()
1044            .resolution(Resolution::R32)
1045            .string(literal!("abc"))
1046            .font(Font::System16)
1047            .positioning(Positioning {
1048                x: PositioningX::Right,
1049                line_y: PositioningY::BodyTop,
1050                z: PositioningZ::Front,
1051            })
1052            .build();
1053
1054        // The part we care about precisely is that the upper corner.
1055        // The lower corner might change when we change the system font metrics.
1056        assert_eq!(
1057            text.bounding_voxels(),
1058            GridAab::from_lower_upper([8, 19, 31], [32, 32, 32])
1059        );
1060    }
1061
1062    /// Test the rounding behavior of text positioning.
1063    ///
1064    /// Includes left and right even though only centering is really hairy.
1065    ///
1066    /// Note that for odd&even cases, we primarily care about the choice of “round down” vs.
1067    /// “round up” options in that they shouldn’t *change without notice*.
1068    ///
1069    /// * If `odd_font` is true, the string is 27 voxels wide. If false, 48 voxels wide.
1070    /// * If `odd_bounds` is true, the `layout_bounds` is 15 voxels wide. false, 16 voxels.
1071    #[rstest::rstest]
1072    #[case(PositioningX::Left, false, 0..16, 0..48)]
1073    #[case(PositioningX::Right, false, 0..16, -32..16)]
1074    #[case(PositioningX::Center, false, 0..16, -16..32)]
1075    #[case(PositioningX::Center, true, 0..16, -6..21)]
1076    #[case(PositioningX::Center, false, 0..15, -16..32)]
1077    #[case(PositioningX::Center, true, 0..15, -6..21)]
1078    #[case(PositioningX::Center, false, 1..16, -15..33)]
1079    #[case(PositioningX::Center, true, 1..16, -5..22)]
1080    fn positioning_x(
1081        #[case] pos: PositioningX,
1082        #[case] odd_font: bool,
1083        #[case] bounds_range: core::ops::Range<i32>,
1084        #[case] expected: core::ops::Range<i32>,
1085    ) {
1086        let text = Text::builder()
1087            .string(if odd_font {
1088                // must have an odd number of characters
1089                literal!("abc")
1090            } else {
1091                literal!("abcdef")
1092            })
1093            // TODO: when we have custom fonts, use custom fonts instead of depending on properties
1094            // of fonts with other intents.
1095            .font(if odd_font { Font::Logo } else { Font::System16 })
1096            .layout_bounds(
1097                Resolution::R16,
1098                GridAab::from_ranges([bounds_range, 0..16, 0..16]),
1099            )
1100            .positioning(Positioning {
1101                x: pos,
1102                line_y: PositioningY::BodyMiddle,
1103                z: PositioningZ::Back,
1104            })
1105            .build();
1106
1107        assert_eq!(text.bounding_voxels().x_range(), expected);
1108    }
1109
1110    #[test]
1111    fn no_intersection_with_block() {
1112        let (universe, block) = single_block_test_case({
1113            Text::builder()
1114                .string(literal!("ab"))
1115                .font(Font::System16)
1116                .layout_bounds(
1117                    Resolution::R16,
1118                    GridAab::from_lower_size([100000, 0, 0], [16, 16, 16]),
1119                )
1120                .build()
1121        });
1122
1123        let ev = block.evaluate(universe.read_ticket()).unwrap();
1124        assert_eq!(
1125            ev.attributes,
1126            BlockAttributes {
1127                display_name: arcstr::literal!("ab"),
1128                ..BlockAttributes::default()
1129            }
1130        );
1131        assert_eq!(ev.resolution(), Resolution::R1);
1132        assert!(!ev.visible());
1133    }
1134
1135    // TODO: test that voxel attributes are as expected
1136}