Commit ccd8f837 authored by Alex Butler's avatar Alex Butler

Use crate ab_glyph_rasterizer to support otf cubic bezier curves

* Add Exo2-Light.oft refrence tests & benchmark.
* Update readme.
* Remove crate rasterization logic.
* Update render references (byte differences, visually the same).
parent 4175cb9e
......@@ -29,6 +29,7 @@ features = ["gpu_cache"]
ordered-float = { version = "1", default-features = false }
approx = { version = "0.3", default-features = false }
ttf-parser = { version = "0.5", default-features = false }
ab_glyph_rasterizer = { version = "0.1", default-features = false }
libm = { version = "0.2.1", default-features = false, optional = true }
......@@ -46,7 +47,7 @@ num_cpus = { version = "1.0", optional = true }
[features]
default = ["std"]
# Activates usage of std.
std = ["has-atomics", "ttf-parser/default"]
std = ["has-atomics", "ttf-parser/default", "ab_glyph_rasterizer/default"]
# Uses libm when not using std. This needs to be active in that case.
libm-math = ["libm"]
# Some targets don't have atomics, this activates usage of Arc<T> instead of Rc<T>.
......
......@@ -6,26 +6,22 @@ RustType is a pure Rust alternative to libraries like FreeType.
The current capabilities of RustType:
* Reading TrueType formatted fonts and font collections. This includes `*.ttf`
as well as a subset of `*.otf` font files.
* Reading OpenType formatted fonts and font collections. This includes `*.ttf`
as well as `*.otf` font files.
* Retrieving glyph shapes and commonly used properties for a font and its glyphs.
* Laying out glyphs horizontally using horizontal and vertical metrics, and
glyph-pair-specific kerning.
* Rasterising glyphs with sub-pixel positioning using an accurate analytical
algorithm (not based on sampling).
* Managing a font cache on the GPU with the `gpu_cache` module. This keeps
recently used glyph renderings
in a dynamic cache in GPU memory to minimise texture uploads per-frame. It
also allows you keep the draw call count for text very low, as all glyphs are
kept in one GPU texture.
recently used glyph renderings in a dynamic cache in GPU memory to minimise
texture uploads per-frame. It also allows you keep the draw call count for
text very low, as all glyphs are kept in one GPU texture.
Notable things that RustType does not support *yet*:
* OpenType formatted fonts that are not just TrueType fonts (OpenType is a
superset of TrueType). Notably there is no support yet for cubic Bezier curves
used in glyphs.
* Font hinting.
* Ligatures of any kind
* Ligatures of any kind.
* Some less common TrueType sub-formats.
* Right-to-left and vertical text layout.
......@@ -41,21 +37,14 @@ Run examples with `cargo run --example <NAME> -p rusttype-dev`
To hit the ground running with RustType, look at `dev/examples/simple.rs`
supplied with the crate. It demonstrates loading a font file, rasterising an
arbitrary string, and displaying the result as ASCII art. If you prefer to just
look at the documentation, the entry point for loading fonts is
`FontCollection`, from which you can access individual fonts, then their glyphs.
look at the documentation, the entry point for loading fonts is `Font`,
from which you can access individual fonts, then their glyphs.
## Future Plans
The initial motivation for the project was to provide easy-to-use font rendering for games.
There are numerous avenues for improving RustType. Ideas:
* Some form of hinting for improved legibility at small font sizes.
* Replacing the dependency on
[stb_truetype-rs](https://gitlab.redox-os.org/redox-os/stb_truetype-rs)
(a translation of [stb_truetype.h](https://github.com/nothings/stb/blob/master/stb_truetype.h)),
with OpenType font loading written in idiomatic Rust.
* Add support for cubic curves in OpenType fonts.
* Extract the rasterisation code into a separate vector graphics rendering crate.
* Support for some common forms of ligatures.
* And, eventually, support for embedded right-to-left Unicode text.
......
use blake2::{Blake2s, Digest};
use criterion::{criterion_group, criterion_main, Criterion};
use once_cell::sync::Lazy;
use rusttype::*;
......@@ -9,8 +8,11 @@ static DEJA_VU_MONO: Lazy<Font<'static>> = Lazy::new(|| {
static OPEN_SANS_ITALIC: Lazy<Font<'static>> = Lazy::new(|| {
Font::try_from_bytes(include_bytes!("../fonts/opensans/OpenSans-Italic.ttf") as &[u8]).unwrap()
});
static EXO2_OFT: Lazy<Font<'static>> = Lazy::new(|| {
Font::try_from_bytes(include_bytes!("../fonts/Exo2-Light.otf") as &[u8]).unwrap()
});
fn bench_draw_big_biohazard(c: &mut Criterion) {
fn draw_big_biohazard(c: &mut Criterion) {
let glyph = DEJA_VU_MONO
.glyph('☣')
.scaled(Scale::uniform(600.0))
......@@ -33,15 +35,10 @@ fn bench_draw_big_biohazard(c: &mut Criterion) {
target[WIDTH * y + x] = (alpha * 255.0) as u8;
})
});
// verify the draw result against static reference hash
assert_eq!(
format!("{:x}", Blake2s::digest(&target)),
"307a2514a191b827a214174d6c5d109599f0ec4b42d466bde91d10bdd5f8e22d"
);
});
}
fn bench_draw_w(c: &mut Criterion) {
fn draw_w(c: &mut Criterion) {
let glyph = DEJA_VU_MONO
.glyph('w')
.scaled(Scale::uniform(16.0))
......@@ -64,15 +61,10 @@ fn bench_draw_w(c: &mut Criterion) {
target[WIDTH * y + x] = (alpha * 255.0) as u8;
})
});
// verify the draw result against static reference hash
assert_eq!(
format!("{:x}", Blake2s::digest(&target)),
"c0e795601e3412144d1bfdc0cd94d9507aa9775a0f0f4f9862fe7ec7e83d7684"
);
});
}
fn bench_draw_iota(c: &mut Criterion) {
fn draw_iota(c: &mut Criterion) {
let glyph = OPEN_SANS_ITALIC
.glyph('ΐ')
.scaled(Scale::uniform(60.0))
......@@ -95,19 +87,41 @@ fn bench_draw_iota(c: &mut Criterion) {
target[WIDTH * y + x] = (alpha * 255.0) as u8;
})
});
// verify the draw result against static reference hash
assert_eq!(
format!("{:x}", Blake2s::digest(&target)),
"d8fa90d375a7dc2c8c821395e8cef8baefb78046e4a7a93d87f96509add6a65c"
);
});
}
fn draw_oft_tailed_e(c: &mut Criterion) {
let glyph = EXO2_OFT
.glyph('ę')
.scaled(Scale::uniform(300.0))
.positioned(point(0.0, 0.0));
const WIDTH: usize = 106;
const HEIGHT: usize = 183;
let bounds = glyph.pixel_bounding_box().unwrap();
assert_eq!(
(bounds.width() as usize, bounds.height() as usize),
(WIDTH, HEIGHT)
);
let mut target = [0u8; WIDTH * HEIGHT];
c.bench_function("draw_oft_tailed_e", |b| {
b.iter(|| {
glyph.draw(|x, y, alpha| {
let (x, y) = (x as usize, y as usize);
target[WIDTH * y + x] = (alpha * 255.0) as u8;
})
});
});
}
criterion_group!(
benches,
bench_draw_big_biohazard,
bench_draw_w,
bench_draw_iota,
draw_benches,
draw_big_biohazard,
draw_w,
draw_iota,
draw_oft_tailed_e,
);
criterion_main!(benches);
criterion_main!(draw_benches);
dev/tests/reference_big_biohazard.png

9.88 KB | W: | H:

dev/tests/reference_big_biohazard.png

7.27 KB | W: | H:

dev/tests/reference_big_biohazard.png
dev/tests/reference_big_biohazard.png
dev/tests/reference_big_biohazard.png
dev/tests/reference_big_biohazard.png
  • 2-up
  • Swipe
  • Onion skin
dev/tests/reference_iota.png

539 Bytes | W: | H:

dev/tests/reference_iota.png

403 Bytes | W: | H:

dev/tests/reference_iota.png
dev/tests/reference_iota.png
dev/tests/reference_iota.png
dev/tests/reference_iota.png
  • 2-up
  • Swipe
  • Onion skin
......@@ -9,6 +9,9 @@ static DEJA_VU_MONO: Lazy<Font<'static>> = Lazy::new(|| {
static OPEN_SANS_ITALIC: Lazy<Font<'static>> = Lazy::new(|| {
Font::try_from_bytes(include_bytes!("../fonts/opensans/OpenSans-Italic.ttf") as &[u8]).unwrap()
});
static EXO2_OFT: Lazy<Font<'static>> = Lazy::new(|| {
Font::try_from_bytes(include_bytes!("../fonts/Exo2-Light.otf") as &[u8]).unwrap()
});
fn draw_luma_alpha(glyph: ScaledGlyph<'_>) -> image::GrayAlphaImage {
let glyph = glyph.positioned(point(0.0, 0.0));
......@@ -113,3 +116,33 @@ fn render_to_reference_iota() {
}
}
}
/// Render a 300px 'ę' character that uses cubic beziers & require it to match the reference.
#[test]
fn render_to_reference_oft_tailed_e() {
let new_image = draw_luma_alpha(EXO2_OFT.glyph('ę').scaled(Scale::uniform(300.0)));
// save the new render for manual inspection
new_image.save("../target/otf_tailed_e.png").unwrap();
let reference = image::load(
Cursor::new(include_bytes!("reference_otf_tailed_e.png") as &[u8]),
image::ImageFormat::Png,
)
.expect("!image::load")
.to_luma_alpha();
assert_eq!(reference.dimensions(), new_image.dimensions());
for y in 0..reference.height() {
for x in 0..reference.width() {
assert_eq!(
reference.get_pixel(x, y),
new_image.get_pixel(x, y),
"unexpected alpha difference at ({}, {})",
x,
y
);
}
}
}
......@@ -143,17 +143,6 @@ impl<N: ops::Add<Output = N>> ops::Add<Point<N>> for Vector<N> {
}
}
/// A straight line between two points, `p[0]` and `p[1]`
#[derive(Copy, Clone, Debug, Default, PartialEq, PartialOrd)]
pub struct Line {
pub p: [Point<f32>; 2],
}
/// A quadratic Bezier curve, starting at `p[0]`, ending at `p[2]`, with control
/// point `p[1]`.
#[derive(Copy, Clone, Debug, Default, PartialEq, PartialOrd)]
pub struct Curve {
pub p: [Point<f32>; 3],
}
/// A rectangle, with top-left corner at `min`, and bottom-right corner at
/// `max`.
#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord)]
......@@ -183,175 +172,3 @@ pub trait BoundingBox<N> {
fn x_bounds(&self) -> (N, N);
fn y_bounds(&self) -> (N, N);
}
impl BoundingBox<f32> for Line {
fn x_bounds(&self) -> (f32, f32) {
let p = &self.p;
if p[0].x < p[1].x {
(p[0].x, p[1].x)
} else {
(p[1].x, p[0].x)
}
}
fn y_bounds(&self) -> (f32, f32) {
let p = &self.p;
if p[0].y < p[1].y {
(p[0].y, p[1].y)
} else {
(p[1].y, p[0].y)
}
}
}
impl BoundingBox<f32> for Curve {
fn x_bounds(&self) -> (f32, f32) {
let p = &self.p;
if p[0].x <= p[1].x && p[1].x <= p[2].x {
(p[0].x, p[2].x)
} else if p[0].x >= p[1].x && p[1].x >= p[2].x {
(p[2].x, p[0].x)
} else {
let t = (p[0].x - p[1].x) / (p[0].x - 2.0 * p[1].x + p[2].x);
let _1mt = 1.0 - t;
let inflection = _1mt * _1mt * p[0].x + 2.0 * _1mt * t * p[1].x + t * t * p[2].x;
if p[1].x < p[0].x {
(inflection, p[0].x.max(p[2].x))
} else {
(p[0].x.min(p[2].x), inflection)
}
}
}
fn y_bounds(&self) -> (f32, f32) {
let p = &self.p;
if p[0].y <= p[1].y && p[1].y <= p[2].y {
(p[0].y, p[2].y)
} else if p[0].y >= p[1].y && p[1].y >= p[2].y {
(p[2].y, p[0].y)
} else {
let t = (p[0].y - p[1].y) / (p[0].y - 2.0 * p[1].y + p[2].y);
let _1mt = 1.0 - t;
let inflection = _1mt * _1mt * p[0].y + 2.0 * _1mt * t * p[1].y + t * t * p[2].y;
if p[1].y < p[0].y {
(inflection, p[0].y.max(p[2].y))
} else {
(p[0].y.min(p[2].y), inflection)
}
}
}
}
pub trait Cut: Sized {
fn cut_to(self, t: f32) -> Self;
fn cut_from(self, t: f32) -> Self;
fn cut_from_to(self, t0: f32, t1: f32) -> Self {
self.cut_from(t0).cut_to((t1 - t0) / (1.0 - t0))
}
}
impl Cut for Curve {
fn cut_to(self, t: f32) -> Curve {
let p = self.p;
let a = p[0] + t * (p[1] - p[0]);
let b = p[1] + t * (p[2] - p[1]);
let c = a + t * (b - a);
Curve { p: [p[0], a, c] }
}
fn cut_from(self, t: f32) -> Curve {
let p = self.p;
let a = p[0] + t * (p[1] - p[0]);
let b = p[1] + t * (p[2] - p[1]);
let c = a + t * (b - a);
Curve { p: [c, b, p[2]] }
}
}
impl Cut for Line {
fn cut_to(self, t: f32) -> Line {
let p = self.p;
Line {
p: [p[0], p[0] + t * (p[1] - p[0])],
}
}
fn cut_from(self, t: f32) -> Line {
let p = self.p;
Line {
p: [p[0] + t * (p[1] - p[0]), p[1]],
}
}
fn cut_from_to(self, t0: f32, t1: f32) -> Line {
let p = self.p;
let v = p[1] - p[0];
Line {
p: [p[0] + t0 * v, p[0] + t1 * v],
}
}
}
/// The real valued solutions to a real quadratic equation.
#[derive(Copy, Clone, Debug)]
pub enum RealQuadraticSolution {
/// Two zero-crossing solutions
Two(f32, f32),
/// One zero-crossing solution (equation is a straight line)
One(f32),
/// One zero-touching solution
Touch(f32),
/// No solutions
None,
/// All real numbers are solutions since a == b == c == 0.0
All,
}
impl RealQuadraticSolution {
/// If there are two solutions, this function ensures that they are in order
/// (first < second)
pub fn in_order(self) -> RealQuadraticSolution {
use self::RealQuadraticSolution::*;
match self {
Two(x, y) => {
if x < y {
Two(x, y)
} else {
Two(y, x)
}
}
other => other,
}
}
}
/// Solve a real quadratic equation, giving all real solutions, if any.
pub fn solve_quadratic_real(a: f32, b: f32, c: f32) -> RealQuadraticSolution {
let discriminant = b * b - 4.0 * a * c;
if discriminant > 0.0 {
let sqrt_d = discriminant.sqrt();
let common = -b + if b >= 0.0 { -sqrt_d } else { sqrt_d };
let x1 = 2.0 * c / common;
if a == 0.0 {
RealQuadraticSolution::One(x1)
} else {
let x2 = common / (2.0 * a);
RealQuadraticSolution::Two(x1, x2)
}
} else if discriminant < 0.0 {
RealQuadraticSolution::None
} else if b == 0.0 {
if a == 0.0 {
if c == 0.0 {
RealQuadraticSolution::All
} else {
RealQuadraticSolution::None
}
} else {
RealQuadraticSolution::Touch(0.0)
}
} else {
RealQuadraticSolution::Touch(2.0 * c / -b)
}
}
#[test]
fn quadratic_test() {
solve_quadratic_real(-0.000_000_1, -2.0, 10.0);
}
......@@ -973,13 +973,13 @@ fn draw_glyph(tex_coords: Rect<u32>, glyph: &PositionedGlyph<'_>, pad_glyphs: bo
let mut pixels = ByteArray2d::zeros(tex_coords.height() as usize, tex_coords.width() as usize);
if pad_glyphs {
glyph.draw(|x, y, v| {
let v = (v * 255.0).round().max(0.0).min(255.0) as u8;
let v = (v * 255.0).round() as u8;
// `+ 1` accounts for top/left glyph padding
pixels[(y as usize + 1, x as usize + 1)] = v;
});
} else {
glyph.draw(|x, y, v| {
let v = (v * 255.0).round().max(0.0).min(255.0) as u8;
let v = (v * 255.0).round() as u8;
pixels[(y as usize, x as usize)] = v;
});
}
......
......@@ -31,7 +31,7 @@
//! supplied with the crate. It demonstrates loading a font file, rasterising an
//! arbitrary string, and displaying the result as ASCII art. If you prefer to
//! just look at the documentation, the entry point for loading fonts is
//! `FontCollection`, from which you can access individual fonts, then their
//! `Font`, from which you can access individual fonts, then their
//! glyphs.
//!
//! # Glyphs
......@@ -99,7 +99,7 @@ extern crate alloc;
mod font;
mod geometry;
mod rasterizer;
mod outliner;
#[cfg(all(feature = "libm-math", not(feature = "std")))]
mod nostd_float;
......@@ -107,7 +107,7 @@ mod nostd_float;
#[cfg(feature = "gpu_cache")]
pub mod gpu_cache;
pub use crate::geometry::{point, vector, Curve, Line, Point, Rect, Vector};
pub use crate::geometry::{point, vector, Point, Rect, Vector};
pub use font::*;
use approx::relative_eq;
......@@ -132,9 +132,7 @@ impl From<GlyphId> for ttf_parser::GlyphId {
}
}
/// A single glyph of a font. this may either be a thin wrapper referring to the
/// font and the glyph id, or it may be a standalone glyph that owns the data
/// needed by it.
/// A single glyph of a font.
///
/// A `Glyph` does not have an inherent scale or position associated with it. To
/// augment a glyph with a size, give it a scale using `scaled`. You can then
......@@ -146,10 +144,7 @@ pub struct Glyph<'font> {
}
impl<'font> Glyph<'font> {
/// The font to which this glyph belongs. If the glyph is a standalone glyph
/// that owns its resources, it no longer has a reference to the font which
/// it was created from (using `standalone()`). In which case, `None` is
/// returned.
/// The font to which this glyph belongs.
pub fn font(&self) -> &Font<'font> {
&self.font
}
......@@ -234,10 +229,7 @@ impl<'font> ScaledGlyph<'font> {
self.g.id()
}
/// The font to which this glyph belongs. If the glyph is a standalone glyph
/// that owns its resources, it no longer has a reference to the font which
/// it was created from (using `standalone()`). In which case, `None` is
/// returned.
/// The font to which this glyph belongs.
#[inline]
pub fn font(&self) -> &Font<'font> {
self.g.font()
......@@ -365,10 +357,7 @@ impl<'font> PositionedGlyph<'font> {
self.sg.id()
}
/// The font to which this glyph belongs. If the glyph is a standalone glyph
/// that owns its resources, it no longer has a reference to the font which
/// it was created from (using `standalone()`). In which case, `None` is
/// returned.
/// The font to which this glyph belongs.
#[inline]
pub fn font(&self) -> &Font<'font> {
self.sg.font()
......@@ -427,20 +416,23 @@ impl<'font> PositionedGlyph<'font> {
return;
};
let width = (bb.max.x - bb.min.x) as u32;
let height = (bb.max.y - bb.min.y) as u32;
let offset = vector(bb.min.x as f32, bb.min.y as f32);
let mut outliner = Outliner::new(&self, offset);
let mut outliner = crate::outliner::OutlineRasterizer::new(
self.position - offset,
self.sg.scale,
width as _,
height as _,
);
self.font()
.inner()
.outline_glyph(self.id().into(), &mut outliner);
rasterizer::rasterize(
&outliner.lines,
&outliner.curves,
(bb.max.x - bb.min.x) as u32,
(bb.max.y - bb.min.y) as u32,
o,
);
outliner.rasterizer.for_each_pixel_2d(o);
}
/// Resets positioning information and recalculates the pixel bounding box
......@@ -564,90 +556,3 @@ impl<'b> Iterator for LayoutIter<'b> {
})
}
}
struct Outliner {
lines: Vec<Line>,
curves: Vec<Curve>,
last: Point<f32>,
position: Point<f32>,
scale: Vector<f32>,
offset: Vector<f32>,
}
// struct OffsetOutlines {
// lines: Vec<Line>,
// curves: Vec<Curve>,
// }
impl Outliner {
fn new(glyph: &PositionedGlyph<'_>, offset: Vector<f32>) -> Self {
Self {
lines: <_>::default(),
curves: <_>::default(),
last: point(0.0, 0.0),
position: glyph.position,
scale: glyph.sg.scale,
offset,
}
}
// fn offset(self, offset: Vector<f32>) -> OffsetOutlines {
// let mut lines = self.lines;
// let mut curves = self.curves;
//
// lines.iter_mut().for_each(|l| l.p.iter_mut().for_each(|p| *p = *p - offset));
// curves.iter_mut().for_each(|l| l.p.iter_mut().for_each(|p| *p = *p - offset));
//
// OffsetOutlines {
// lines,
// curves,
// }
// }
}
impl ttf_parser::OutlineBuilder for Outliner {
fn move_to(&mut self, x: f32, y: f32) {
self.last = point(
x as f32 * self.scale.x + self.position.x,
-y as f32 * self.scale.y + self.position.y,
) - self.offset;
}
fn line_to(&mut self, x: f32, y: f32) {
let end = point(
x as f32 * self.scale.x + self.position.x,
-y as f32 * self.scale.y + self.position.y,
) - self.offset;
self.lines.push(Line {
p: [self.last, end],
});
self.last = end;
}
fn quad_to(&mut self, x1: f32, y1: f32, x: f32, y: f32) {
let end = point(
x as f32 * self.scale.x + self.position.x,
-y as f32 * self.scale.y + self.position.y,
) - self.offset;
let control = point(
x1 as f32 * self.scale.x + self.position.x,
-y1 as f32 * self.scale.y + self.position.y,
) - self.offset;
self.curves.push(Curve {
p: [self.last, control, end],
});
self.last = end;
}
fn curve_to(&mut self, _x1: f32, _y1: f32, _x2: f32, _y2: f32, _x: f32, _y: f32) {
todo!("otf curves not yet supported")
}
fn close(&mut self) {}
}
use crate::{Point, Vector};
use ab_glyph_rasterizer::{point as ab_point, Point as AbPoint, Rasterizer};
pub(crate) struct OutlineRasterizer {
pub(crate) rasterizer: Rasterizer,
last: AbPoint,
last_move: Option<AbPoint>,
position: AbPoint,
scale: Vector<f32>,
}
impl OutlineRasterizer {
pub(crate) fn new(position: Point<f32>, scale: Vector<f32>, width: usize, height: usize) -> Self {
Self {
rasterizer: Rasterizer::new(width, height),
last: ab_point(0.0, 0.0),
last_move: None,
position: ab_point(position.x, position.y),
scale,
}
}
}
impl ttf_parser::OutlineBuilder for OutlineRasterizer {
fn move_to(&mut self, x: f32, y: f32) {
self.last = AbPoint {
x: x as f32 * self.scale.x + self.position.x,
y: -y as f32 * self.scale.y + self.position.y,
};
self.last_move = Some(self.last);
}
fn line_to(&mut self, x1: f32, y1: f32) {
let p1 = AbPoint {
x: x1 as f32 * self.scale.x + self.position.x,
y: -y1 as f32 * self.scale.y + self.position.y,
};
self.rasterizer.draw_line(self.last, p1);
self.last = p1;
}
fn quad_to(&mut self, x1: f32, y1: f32, x2: f32, y2: f32) {
let p1 = AbPoint {
x: x1 as f32 * self.scale.x + self.position.x,
y: -y1 as f32 * self.scale.y + self.position.y,
};
let p2 = AbPoint {
x: x2 as f32 * self.scale.x + self.position.x,
y: -y2 as f32 * self.scale.y + self.position.y,
};
self.rasterizer.draw_quad(self.last, p1, p2);
self.last = p2;
}
fn curve_to(&mut self, x1: f32, y1: f32, x2: f32, y2: f32, x3: f32, y3: f32) {
let p1 = AbPoint {
x: x1 as f32 * self.scale.x + self.position.x,
y: -y1 as f32 * self.scale.y + self.position.y,
};
let p2 = AbPoint {
x: x2 as f32 * self.scale.x + self.position.x,
y: -y2 as f32 * self.scale.y + self.position.y,
};
let p3 = AbPoint {
x: x3 as f32 * self.scale.x + self.position.x,
y: -y3 as f32 * self.scale.y + self.position.y,
};
self.rasterizer.draw_cubic(self.last, p1, p2, p3);
self.last = p3;
}
fn close(&mut self) {
if let Some(m) = self.last_move {
self.rasterizer.draw_line(self.last, m);
}
}
}
This diff is collapsed.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment