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 ## 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. 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. 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 #### Combining Multiple Sprites
Multiple `sprite_id` values can be combined into one sprite with the same pattern as for tile 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: Martin data is available via the HTTP `GET` endpoints:
| URL | Description | | URL | Description |
|-----------------------------------------|------------------------------------------------| |------------------------------------------|------------------------------------------------|
| `/` | Web UI | | `/` | Web UI |
| `/catalog` | [List of all sources](#catalog) | | `/catalog` | [List of all sources](#catalog) |
| `/{sourceID}` | [Source TileJSON](#source-tilejson) | | `/{sourceID}` | [Source TileJSON](#source-tilejson) |
| `/{sourceID}/{z}/{x}/{y}` | Map Tiles | | `/{sourceID}/{z}/{x}/{y}` | Map Tiles |
| `/{source1},…,{sourceN}` | [Composite Source TileJSON](#source-tilejson) | | `/{source1},…,{sourceN}` | [Composite Source TileJSON](#source-tilejson) |
| `/{source1},…,{sourceN}/{z}/{x}/{y}` | [Composite Source Tiles](sources-composite.md) | | `/{source1},…,{sourceN}/{z}/{x}/{y}` | [Composite Source Tiles](sources-composite.md) |
| `/sprite/{spriteID}[@2x].{json,png}` | [Sprite sources](sources-sprites.md) | | `/sprite/{spriteID}[@2x].{json,png}` | [Sprite sources](sources-sprites.md) |
| `/font/{font}/{start}-{end}` | [Font source](sources-fonts.md) | | `/sdf_sprite/{spriteID}[@2x].{json,png}` | [SDF Sprite sources](sources-sprites.md) |
| `/font/{font1},…,{fontN}/{start}-{end}` | [Composite Font source](sources-fonts.md) | | `/font/{font}/{start}-{end}` | [Font source](sources-fonts.md) |
| `/health` | Martin server health check: returns 200 `OK` | | `/font/{font1},…,{fontN}/{start}-{end}` | [Composite Font source](sources-fonts.md) |
| `/health` | Martin server health check: returns 200 `OK` |
### Duplicate Source ID ### 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. /// 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. /// `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") { let (ids, dpi) = if let Some(ids) = ids.strip_suffix("@2x") {
(ids, 2) (ids, 2)
} else { } else {
@ -162,7 +162,7 @@ impl SpriteSources {
}) })
.collect::<SpriteResult<Vec<_>>>()?; .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, name: String,
path: PathBuf, path: PathBuf,
pixel_ratio: u8, pixel_ratio: u8,
as_sdf: bool,
) -> SpriteResult<(String, Sprite)> { ) -> SpriteResult<(String, Sprite)> {
let on_err = |e| SpriteError::IoError(e, path.clone()); 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()) let tree = Tree::from_data(&buffer, &Options::default())
.map_err(|e| SpriteParsingError(e, path.clone()))?; .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)) Ok((name, sprite))
} }
@ -194,6 +200,7 @@ async fn parse_sprite(
pub async fn get_spritesheet( pub async fn get_spritesheet(
sources: impl Iterator<Item = &SpriteSource>, sources: impl Iterator<Item = &SpriteSource>,
pixel_ratio: u8, pixel_ratio: u8,
as_sdf: bool,
) -> SpriteResult<Spritesheet> { ) -> SpriteResult<Spritesheet> {
// Asynchronously load all SVG files from the given sources // Asynchronously load all SVG files from the given sources
let mut futures = Vec::new(); let mut futures = Vec::new();
@ -203,11 +210,14 @@ pub async fn get_spritesheet(
for path in paths { for path in paths {
let name = sprite_name(&path, &source.path) let name = sprite_name(&path, &source.path)
.map_err(|e| SpriteProcessingError(e, source.path.clone()))?; .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 sprites = try_join_all(futures).await?;
let mut builder = SpritesheetBuilder::new(); let mut builder = SpritesheetBuilder::new();
if as_sdf {
builder.make_sdf();
}
builder.sprites(sprites.into_iter().collect()); builder.sprites(sprites.into_iter().collect());
// TODO: decide if this is needed and/or configurable // TODO: decide if this is needed and/or configurable
@ -234,24 +244,32 @@ mod tests {
let sprites = SpriteSources::resolve(&mut cfg).unwrap().0; let sprites = SpriteSources::resolve(&mut cfg).unwrap().0;
assert_eq!(sprites.len(), 2); assert_eq!(sprites.len(), 2);
test_src(sprites.values(), 1, "all_1").await; //.sdf => generate sdf from png, add sdf == true
test_src(sprites.values(), 2, "all_2").await; //- => 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(), 1, "src1_1", extension).await;
test_src(sprites.get("src1").into_iter(), 2, "src1_2").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(), 1, "src2_1", extension).await;
test_src(sprites.get("src2").into_iter(), 2, "src2_2").await; test_src(sprites.get("src2").into_iter(), 2, "src2_2", extension).await;
}
} }
async fn test_src( async fn test_src(
sources: impl Iterator<Item = &SpriteSource>, sources: impl Iterator<Item = &SpriteSource>,
pixel_ratio: u8, pixel_ratio: u8,
filename: &str, filename: &str,
extension: &str,
) { ) {
let path = PathBuf::from(format!("../tests/fixtures/sprites/expected/{filename}")); let path = PathBuf::from(format!(
"../tests/fixtures/sprites/expected/{filename}{extension}"
let sprites = get_spritesheet(sources, pixel_ratio).await.unwrap(); ));
let sprites = get_spritesheet(sources, pixel_ratio, extension == "_sdf")
.await
.unwrap();
let mut json = serde_json::to_string_pretty(sprites.get_index()).unwrap(); let mut json = serde_json::to_string_pretty(sprites.get_index()).unwrap();
json.push('\n'); json.push('\n');
let png = sprites.encode_png().unwrap(); 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); .service(get_tile);
#[cfg(feature = "sprites")] #[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); .service(crate::srv::sprites::get_sprite_png);
#[cfg(feature = "fonts")] #[cfg(feature = "fonts")]

View File

@ -15,7 +15,18 @@ async fn get_sprite_png(
path: Path<SourceIDsRequest>, path: Path<SourceIDsRequest>,
sprites: Data<SpriteSources>, sprites: Data<SpriteSources>,
) -> ActixResult<HttpResponse> { ) -> 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() Ok(HttpResponse::Ok()
.content_type(ContentType::png()) .content_type(ContentType::png())
.body(sheet.encode_png().map_err(map_internal_error)?)) .body(sheet.encode_png().map_err(map_internal_error)?))
@ -31,13 +42,31 @@ async fn get_sprite_json(
path: Path<SourceIDsRequest>, path: Path<SourceIDsRequest>,
sprites: Data<SpriteSources>, sprites: Data<SpriteSources>,
) -> ActixResult<HttpResponse> { ) -> ActixResult<HttpResponse> {
let sheet = get_sprite(&path, &sprites).await?; let sheet = get_sprite(&path, &sprites, false).await?;
Ok(HttpResponse::Ok().json(sheet.get_index())) 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 sprites
.get_sprites(&path.source_ids) .get_sprites(&path.source_ids, as_sdf)
.await .await
.map_err(|e| match e { .map_err(|e| match e {
SpriteError::SpriteNotFound(_) => ErrorNotFound(e.to_string()), 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_png pmt2_0_0_0 pmt2/0/0/0 # HTTP pmtiles
# Test sprites # Test sprites
test_jsn spr_src1 sprite/src1.json test_jsn spr_src1 sprite/src1.json
test_png spr_src1 sprite/src1.png test_jsn sdf_spr_src1 sdf_sprite/src1.json
test_jsn spr_src1_2x sprite/src1@2x.json test_png spr_src1 sprite/src1.png
test_png spr_src1_2x sprite/src1@2x.png test_png sdf_spr_src1 sdf_sprite/src1.png
test_jsn spr_mysrc sprite/mysrc.json test_jsn spr_src1_2x sprite/src1@2x.json
test_png spr_mysrc sprite/mysrc.png test_jsn sdf_spr_src1_ sdf_sprite/src1@2x.json
test_jsn spr_mysrc_2x sprite/mysrc@2x.json test_png spr_src1_2x sprite/src1@2x.png
test_png spr_mysrc_2x sprite/mysrc@2x.png test_png sdf_spr_src1_ sdf_sprite/src1@2x.png
test_jsn spr_cmp sprite/src1,mysrc.json test_jsn spr_mysrc sprite/mysrc.json
test_png spr_cmp sprite/src1,mysrc.png test_jsn sdf_spr_mysrc sdf_sprite/mysrc.json
test_jsn spr_cmp_2x sprite/src1,mysrc@2x.json test_png spr_mysrc sprite/mysrc.png
test_png spr_cmp_2x sprite/src1,mysrc@2x.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 fonts
test_font font_1 font/Overpass%20Mono%20Light/0-255 test_font font_1 font/Overpass%20Mono%20Light/0-255