Skip to content

Commit be09578

Browse files
committed
Fix spiky polygons
1 parent ff1411d commit be09578

2 files changed

Lines changed: 241 additions & 45 deletions

File tree

src/map/mapvas_egui/layer/geometry_highlighting.rs

Lines changed: 136 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,17 @@ use crate::map::{
33
geometry_collection::{DEFAULT_STYLE, Geometry, Metadata},
44
};
55
use egui::{
6-
Color32, Painter, Shape, Stroke,
6+
Color32, ColorImage, Painter, Pos2, Rect, Shape, Stroke,
77
epaint::{CircleShape, PathShape, PathStroke},
88
};
99
use std::collections::HashMap;
10+
use tiny_skia::{
11+
FillRule, LineCap, LineJoin, Paint, PathBuilder, Pixmap, Stroke as SkiaStroke,
12+
Transform as SkiaTransform,
13+
};
14+
15+
/// Bold highlight stroke width, in screen pixels.
16+
const HIGHLIGHT_STROKE_WIDTH: f32 = 6.0;
1017

1118
/// Manages geometry highlighting with unique IDs
1219
pub struct GeometryHighlighter {
@@ -118,18 +125,15 @@ pub fn draw_highlighted_geometry(
118125
Geometry::LineString(coords, metadata) => {
119126
draw_highlighted_linestring(coords, metadata, painter, transform);
120127
}
121-
Geometry::Polygon(coords, metadata) => {
122-
draw_highlighted_polygon(coords, metadata, painter, transform);
128+
Geometry::Polygon(_, _) | Geometry::Heatmap(_, _) => {
129+
// Polygons render via tiny-skia (`rasterize_highlighted_polygons`);
130+
// heatmaps are not individually highlightable.
123131
}
124132
Geometry::GeometryCollection(geometries, _) => {
125-
// Draw all geometries in the collection as highlighted
126133
for nested_geometry in geometries {
127134
draw_highlighted_geometry(nested_geometry, painter, transform, false);
128135
}
129136
}
130-
Geometry::Heatmap(_, _) => {
131-
// Heatmaps are not individually highlightable.
132-
}
133137
}
134138
}
135139

@@ -175,25 +179,132 @@ fn draw_highlighted_linestring(
175179
painter.add(shape);
176180
}
177181

178-
/// Draw a highlighted polygon
179-
fn draw_highlighted_polygon(
180-
coords: &[PixelCoordinate],
181-
metadata: &Metadata,
182-
painter: &Painter,
182+
/// Rasterize all polygons inside `geometry` (recursively) into a pixmap
183+
/// clipped to `viewport`, drawing each polygon's fill and bold solid stroke
184+
/// via tiny-skia. egui can't draw these correctly: its fan-triangulator
185+
/// produces fold-overs on concave/bridged polygons, and its `PathStroke` has
186+
/// no `LineJoin` control so sharp angles produce miter spikes. tiny-skia
187+
/// uses winding-rule fill and `LineJoin::Round`, so neither happens.
188+
pub fn rasterize_highlighted_polygons(
189+
geometry: &Geometry<PixelCoordinate>,
183190
transform: &Transform,
184-
) {
185-
let style = metadata.style.as_ref().unwrap_or(&DEFAULT_STYLE);
186-
let base_color = style.color();
191+
viewport: Rect,
192+
) -> Option<(ColorImage, Rect)> {
193+
let mut bbox: Option<Rect> = None;
194+
collect_polygon_bbox(geometry, transform, &mut bbox);
195+
// Pad for the stroke half-width plus a feathering margin.
196+
let pad = HIGHLIGHT_STROKE_WIDTH * 0.5 + 1.0;
197+
let bbox = bbox?.expand(pad);
198+
let clipped = bbox.intersect(viewport);
199+
if clipped.width() <= 0.0 || clipped.height() <= 0.0 {
200+
return None;
201+
}
202+
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
203+
let w = clipped.width().ceil() as u32;
204+
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
205+
let h = clipped.height().ceil() as u32;
206+
if w == 0 || h == 0 {
207+
return None;
208+
}
209+
let mut pixmap = Pixmap::new(w, h)?;
210+
fill_polygons_into_pixmap(geometry, transform, &mut pixmap, clipped.min);
187211

188-
// Draw highlighted polygon with solid colors - fill matches stroke color
189-
let highlight_stroke = base_color; // Solid stroke in original color
190-
let highlight_fill = base_color; // Fill always matches stroke color
212+
// tiny-skia stores premultiplied RGBA; un-premultiply for ColorImage.
213+
let mut straight = Vec::with_capacity(pixmap.data().len());
214+
for p in pixmap.data().chunks_exact(4) {
215+
let a = p[3];
216+
if a == 0 {
217+
straight.extend_from_slice(&[0, 0, 0, 0]);
218+
} else {
219+
let inv = 255.0_f32 / f32::from(a);
220+
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
221+
{
222+
straight.push((f32::from(p[0]) * inv).min(255.0) as u8);
223+
straight.push((f32::from(p[1]) * inv).min(255.0) as u8);
224+
straight.push((f32::from(p[2]) * inv).min(255.0) as u8);
225+
}
226+
straight.push(a);
227+
}
228+
}
229+
Some((
230+
ColorImage::from_rgba_unmultiplied([w as usize, h as usize], &straight),
231+
clipped,
232+
))
233+
}
191234

192-
let shape = Shape::Path(PathShape {
193-
points: coords.iter().map(|c| transform.apply(*c).into()).collect(),
194-
closed: true,
195-
fill: highlight_fill,
196-
stroke: PathStroke::new(6.0, highlight_stroke),
197-
});
198-
painter.add(shape);
235+
fn collect_polygon_bbox(
236+
geometry: &Geometry<PixelCoordinate>,
237+
transform: &Transform,
238+
acc: &mut Option<Rect>,
239+
) {
240+
match geometry {
241+
Geometry::Polygon(coords, _) => {
242+
for c in coords {
243+
let p: Pos2 = transform.apply(*c).into();
244+
let r = Rect::from_min_max(p, p);
245+
*acc = Some(acc.map_or(r, |a| a.union(r)));
246+
}
247+
}
248+
Geometry::GeometryCollection(geoms, _) => {
249+
for g in geoms {
250+
collect_polygon_bbox(g, transform, acc);
251+
}
252+
}
253+
_ => {}
254+
}
255+
}
256+
257+
fn fill_polygons_into_pixmap(
258+
geometry: &Geometry<PixelCoordinate>,
259+
transform: &Transform,
260+
pixmap: &mut Pixmap,
261+
origin: Pos2,
262+
) {
263+
match geometry {
264+
Geometry::Polygon(coords, metadata) => {
265+
let style = metadata.style.as_ref().unwrap_or(&DEFAULT_STYLE);
266+
let color = style.color();
267+
let mut iter = coords.iter();
268+
let Some(first) = iter.next() else { return };
269+
let mut pb = PathBuilder::new();
270+
let p: Pos2 = transform.apply(*first).into();
271+
pb.move_to(p.x - origin.x, p.y - origin.y);
272+
for c in iter {
273+
let p: Pos2 = transform.apply(*c).into();
274+
pb.line_to(p.x - origin.x, p.y - origin.y);
275+
}
276+
pb.close();
277+
let Some(path) = pb.finish() else { return };
278+
let mut paint = Paint::default();
279+
paint.set_color(tiny_skia::Color::from_rgba8(
280+
color.r(),
281+
color.g(),
282+
color.b(),
283+
color.a(),
284+
));
285+
paint.anti_alias = true;
286+
pixmap.fill_path(
287+
&path,
288+
&paint,
289+
FillRule::Winding,
290+
SkiaTransform::identity(),
291+
None,
292+
);
293+
// Bold solid stroke with round joins to suppress miter spikes on
294+
// bridged-multipolygon path reversals.
295+
let stroke = SkiaStroke {
296+
width: HIGHLIGHT_STROKE_WIDTH,
297+
line_cap: LineCap::Round,
298+
line_join: LineJoin::Round,
299+
..SkiaStroke::default()
300+
};
301+
pixmap.stroke_path(&path, &paint, &stroke, SkiaTransform::identity(), None);
302+
}
303+
Geometry::GeometryCollection(geoms, _) => {
304+
for g in geoms {
305+
fill_polygons_into_pixmap(g, transform, pixmap, origin);
306+
}
307+
}
308+
_ => {}
309+
}
199310
}

src/map/mapvas_egui/layer/shape_layer.rs

Lines changed: 105 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,23 @@ pub struct ShapeLayer {
106106
/// Shared shape info cache that the HTTP endpoint reads directly.
107107
shape_info:
108108
Arc<std::sync::RwLock<std::collections::HashMap<String, Vec<crate::remote::ShapeInfo>>>>,
109+
/// Cached pixmap+texture for the polygon fills of the currently highlighted
110+
/// geometry. Avoids re-rasterizing every frame on a static hover.
111+
highlight_texture: Option<HighlightTextureCache>,
112+
}
113+
114+
#[derive(PartialEq, Eq)]
115+
struct HighlightCacheKey {
116+
geometry_path: (String, usize, Vec<usize>),
117+
viewport: [u32; 4],
118+
transform: [u32; 3],
119+
version: u64,
120+
}
121+
122+
struct HighlightTextureCache {
123+
key: HighlightCacheKey,
124+
texture: egui::TextureHandle,
125+
screen_rect: egui::Rect,
109126
}
110127

111128
fn truncate_label_by_width(ui: &egui::Ui, label: &str, available_width: f32) -> (String, bool) {
@@ -303,6 +320,7 @@ impl ShapeLayer {
303320
shape_vis_receiver,
304321
shape_vis_sender,
305322
shape_info,
323+
highlight_texture: None,
306324
}
307325
}
308326

@@ -1771,6 +1789,88 @@ impl ShapeLayer {
17711789
draw_highlighted_geometry(geometry, painter, transform, false);
17721790
}
17731791

1792+
/// Render the hover-highlight for the currently selected geometry.
1793+
/// Polygon fills are rasterized via tiny-skia and cached as a texture;
1794+
/// strokes/points/lines are added as egui shapes.
1795+
fn draw_highlight_overlay(&mut self, ui: &mut egui::Ui, transform: &Transform, rect: Rect) {
1796+
let Some((layer_id, shape_idx, nested_path)) =
1797+
self.geometry_highlighter.get_highlighted_geometry()
1798+
else {
1799+
self.highlight_texture = None;
1800+
return;
1801+
};
1802+
if !nested_path.is_empty() {
1803+
// The render loop only handles top-level highlights; preserve that.
1804+
self.highlight_texture = None;
1805+
return;
1806+
}
1807+
if !*self.layer_visibility.get(&layer_id).unwrap_or(&true) {
1808+
self.highlight_texture = None;
1809+
return;
1810+
}
1811+
if !*self
1812+
.geometry_visibility
1813+
.get(&(layer_id.clone(), shape_idx))
1814+
.unwrap_or(&true)
1815+
{
1816+
self.highlight_texture = None;
1817+
return;
1818+
}
1819+
let Some(shape) = self
1820+
.shape_map
1821+
.get(&layer_id)
1822+
.and_then(|s| s.get(shape_idx))
1823+
.cloned()
1824+
else {
1825+
self.highlight_texture = None;
1826+
return;
1827+
};
1828+
1829+
let key = HighlightCacheKey {
1830+
geometry_path: (layer_id, shape_idx, nested_path),
1831+
viewport: [
1832+
rect.min.x.to_bits(),
1833+
rect.min.y.to_bits(),
1834+
rect.max.x.to_bits(),
1835+
rect.max.y.to_bits(),
1836+
],
1837+
transform: [
1838+
transform.zoom.to_bits(),
1839+
transform.trans.x.to_bits(),
1840+
transform.trans.y.to_bits(),
1841+
],
1842+
version: self.version,
1843+
};
1844+
1845+
let needs_rebuild = self.highlight_texture.as_ref().is_none_or(|c| c.key != key);
1846+
if needs_rebuild {
1847+
use super::geometry_highlighting::rasterize_highlighted_polygons;
1848+
if let Some((image, screen_rect)) = rasterize_highlighted_polygons(&shape, transform, rect) {
1849+
let handle =
1850+
ui.ctx()
1851+
.load_texture("highlight_polygon", image, egui::TextureOptions::LINEAR);
1852+
self.highlight_texture = Some(HighlightTextureCache {
1853+
key,
1854+
texture: handle,
1855+
screen_rect,
1856+
});
1857+
} else {
1858+
self.highlight_texture = None;
1859+
}
1860+
}
1861+
1862+
let painter = ui.painter_at(rect);
1863+
if let Some(cache) = &self.highlight_texture {
1864+
painter.image(
1865+
cache.texture.id(),
1866+
cache.screen_rect,
1867+
Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)),
1868+
Color32::WHITE,
1869+
);
1870+
}
1871+
Self::draw_highlighted_geometry(&shape, &painter, transform, false);
1872+
}
1873+
17741874
fn show_delete_collection_button(
17751875
&mut self,
17761876
ui: &mut egui::Ui,
@@ -2322,26 +2422,10 @@ impl Layer for ShapeLayer {
23222422
}
23232423
}
23242424

2325-
// Draw highlighted geometries on top using egui shapes (per-frame, follows mouse).
2326-
for (layer_id, shapes) in &self.shape_map {
2327-
if !*self.layer_visibility.get(layer_id).unwrap_or(&true) {
2328-
continue;
2329-
}
2330-
for (shape_idx, shape) in shapes.iter().enumerate() {
2331-
let geometry_key = (layer_id.clone(), shape_idx);
2332-
if !*self.geometry_visibility.get(&geometry_key).unwrap_or(&true) {
2333-
continue;
2334-
}
2335-
let highlight_key = (layer_id.clone(), shape_idx, Vec::new());
2336-
if self.geometry_highlighter.is_highlighted(
2337-
&highlight_key.0,
2338-
highlight_key.1,
2339-
&highlight_key.2,
2340-
) {
2341-
Self::draw_highlighted_geometry(shape, ui.painter(), transform, false);
2342-
}
2343-
}
2344-
}
2425+
// Draw highlight overlay for the currently-hovered geometry.
2426+
// Polygon fills go through tiny-skia (cached as a texture) to avoid egui's
2427+
// fan-triangulation; points, lines, and polygon strokes go through egui.
2428+
self.draw_highlight_overlay(ui, transform, rect);
23452429

23462430
// Handle pending detail popup from double-click as lightweight positioned window
23472431
// This needs to be in draw() so it shows regardless of sidebar state
@@ -3238,6 +3322,7 @@ mod tests {
32383322
s
32393323
},
32403324
shape_info: Arc::new(std::sync::RwLock::new(std::collections::HashMap::new())),
3325+
highlight_texture: None,
32413326
}
32423327
}
32433328
}

0 commit comments

Comments
 (0)