Add sdf sprite support with /sdf_sprite/... endpoint (#1492)

Given a set of SVGs as a sprite source X, Martin will now generate two endpoints: `/sprite/X` and `/sdf_sprite/X`, with the second endpoint serving [signed distance field](https://en.wikipedia.org/wiki/Signed_distance_function) images.

Closes #1075
This commit is contained in:
Frank Elsinga 2024-10-22 18:49:36 +02:00 committed by GitHub
parent 763f626b2c
commit b134cb2bc6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
33 changed files with 394 additions and 43 deletions

View File

@ -1,6 +1,7 @@
## Sprite Sources
Given a directory with SVG images, Martin will generate a sprite -- a JSON index and a PNG image, for both low and highresolution displays. The SVG filenames without extension will be used as the sprites' image IDs (remember that one sprite and thus `sprite_id` contains multiple images).
Given a directory with SVG images, Martin will generate a sprite -- a JSON index and a PNG image, for both low and highresolution displays.
The SVG filenames without extension will be used as the sprites' image IDs (remember that one sprite and thus `sprite_id` contains multiple images).
The images are searched recursively in the given directory, so subdirectory names will be used as prefixes for the image IDs.
For example `icons/bicycle.svg` will be available as `icons/bicycle` sprite image.
@ -40,6 +41,19 @@ the PNG, there is a high DPI version available at `/sprite/<sprite_id>@2x.json`.
}
```
##### Coloring at runtime via Signed Distance Fields (SDFs)
If you want to set the color of a sprite at runtime, you will need use the [Signed Distance Fields (SDFs)](https://steamcdn-a.akamaihd.net/apps/valve/2007/SIGGRAPH2007_AlphaTestedMagnification.pdf)-endpoints.
For example, maplibre does support the image being modified via the [`icon-color`](https://maplibre.org/maplibre-style-spec/layers/#icon-color) and [`icon-halo-color`](https://maplibre.org/maplibre-style-spec/layers/#icon-halo-color) properties if using SDFs.
SDFs have the significant **downside of only allowing one color**.
If you want multiple colors, you will need to layer icons on top of each other.
The following APIs are available:
- `/sdf_sprite/<sprite_id>.json` for getting a sprite index as SDF and
- `/sdf_sprite/<sprite_id>.png` for getting sprite PNGs as SDF
#### Combining Multiple Sprites
Multiple `sprite_id` values can be combined into one sprite with the same pattern as for tile

View File

@ -2,18 +2,19 @@
Martin data is available via the HTTP `GET` endpoints:
| URL | Description |
|-----------------------------------------|------------------------------------------------|
| `/` | Web UI |
| `/catalog` | [List of all sources](#catalog) |
| `/{sourceID}` | [Source TileJSON](#source-tilejson) |
| `/{sourceID}/{z}/{x}/{y}` | Map Tiles |
| `/{source1},…,{sourceN}` | [Composite Source TileJSON](#source-tilejson) |
| `/{source1},…,{sourceN}/{z}/{x}/{y}` | [Composite Source Tiles](sources-composite.md) |
| `/sprite/{spriteID}[@2x].{json,png}` | [Sprite sources](sources-sprites.md) |
| `/font/{font}/{start}-{end}` | [Font source](sources-fonts.md) |
| `/font/{font1},…,{fontN}/{start}-{end}` | [Composite Font source](sources-fonts.md) |
| `/health` | Martin server health check: returns 200 `OK` |
| URL | Description |
|------------------------------------------|------------------------------------------------|
| `/` | Web UI |
| `/catalog` | [List of all sources](#catalog) |
| `/{sourceID}` | [Source TileJSON](#source-tilejson) |
| `/{sourceID}/{z}/{x}/{y}` | Map Tiles |
| `/{source1},…,{sourceN}` | [Composite Source TileJSON](#source-tilejson) |
| `/{source1},…,{sourceN}/{z}/{x}/{y}` | [Composite Source Tiles](sources-composite.md) |
| `/sprite/{spriteID}[@2x].{json,png}` | [Sprite sources](sources-sprites.md) |
| `/sdf_sprite/{spriteID}[@2x].{json,png}` | [SDF Sprite sources](sources-sprites.md) |
| `/font/{font}/{start}-{end}` | [Font source](sources-fonts.md) |
| `/font/{font1},…,{fontN}/{start}-{end}` | [Composite Font source](sources-fonts.md) |
| `/health` | Martin server health check: returns 200 `OK` |
### Duplicate Source ID

View File

@ -146,7 +146,7 @@ impl SpriteSources {
/// Given a list of IDs in a format "id1,id2,id3", return a spritesheet with them all.
/// `ids` may optionally end with "@2x" to request a high-DPI spritesheet.
pub async fn get_sprites(&self, ids: &str) -> SpriteResult<Spritesheet> {
pub async fn get_sprites(&self, ids: &str, as_sdf: bool) -> SpriteResult<Spritesheet> {
let (ids, dpi) = if let Some(ids) = ids.strip_suffix("@2x") {
(ids, 2)
} else {
@ -162,7 +162,7 @@ impl SpriteSources {
})
.collect::<SpriteResult<Vec<_>>>()?;
get_spritesheet(sprite_ids.into_iter(), dpi).await
get_spritesheet(sprite_ids.into_iter(), dpi, as_sdf).await
}
}
@ -175,6 +175,7 @@ async fn parse_sprite(
name: String,
path: PathBuf,
pixel_ratio: u8,
as_sdf: bool,
) -> SpriteResult<(String, Sprite)> {
let on_err = |e| SpriteError::IoError(e, path.clone());
@ -186,7 +187,12 @@ async fn parse_sprite(
let tree = Tree::from_data(&buffer, &Options::default())
.map_err(|e| SpriteParsingError(e, path.clone()))?;
let sprite = Sprite::new(tree, pixel_ratio).ok_or_else(|| SpriteInstError(path.clone()))?;
let sprite = if as_sdf {
Sprite::new_sdf(tree, pixel_ratio)
} else {
Sprite::new(tree, pixel_ratio)
};
let sprite = sprite.ok_or_else(|| SpriteInstError(path.clone()))?;
Ok((name, sprite))
}
@ -194,6 +200,7 @@ async fn parse_sprite(
pub async fn get_spritesheet(
sources: impl Iterator<Item = &SpriteSource>,
pixel_ratio: u8,
as_sdf: bool,
) -> SpriteResult<Spritesheet> {
// Asynchronously load all SVG files from the given sources
let mut futures = Vec::new();
@ -203,11 +210,14 @@ pub async fn get_spritesheet(
for path in paths {
let name = sprite_name(&path, &source.path)
.map_err(|e| SpriteProcessingError(e, source.path.clone()))?;
futures.push(parse_sprite(name, path, pixel_ratio));
futures.push(parse_sprite(name, path, pixel_ratio, as_sdf));
}
}
let sprites = try_join_all(futures).await?;
let mut builder = SpritesheetBuilder::new();
if as_sdf {
builder.make_sdf();
}
builder.sprites(sprites.into_iter().collect());
// TODO: decide if this is needed and/or configurable
@ -234,24 +244,32 @@ mod tests {
let sprites = SpriteSources::resolve(&mut cfg).unwrap().0;
assert_eq!(sprites.len(), 2);
test_src(sprites.values(), 1, "all_1").await;
test_src(sprites.values(), 2, "all_2").await;
//.sdf => generate sdf from png, add sdf == true
//- => does not generate sdf, omits sdf == true
for extension in ["_sdf", ""] {
test_src(sprites.values(), 1, "all_1", extension).await;
test_src(sprites.values(), 2, "all_2", extension).await;
test_src(sprites.get("src1").into_iter(), 1, "src1_1").await;
test_src(sprites.get("src1").into_iter(), 2, "src1_2").await;
test_src(sprites.get("src1").into_iter(), 1, "src1_1", extension).await;
test_src(sprites.get("src1").into_iter(), 2, "src1_2", extension).await;
test_src(sprites.get("src2").into_iter(), 1, "src2_1").await;
test_src(sprites.get("src2").into_iter(), 2, "src2_2").await;
test_src(sprites.get("src2").into_iter(), 1, "src2_1", extension).await;
test_src(sprites.get("src2").into_iter(), 2, "src2_2", extension).await;
}
}
async fn test_src(
sources: impl Iterator<Item = &SpriteSource>,
pixel_ratio: u8,
filename: &str,
extension: &str,
) {
let path = PathBuf::from(format!("../tests/fixtures/sprites/expected/{filename}"));
let sprites = get_spritesheet(sources, pixel_ratio).await.unwrap();
let path = PathBuf::from(format!(
"../tests/fixtures/sprites/expected/{filename}{extension}"
));
let sprites = get_spritesheet(sources, pixel_ratio, extension == "_sdf")
.await
.unwrap();
let mut json = serde_json::to_string_pretty(sprites.get_index()).unwrap();
json.push('\n');
let png = sprites.encode_png().unwrap();

View File

@ -114,7 +114,9 @@ pub fn router(cfg: &mut web::ServiceConfig, #[allow(unused_variables)] usr_cfg:
.service(get_tile);
#[cfg(feature = "sprites")]
cfg.service(crate::srv::sprites::get_sprite_json)
cfg.service(crate::srv::sprites::get_sprite_sdf_json)
.service(crate::srv::sprites::get_sprite_json)
.service(crate::srv::sprites::get_sprite_sdf_png)
.service(crate::srv::sprites::get_sprite_png);
#[cfg(feature = "fonts")]

View File

@ -15,7 +15,18 @@ async fn get_sprite_png(
path: Path<SourceIDsRequest>,
sprites: Data<SpriteSources>,
) -> ActixResult<HttpResponse> {
let sheet = get_sprite(&path, &sprites).await?;
let sheet = get_sprite(&path, &sprites, false).await?;
Ok(HttpResponse::Ok()
.content_type(ContentType::png())
.body(sheet.encode_png().map_err(map_internal_error)?))
}
#[route("/sdf_sprite/{source_ids}.png", method = "GET", method = "HEAD")]
async fn get_sprite_sdf_png(
path: Path<SourceIDsRequest>,
sprites: Data<SpriteSources>,
) -> ActixResult<HttpResponse> {
let sheet = get_sprite(&path, &sprites, true).await?;
Ok(HttpResponse::Ok()
.content_type(ContentType::png())
.body(sheet.encode_png().map_err(map_internal_error)?))
@ -31,13 +42,31 @@ async fn get_sprite_json(
path: Path<SourceIDsRequest>,
sprites: Data<SpriteSources>,
) -> ActixResult<HttpResponse> {
let sheet = get_sprite(&path, &sprites).await?;
let sheet = get_sprite(&path, &sprites, false).await?;
Ok(HttpResponse::Ok().json(sheet.get_index()))
}
async fn get_sprite(path: &SourceIDsRequest, sprites: &SpriteSources) -> ActixResult<Spritesheet> {
#[route(
"/sdf_sprite/{source_ids}.json",
method = "GET",
method = "HEAD",
wrap = "middleware::Compress::default()"
)]
async fn get_sprite_sdf_json(
path: Path<SourceIDsRequest>,
sprites: Data<SpriteSources>,
) -> ActixResult<HttpResponse> {
let sheet = get_sprite(&path, &sprites, true).await?;
Ok(HttpResponse::Ok().json(sheet.get_index()))
}
async fn get_sprite(
path: &SourceIDsRequest,
sprites: &SpriteSources,
as_sdf: bool,
) -> ActixResult<Spritesheet> {
sprites
.get_sprites(&path.source_ids)
.get_sprites(&path.source_ids, as_sdf)
.await
.map_err(|e| match e {
SpriteError::SpriteNotFound(_) => ErrorNotFound(e.to_string()),

View File

@ -0,0 +1,34 @@
{
"another_bicycle": {
"height": 21,
"pixelRatio": 1,
"width": 21,
"x": 26,
"y": 22,
"sdf": true
},
"bear": {
"height": 22,
"pixelRatio": 1,
"width": 22,
"x": 26,
"y": 0,
"sdf": true
},
"bicycle": {
"height": 21,
"pixelRatio": 1,
"width": 21,
"x": 0,
"y": 26,
"sdf": true
},
"sub/circle": {
"height": 26,
"pixelRatio": 1,
"width": 26,
"x": 0,
"y": 0,
"sdf": true
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 945 B

View File

@ -0,0 +1 @@
tests/output/configured/sdf_spr_cmp.png: PNG image data, 48 x 47, 8-bit gray+alpha, non-interlaced

View File

@ -0,0 +1,34 @@
{
"another_bicycle": {
"height": 36,
"pixelRatio": 2,
"width": 36,
"x": 84,
"y": 0,
"sdf": true
},
"bear": {
"height": 38,
"pixelRatio": 2,
"width": 38,
"x": 46,
"y": 0,
"sdf": true
},
"bicycle": {
"height": 36,
"pixelRatio": 2,
"width": 36,
"x": 84,
"y": 36,
"sdf": true
},
"sub/circle": {
"height": 46,
"pixelRatio": 2,
"width": 46,
"x": 0,
"y": 0,
"sdf": true
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@ -0,0 +1 @@
tests/output/configured/sdf_spr_cmp_2.png: PNG image data, 120 x 72, 8-bit gray+alpha, non-interlaced

View File

@ -0,0 +1,10 @@
{
"bicycle": {
"height": 36,
"pixelRatio": 2,
"width": 36,
"x": 0,
"y": 0,
"sdf": true
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 675 B

View File

@ -0,0 +1 @@
tests/output/configured/sdf_spr_mysrc.png: PNG image data, 36 x 36, 8-bit gray+alpha, non-interlaced

View File

@ -0,0 +1,26 @@
{
"another_bicycle": {
"height": 21,
"pixelRatio": 1,
"width": 21,
"x": 26,
"y": 22,
"sdf": true
},
"bear": {
"height": 22,
"pixelRatio": 1,
"width": 22,
"x": 26,
"y": 0,
"sdf": true
},
"sub/circle": {
"height": 26,
"pixelRatio": 1,
"width": 26,
"x": 0,
"y": 0,
"sdf": true
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 900 B

View File

@ -0,0 +1 @@
tests/output/configured/sdf_spr_src1.png: PNG image data, 48 x 43, 8-bit gray+alpha, non-interlaced

View File

@ -0,0 +1,26 @@
{
"another_bicycle": {
"height": 36,
"pixelRatio": 2,
"width": 36,
"x": 84,
"y": 0,
"sdf": true
},
"bear": {
"height": 38,
"pixelRatio": 2,
"width": 38,
"x": 46,
"y": 0,
"sdf": true
},
"sub/circle": {
"height": 46,
"pixelRatio": 2,
"width": 46,
"x": 0,
"y": 0,
"sdf": true
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@ -0,0 +1 @@
tests/output/configured/sdf_spr_src1_.png: PNG image data, 120 x 46, 8-bit gray+alpha, non-interlaced

View File

@ -0,0 +1,34 @@
{
"another_bicycle": {
"height": 21,
"pixelRatio": 1,
"width": 21,
"x": 26,
"y": 22,
"sdf": true
},
"bear": {
"height": 22,
"pixelRatio": 1,
"width": 22,
"x": 26,
"y": 0,
"sdf": true
},
"bicycle": {
"height": 21,
"pixelRatio": 1,
"width": 21,
"x": 0,
"y": 26,
"sdf": true
},
"sub/circle": {
"height": 26,
"pixelRatio": 1,
"width": 26,
"x": 0,
"y": 0,
"sdf": true
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 945 B

View File

@ -0,0 +1,34 @@
{
"another_bicycle": {
"height": 36,
"pixelRatio": 2,
"width": 36,
"x": 84,
"y": 0,
"sdf": true
},
"bear": {
"height": 38,
"pixelRatio": 2,
"width": 38,
"x": 46,
"y": 0,
"sdf": true
},
"bicycle": {
"height": 36,
"pixelRatio": 2,
"width": 36,
"x": 84,
"y": 36,
"sdf": true
},
"sub/circle": {
"height": 46,
"pixelRatio": 2,
"width": 46,
"x": 0,
"y": 0,
"sdf": true
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@ -0,0 +1,26 @@
{
"another_bicycle": {
"height": 21,
"pixelRatio": 1,
"width": 21,
"x": 26,
"y": 22,
"sdf": true
},
"bear": {
"height": 22,
"pixelRatio": 1,
"width": 22,
"x": 26,
"y": 0,
"sdf": true
},
"sub/circle": {
"height": 26,
"pixelRatio": 1,
"width": 26,
"x": 0,
"y": 0,
"sdf": true
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 900 B

View File

@ -0,0 +1,26 @@
{
"another_bicycle": {
"height": 36,
"pixelRatio": 2,
"width": 36,
"x": 84,
"y": 0,
"sdf": true
},
"bear": {
"height": 38,
"pixelRatio": 2,
"width": 38,
"x": 46,
"y": 0,
"sdf": true
},
"sub/circle": {
"height": 46,
"pixelRatio": 2,
"width": 46,
"x": 0,
"y": 0,
"sdf": true
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@ -0,0 +1,10 @@
{
"bicycle": {
"height": 21,
"pixelRatio": 1,
"width": 21,
"x": 0,
"y": 0,
"sdf": true
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 377 B

View File

@ -0,0 +1,10 @@
{
"bicycle": {
"height": 36,
"pixelRatio": 2,
"width": 36,
"x": 0,
"y": 0,
"sdf": true
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 675 B

View File

@ -351,18 +351,30 @@ test_png pmt_0_0_0 pmt/0/0/0
test_png pmt2_0_0_0 pmt2/0/0/0 # HTTP pmtiles
# Test sprites
test_jsn spr_src1 sprite/src1.json
test_png spr_src1 sprite/src1.png
test_jsn spr_src1_2x sprite/src1@2x.json
test_png spr_src1_2x sprite/src1@2x.png
test_jsn spr_mysrc sprite/mysrc.json
test_png spr_mysrc sprite/mysrc.png
test_jsn spr_mysrc_2x sprite/mysrc@2x.json
test_png spr_mysrc_2x sprite/mysrc@2x.png
test_jsn spr_cmp sprite/src1,mysrc.json
test_png spr_cmp sprite/src1,mysrc.png
test_jsn spr_cmp_2x sprite/src1,mysrc@2x.json
test_png spr_cmp_2x sprite/src1,mysrc@2x.png
test_jsn spr_src1 sprite/src1.json
test_jsn sdf_spr_src1 sdf_sprite/src1.json
test_png spr_src1 sprite/src1.png
test_png sdf_spr_src1 sdf_sprite/src1.png
test_jsn spr_src1_2x sprite/src1@2x.json
test_jsn sdf_spr_src1_ sdf_sprite/src1@2x.json
test_png spr_src1_2x sprite/src1@2x.png
test_png sdf_spr_src1_ sdf_sprite/src1@2x.png
test_jsn spr_mysrc sprite/mysrc.json
test_jsn sdf_spr_mysrc sdf_sprite/mysrc.json
test_png spr_mysrc sprite/mysrc.png
test_png sdf_spr_mysrc sdf_sprite/mysrc.png
test_jsn spr_mysrc_2x sprite/mysrc@2x.json
test_jsn sdf_spr_mysrc sdf_sprite/mysrc@2x.json
test_png spr_mysrc_2x sprite/mysrc@2x.png
test_png sdf_spr_mysrc sdf_sprite/mysrc@2x.png
test_jsn spr_cmp sprite/src1,mysrc.json
test_jsn sdf_spr_cmp sdf_sprite/src1,mysrc.json
test_png spr_cmp sprite/src1,mysrc.png
test_png sdf_spr_cmp sdf_sprite/src1,mysrc.png
test_jsn spr_cmp_2x sprite/src1,mysrc@2x.json
test_jsn sdf_spr_cmp_2 sdf_sprite/src1,mysrc@2x.json
test_png spr_cmp_2x sprite/src1,mysrc@2x.png
test_png sdf_spr_cmp_2 sdf_sprite/src1,mysrc@2x.png
# Test fonts
test_font font_1 font/Overpass%20Mono%20Light/0-255