feat(lapaloma-cartesian): picture cache pan-blit

- ChartCache + ChartCacheHandle (Arc<Mutex<...>>) cacheable entre
  frames. El Render host crea uno con chart_cache() y lo pasa al
  Element con .with_cache(handle). Sin handle, cada frame rebuild
  completo (correcto pero sin la optimización).
- Hash estructural: plot rect + viewport.span (no x_min/y_min) +
  per-series (data.revision + data.len + stroke). 5 tests cubren
  estabilidad, pan no invalida, zoom invalida, data revision
  invalida, plot rect invalida.
- En paint: si el hash matches, pan-blit = copia las coords
  cacheadas con offset (dx_px, dy_px) calculado del diff entre
  viewport.x_min cached vs actual. Salteamos LTTB + projection.
- LineSeries::compute_projected expone el pipe LTTB + project_buffer
  como método público para que el Element pueda cachear sin pasar
  por paint().
- Demo multi-series usa el cache; header muestra "cache: N
  pan-blits / M rebuilds" en vivo para que se vea la métrica al
  draguear (pan-blits crece) y al zoomear (rebuilds crece).

Limitación v0.1 anotada en código: el doc canónico (sección 4.4)
usa una textura offscreen blitable; GPUI 0.2 no expone esa primitiva
directa. La impl actual cachea coords proyectadas y emite las
polilíneas con offset — mismo ahorro de CPU (saltea LTTB) sin GPU
texture cache.

51 tests verdes (28 cartesian incluyendo 5 nuevos del structural_hash,
20 core, 3 render).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
sergio
2026-05-13 03:12:02 +00:00
parent ab03a61db4
commit 2b8e990cf9
4 changed files with 315 additions and 49 deletions
+14 -2
View File
@@ -14,7 +14,7 @@ use gpui::{
MouseMoveEvent, MouseUpEvent, Point, Render, ScrollDelta, ScrollWheelEvent, Window, MouseMoveEvent, MouseUpEvent, Point, Render, ScrollDelta, ScrollWheelEvent, Window,
}; };
use lapaloma_cartesian::{ChartViewport, LapalomaChartElement}; use lapaloma_cartesian::{chart_cache, ChartCacheHandle, ChartViewport, LapalomaChartElement};
use lapaloma_core::buffer::DataBuffer; use lapaloma_core::buffer::DataBuffer;
use lapaloma_render::{Color, StrokeStyle}; use lapaloma_render::{Color, StrokeStyle};
use yahweh_launcher::launch_app; use yahweh_launcher::launch_app;
@@ -38,6 +38,7 @@ struct Demo {
viewport: ChartViewport, viewport: ChartViewport,
initial_viewport: ChartViewport, initial_viewport: ChartViewport,
drag: Option<DragAnchor>, drag: Option<DragAnchor>,
chart_cache: ChartCacheHandle,
} }
#[derive(Clone, Copy)] #[derive(Clone, Copy)]
@@ -65,6 +66,7 @@ impl Demo {
viewport, viewport,
initial_viewport: viewport, initial_viewport: viewport,
drag: None, drag: None,
chart_cache: chart_cache(),
} }
} }
@@ -145,6 +147,7 @@ impl Render for Demo {
let plot_bg = Color::rgba(0.10, 0.12, 0.16, 1.0); let plot_bg = Color::rgba(0.10, 0.12, 0.16, 1.0);
let chart = LapalomaChartElement::new(self.viewport) let chart = LapalomaChartElement::new(self.viewport)
.background(plot_bg) .background(plot_bg)
.with_cache(self.chart_cache.clone())
.add_series_named( .add_series_named(
self.series_sin.clone(), self.series_sin.clone(),
StrokeStyle::new(2.0, Color::from_hex(COLOR_SIN)), StrokeStyle::new(2.0, Color::from_hex(COLOR_SIN)),
@@ -162,6 +165,10 @@ impl Render for Demo {
); );
let drag_active = self.drag.is_some(); let drag_active = self.drag.is_some();
let (pan_blits, rebuilds) = {
let c = self.chart_cache.lock().unwrap();
(c.pan_blits(), c.rebuilds())
};
div() div()
.id("lapaloma-demo-root") .id("lapaloma-demo-root")
@@ -200,7 +207,12 @@ impl Render for Demo {
.child( .child(
div() div()
.text_color(theme.fg_muted) .text_color(theme.fg_muted)
.child(if drag_active { "· dragging" } else { "" }), .child(format!(
"· cache: {} pan-blits / {} rebuilds {}",
pan_blits,
rebuilds,
if drag_active { "· dragging" } else { "" },
)),
), ),
) )
.child( .child(
@@ -1,17 +1,30 @@
//! `LapalomaChartElement` — el `Element` GPUI que envuelve el //! `LapalomaChartElement` — el `Element` GPUI que envuelve el
//! pipeline cartesian. //! pipeline cartesian.
//! //!
//! Mantiene un `Vec<ChartSeriesItem>` con `DataBuffer + StrokeStyle` //! ## Picture cache pan-blit
//! por serie. En `paint()` arma el `WindowCanvas` adapter, pinta el
//! background, los ejes con sus labels, y delega a una `LineSeries`
//! por cada item. Cada serie emite un solo `paint_path` — N series
//! = N draw calls totales (no N × por punto).
//! //!
//! Todas las series comparten viewport. Para Y axis dual (necesario //! En GPUI cada frame se construye un Element nuevo (el árbol se
//! cuando series tienen rangos muy distintos) la API se va a //! recrea), así que el cache no puede vivir en el Element. El
//! extender con `y_axis_id` por serie y un viewport segundario. //! caller crea un `ChartCacheHandle` (Arc<Mutex<ChartCache>>) una
//! vez y se lo pasa a cada frame.
//!
//! Algoritmo (sección 4.4 del ARCHITECTURE.md adaptada a GPUI):
//! - Hash estructural = plot rect + span (no x_min/y_min) + por
//! serie: revision + len + stroke.
//! - Si hash igual al cached: **pan puro** → emitimos las coords
//! cacheadas con un offset `(dx_px, dy_px)` calculado del
//! diff `viewport.x_min - cached.x_min`. Saltea LTTB +
//! projection.
//! - Si hash distinto: full rebuild. Re-corre LTTB + project,
//! pisa el cache, actualiza el snapshot del viewport.
//!
//! Sin cache, el Element funciona igual: cada frame rebuild
//! completo. Útil para tests/smoke.
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use std::panic; use std::panic;
use std::sync::{Arc, Mutex};
use gpui::{ use gpui::{
App, Bounds, Element, ElementId, GlobalElementId, InspectorElementId, IntoElement, LayoutId, App, Bounds, Element, ElementId, GlobalElementId, InspectorElementId, IntoElement, LayoutId,
@@ -24,18 +37,58 @@ use lapaloma_render::{Canvas, Color, Point, Rect, StrokeStyle, WindowCanvas};
use crate::axis::{decimate_labels, format_tick, ticks_nice, AxisStyle}; use crate::axis::{decimate_labels, format_tick, ticks_nice, AxisStyle};
use crate::coord_system::CoordinateSystem; use crate::coord_system::CoordinateSystem;
use crate::series::{LineSeries, PaintCtx, RenderMode, Series}; use crate::series::LineSeries;
use crate::viewport::ChartViewport; use crate::viewport::ChartViewport;
const TARGET_TICKS_X: usize = 8; const TARGET_TICKS_X: usize = 8;
const TARGET_TICKS_Y: usize = 6; const TARGET_TICKS_Y: usize = 6;
/// Aproximación del ancho de glifo monoespaciado en función del
/// font size. Suficiente para alinear / decimar labels.
const MONO_GLYPH_RATIO: f32 = 0.55; const MONO_GLYPH_RATIO: f32 = 0.55;
/// Una serie del chart: data + estilo + nombre opcional (para /// Cache de coords proyectadas para reuso entre frames. Es lo
/// futura leyenda visual). /// que habilita el pan-blit: el caller lo crea una vez y lo
/// pasa por handle.
#[derive(Default, Debug)]
pub struct ChartCache {
/// Coords proyectadas por serie. `projected.len()` debe coincidir
/// con la cantidad de series del Element.
projected: Vec<Vec<f32>>,
/// Hash de la geometría + identidades de data. Si cambia,
/// invalidamos.
structural_hash: u64,
/// `viewport.x_min` con el que se proyectaron las coords.
cached_x_min: f64,
cached_y_min: f64,
/// Estadística informativa: cuántos pan-blits desde el último
/// rebuild. Útil para debugging y para mostrar en demos.
pan_blits: u64,
/// Estadística informativa: cuántos rebuilds totales.
rebuilds: u64,
has_valid_cache: bool,
}
impl ChartCache {
pub fn new() -> Self {
Self::default()
}
pub fn pan_blits(&self) -> u64 {
self.pan_blits
}
pub fn rebuilds(&self) -> u64 {
self.rebuilds
}
pub fn invalidate(&mut self) {
*self = Self::default();
}
}
pub type ChartCacheHandle = Arc<Mutex<ChartCache>>;
/// Atajo para crear un cache compartido. El caller lo guarda en
/// su `Render` host y le pasa el clone al Element en cada frame.
pub fn chart_cache() -> ChartCacheHandle {
Arc::new(Mutex::new(ChartCache::new()))
}
#[derive(Clone)] #[derive(Clone)]
pub struct ChartSeriesItem { pub struct ChartSeriesItem {
pub data: DataBuffer, pub data: DataBuffer,
@@ -47,7 +100,6 @@ impl ChartSeriesItem {
pub fn new(data: DataBuffer, stroke: StrokeStyle) -> Self { pub fn new(data: DataBuffer, stroke: StrokeStyle) -> Self {
Self { data, stroke, name: None } Self { data, stroke, name: None }
} }
pub fn named(data: DataBuffer, stroke: StrokeStyle, name: impl Into<String>) -> Self { pub fn named(data: DataBuffer, stroke: StrokeStyle, name: impl Into<String>) -> Self {
Self { data, stroke, name: Some(name.into()) } Self { data, stroke, name: Some(name.into()) }
} }
@@ -63,13 +115,13 @@ pub struct LapalomaChartElement {
pub margin_left: f32, pub margin_left: f32,
pub margin_top: f32, pub margin_top: f32,
pub margin_right: f32, pub margin_right: f32,
/// Scratch reusable entre series y entre frames. /// Cache opcional compartido con el `Render` host. Si está
/// presente, habilita pan-blit.
pub cache: Option<ChartCacheHandle>,
scratch: Vec<f32>, scratch: Vec<f32>,
} }
impl LapalomaChartElement { impl LapalomaChartElement {
/// Construye un chart vacío con un viewport. Las series se
/// agregan con `add_series` / `add_series_named`.
pub fn new(viewport: ChartViewport) -> Self { pub fn new(viewport: ChartViewport) -> Self {
Self { Self {
series: Vec::new(), series: Vec::new(),
@@ -81,6 +133,7 @@ impl LapalomaChartElement {
margin_left: 32.0, margin_left: 32.0,
margin_top: 8.0, margin_top: 8.0,
margin_right: 8.0, margin_right: 8.0,
cache: None,
scratch: Vec::new(), scratch: Vec::new(),
} }
} }
@@ -118,6 +171,13 @@ impl LapalomaChartElement {
self self
} }
/// Enchufa un cache compartido. Sin esto, cada frame es rebuild
/// completo (correcto pero sin la optimización pan-blit).
pub fn with_cache(mut self, cache: ChartCacheHandle) -> Self {
self.cache = Some(cache);
self
}
fn plot_rect(&self, bounds: Rect) -> Rect { fn plot_rect(&self, bounds: Rect) -> Rect {
Rect::new( Rect::new(
bounds.x + self.margin_left, bounds.x + self.margin_left,
@@ -145,7 +205,6 @@ impl LapalomaChartElement {
axis_stroke, axis_stroke,
); );
// X axis ticks + labels.
let x_ticks = ticks_nice(self.viewport.x_min, self.viewport.x_max, TARGET_TICKS_X); let x_ticks = ticks_nice(self.viewport.x_min, self.viewport.x_max, TARGET_TICKS_X);
let x_step = nice_step(self.viewport.x_min, self.viewport.x_max, TARGET_TICKS_X); let x_step = nice_step(self.viewport.x_min, self.viewport.x_max, TARGET_TICKS_X);
let mut x_pos: Vec<f32> = Vec::with_capacity(x_ticks.len()); let mut x_pos: Vec<f32> = Vec::with_capacity(x_ticks.len());
@@ -180,7 +239,6 @@ impl LapalomaChartElement {
); );
} }
// Y axis ticks + labels con decimación vertical.
let y_ticks = ticks_nice(self.viewport.y_min, self.viewport.y_max, TARGET_TICKS_Y); let y_ticks = ticks_nice(self.viewport.y_min, self.viewport.y_max, TARGET_TICKS_Y);
let y_step = nice_step(self.viewport.y_min, self.viewport.y_max, TARGET_TICKS_Y); let y_step = nice_step(self.viewport.y_min, self.viewport.y_max, TARGET_TICKS_Y);
let y_label_pitch = style.label_size_px + style.label_min_spacing_px; let y_label_pitch = style.label_size_px + style.label_min_spacing_px;
@@ -216,6 +274,73 @@ impl LapalomaChartElement {
prev_py = Some(py); prev_py = Some(py);
} }
} }
/// Rebuild full: LTTB + projection por serie. Pinta directo desde
/// el cache si está enchufado (para no copiar dos veces).
fn rebuild_and_paint(&mut self, cs: &CoordinateSystem, canvas: &mut dyn Canvas) {
if let Some(handle) = self.cache.clone() {
let mut cache = handle.lock().unwrap();
cache.projected.clear();
cache.projected.resize_with(self.series.len(), Vec::new);
for (i, item) in self.series.iter().enumerate() {
let series = LineSeries::new(&item.data, item.stroke);
series.compute_projected(cs, &mut cache.projected[i]);
if cache.projected[i].len() >= 4 {
canvas.stroke_polyline(&cache.projected[i], item.stroke);
}
}
cache.structural_hash = structural_hash(
cs.plot,
self.viewport.x_span(),
self.viewport.y_span(),
&self.series,
);
cache.cached_x_min = self.viewport.x_min;
cache.cached_y_min = self.viewport.y_min;
cache.has_valid_cache = true;
cache.pan_blits = 0;
cache.rebuilds = cache.rebuilds.wrapping_add(1);
} else {
// Sin cache: usamos el scratch local.
for item in &self.series {
let series = LineSeries::new(&item.data, item.stroke);
series.compute_projected(cs, &mut self.scratch);
if self.scratch.len() >= 4 {
canvas.stroke_polyline(&self.scratch, item.stroke);
}
}
}
}
/// Emite las coords cacheadas con un offset en pixel space.
/// Se usa cuando detectamos pan puro (mismo hash estructural).
fn pan_blit_paint(&mut self, plot: Rect, canvas: &mut dyn Canvas) {
let Some(handle) = self.cache.clone() else {
return;
};
let mut cache = handle.lock().unwrap();
let dx_px = ((cache.cached_x_min - self.viewport.x_min) * plot.w as f64
/ self.viewport.x_span()) as f32;
let dy_px = ((self.viewport.y_min - cache.cached_y_min) * plot.h as f64
/ self.viewport.y_span()) as f32;
for (i, item) in self.series.iter().enumerate() {
let cached = &cache.projected[i];
if cached.len() < 4 {
continue;
}
self.scratch.clear();
self.scratch.reserve(cached.len());
let mut k = 0;
while k + 1 < cached.len() {
self.scratch.push(cached[k] + dx_px);
self.scratch.push(cached[k + 1] + dy_px);
k += 2;
}
canvas.stroke_polyline(&self.scratch, item.stroke);
}
cache.pan_blits = cache.pan_blits.wrapping_add(1);
}
} }
impl IntoElement for LapalomaChartElement { impl IntoElement for LapalomaChartElement {
@@ -232,7 +357,6 @@ impl Element for LapalomaChartElement {
fn id(&self) -> Option<ElementId> { fn id(&self) -> Option<ElementId> {
None None
} }
fn source_location(&self) -> Option<&'static panic::Location<'static>> { fn source_location(&self) -> Option<&'static panic::Location<'static>> {
None None
} }
@@ -288,22 +412,60 @@ impl Element for LapalomaChartElement {
self.paint_axes(&mut canvas, &cs); self.paint_axes(&mut canvas, &cs);
// Una pasada de paint por serie. Re-usamos el mismo scratch // Decide rebuild vs pan-blit.
// entre series — cada `LineSeries::paint` hace `clear()`. let current_hash = structural_hash(
for item in &self.series { plot,
let series = LineSeries::new(&item.data, item.stroke); self.viewport.x_span(),
let mut ctx = PaintCtx { self.viewport.y_span(),
cs, &self.series,
mode: RenderMode::UiRich, );
scratch: &mut self.scratch, let pan_only = self
}; .cache
series.paint(&mut ctx, &mut canvas); .as_ref()
.map(|h| {
let c = h.lock().unwrap();
c.has_valid_cache
&& c.structural_hash == current_hash
&& c.projected.len() == self.series.len()
})
.unwrap_or(false);
if pan_only {
self.pan_blit_paint(plot, &mut canvas);
} else {
self.rebuild_and_paint(&cs, &mut canvas);
} }
} }
} }
/// Helper de una sola línea. Para multi-series usar /// Hash de la geometría + identidades de data. Lo que NO va acá:
/// `LapalomaChartElement::new(viewport).add_series(...).add_series(...)`. /// `viewport.x_min` y `y_min` (el pan los mueve sin invalidar).
fn structural_hash(
plot: Rect,
x_span: f64,
y_span: f64,
series: &[ChartSeriesItem],
) -> u64 {
let mut h = DefaultHasher::new();
plot.x.to_bits().hash(&mut h);
plot.y.to_bits().hash(&mut h);
plot.w.to_bits().hash(&mut h);
plot.h.to_bits().hash(&mut h);
x_span.to_bits().hash(&mut h);
y_span.to_bits().hash(&mut h);
(series.len() as u64).hash(&mut h);
for s in series {
s.data.revision().hash(&mut h);
(s.data.len() as u64).hash(&mut h);
s.stroke.width.to_bits().hash(&mut h);
s.stroke.color.r.to_bits().hash(&mut h);
s.stroke.color.g.to_bits().hash(&mut h);
s.stroke.color.b.to_bits().hash(&mut h);
s.stroke.color.a.to_bits().hash(&mut h);
}
h.finish()
}
pub fn lapaloma_chart( pub fn lapaloma_chart(
data: DataBuffer, data: DataBuffer,
viewport: ChartViewport, viewport: ChartViewport,
@@ -311,3 +473,85 @@ pub fn lapaloma_chart(
) -> LapalomaChartElement { ) -> LapalomaChartElement {
LapalomaChartElement::new(viewport).add_series(data, stroke) LapalomaChartElement::new(viewport).add_series(data, stroke)
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn structural_hash_estable_para_mismo_estado() {
let mut data = DataBuffer::with_capacity(10);
for i in 0..10 {
data.push(i as f32, (i as f32).sin());
}
let series = vec![ChartSeriesItem::new(
data,
StrokeStyle::new(2.0, Color::rgb(1.0, 0.0, 0.0)),
)];
let plot = Rect::new(0.0, 0.0, 100.0, 100.0);
let a = structural_hash(plot, 10.0, 2.0, &series);
let b = structural_hash(plot, 10.0, 2.0, &series);
assert_eq!(a, b);
}
#[test]
fn structural_hash_pan_no_cambia() {
// Mismo span pero distinto x_min/y_min — hash igual.
let mut data = DataBuffer::with_capacity(10);
for i in 0..10 {
data.push(i as f32, (i as f32).sin());
}
let series = vec![ChartSeriesItem::new(
data,
StrokeStyle::new(2.0, Color::rgb(1.0, 0.0, 0.0)),
)];
let plot = Rect::new(0.0, 0.0, 100.0, 100.0);
let a = structural_hash(plot, 10.0, 2.0, &series);
// Pan implícito: x_span/y_span no cambiaron → hash igual.
let b = structural_hash(plot, 10.0, 2.0, &series);
assert_eq!(a, b);
}
#[test]
fn structural_hash_zoom_invalida() {
let series = vec![ChartSeriesItem::new(
DataBuffer::new(),
StrokeStyle::new(2.0, Color::WHITE),
)];
let plot = Rect::new(0.0, 0.0, 100.0, 100.0);
let a = structural_hash(plot, 10.0, 2.0, &series);
let b = structural_hash(plot, 5.0, 2.0, &series); // zoom in X
assert_ne!(a, b);
}
#[test]
fn structural_hash_data_revision_invalida() {
let mut data = DataBuffer::with_capacity(2);
data.push(0.0, 0.0);
let series0 = vec![ChartSeriesItem::new(
data.clone(),
StrokeStyle::new(2.0, Color::WHITE),
)];
let plot = Rect::new(0.0, 0.0, 100.0, 100.0);
let a = structural_hash(plot, 1.0, 1.0, &series0);
data.push(1.0, 1.0); // bump revision
let series1 = vec![ChartSeriesItem::new(
data,
StrokeStyle::new(2.0, Color::WHITE),
)];
let b = structural_hash(plot, 1.0, 1.0, &series1);
assert_ne!(a, b);
}
#[test]
fn structural_hash_plot_rect_invalida() {
let series = vec![ChartSeriesItem::new(
DataBuffer::new(),
StrokeStyle::new(2.0, Color::WHITE),
)];
let a = structural_hash(Rect::new(0.0, 0.0, 100.0, 100.0), 1.0, 1.0, &series);
let b = structural_hash(Rect::new(0.0, 0.0, 200.0, 100.0), 1.0, 1.0, &series);
assert_ne!(a, b);
}
}
@@ -39,4 +39,7 @@ pub use coord_system::CoordinateSystem;
pub use series::{LineSeries, PaintCtx, RenderMode, Series}; pub use series::{LineSeries, PaintCtx, RenderMode, Series};
#[cfg(feature = "gpui")] #[cfg(feature = "gpui")]
pub use element::{lapaloma_chart, LapalomaChartElement}; pub use element::{
chart_cache, lapaloma_chart, ChartCache, ChartCacheHandle, ChartSeriesItem,
LapalomaChartElement,
};
@@ -58,35 +58,42 @@ impl<'a> LineSeries<'a> {
Self { data, stroke, lttb_target: None } Self { data, stroke, lttb_target: None }
} }
fn effective_target(&self, plot_w: f32) -> usize { pub fn effective_target(&self, plot_w: f32) -> usize {
self.lttb_target.unwrap_or_else(|| (plot_w as usize).saturating_mul(3)) self.lttb_target.unwrap_or_else(|| (plot_w as usize).saturating_mul(3))
} }
}
impl<'a> Series for LineSeries<'a> { /// Materializa las coords proyectadas a pixel space en `out`,
fn paint(&self, ctx: &mut PaintCtx<'_>, canvas: &mut dyn Canvas) { /// aplicando LTTB cuando densidad > target. `out` se clearea.
///
/// Útil para callers que necesitan cachear el resultado
/// (picture cache pan-blit) sin pasar por `paint()`.
pub fn compute_projected(&self, cs: &CoordinateSystem, out: &mut Vec<f32>) {
out.clear();
if self.data.len() < 2 { if self.data.len() < 2 {
return; return;
} }
let target = self.effective_target(cs.plot.w);
let target = self.effective_target(ctx.cs.plot.w);
ctx.scratch.clear();
if self.data.len() > target { if self.data.len() > target {
// Decimar primero (en coords de dominio), proyectar después. let mut idx: Vec<usize> = Vec::with_capacity(target);
let mut idx = Vec::with_capacity(target);
lttb::lttb_indices(self.data.coords(), target, &mut idx); lttb::lttb_indices(self.data.coords(), target, &mut idx);
let mut decimated: Vec<f32> = Vec::with_capacity(idx.len() * 2); let mut decimated: Vec<f32> = Vec::with_capacity(idx.len() * 2);
for i in idx { for i in idx {
decimated.push(self.data.coords()[i * 2]); decimated.push(self.data.coords()[i * 2]);
decimated.push(self.data.coords()[i * 2 + 1]); decimated.push(self.data.coords()[i * 2 + 1]);
} }
ctx.cs.project_buffer(&decimated, ctx.scratch); cs.project_buffer(&decimated, out);
} else { } else {
ctx.cs.project_buffer(self.data.coords(), ctx.scratch); cs.project_buffer(self.data.coords(), out);
} }
}
}
impl<'a> Series for LineSeries<'a> {
fn paint(&self, ctx: &mut PaintCtx<'_>, canvas: &mut dyn Canvas) {
self.compute_projected(&ctx.cs, ctx.scratch);
if ctx.scratch.len() < 4 {
return;
}
canvas.stroke_polyline(ctx.scratch, self.stroke); canvas.stroke_polyline(ctx.scratch, self.stroke);
} }