Skip to content

Commit

Permalink
Add sdf sprite support with /sdf_sprite/... endpoint (#1492)
Browse files Browse the repository at this point in the history
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
  • Loading branch information
CommanderStorm authored Oct 22, 2024
1 parent 763f626 commit b134cb2
Show file tree
Hide file tree
Showing 33 changed files with 394 additions and 43 deletions.
16 changes: 15 additions & 1 deletion docs/src/sources-sprites.md
Original file line number Diff line number Diff line change
@@ -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.

Expand Down Expand Up @@ -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
Expand Down
25 changes: 13 additions & 12 deletions docs/src/using.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
44 changes: 31 additions & 13 deletions martin/src/sprites/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
}
}

Expand All @@ -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());

Expand All @@ -186,14 +187,20 @@ 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))
}

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();
Expand All @@ -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
Expand All @@ -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();
Expand Down
4 changes: 3 additions & 1 deletion martin/src/srv/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand Down
37 changes: 33 additions & 4 deletions martin/src/srv/sprites.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)?))
Expand All @@ -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()))
}

#[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) -> ActixResult<Spritesheet> {
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()),
Expand Down
34 changes: 34 additions & 0 deletions tests/expected/configured/sdf_spr_cmp.json
Original file line number Diff line number Diff line change
@@ -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 added tests/expected/configured/sdf_spr_cmp.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions tests/expected/configured/sdf_spr_cmp.png.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
tests/output/configured/sdf_spr_cmp.png: PNG image data, 48 x 47, 8-bit gray+alpha, non-interlaced
34 changes: 34 additions & 0 deletions tests/expected/configured/sdf_spr_cmp_2.json
Original file line number Diff line number Diff line change
@@ -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 added tests/expected/configured/sdf_spr_cmp_2.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions tests/expected/configured/sdf_spr_cmp_2.png.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
tests/output/configured/sdf_spr_cmp_2.png: PNG image data, 120 x 72, 8-bit gray+alpha, non-interlaced
10 changes: 10 additions & 0 deletions tests/expected/configured/sdf_spr_mysrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"bicycle": {
"height": 36,
"pixelRatio": 2,
"width": 36,
"x": 0,
"y": 0,
"sdf": true
}
}
Binary file added tests/expected/configured/sdf_spr_mysrc.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions tests/expected/configured/sdf_spr_mysrc.png.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
tests/output/configured/sdf_spr_mysrc.png: PNG image data, 36 x 36, 8-bit gray+alpha, non-interlaced
26 changes: 26 additions & 0 deletions tests/expected/configured/sdf_spr_src1.json
Original file line number Diff line number Diff line change
@@ -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 added tests/expected/configured/sdf_spr_src1.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions tests/expected/configured/sdf_spr_src1.png.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
tests/output/configured/sdf_spr_src1.png: PNG image data, 48 x 43, 8-bit gray+alpha, non-interlaced
26 changes: 26 additions & 0 deletions tests/expected/configured/sdf_spr_src1_.json
Original file line number Diff line number Diff line change
@@ -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 added tests/expected/configured/sdf_spr_src1_.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions tests/expected/configured/sdf_spr_src1_.png.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
tests/output/configured/sdf_spr_src1_.png: PNG image data, 120 x 46, 8-bit gray+alpha, non-interlaced
Loading

0 comments on commit b134cb2

Please sign in to comment.