mod serve;
pub(crate) mod source;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use clap::{Args, Parser, Subcommand, ValueEnum};
use ezu::core::TileId as CoreTileId;
use ezu::features::mvt;
use ezu::graph::{
build_graph, Cache, CanvasInfo, Evaluator, Graph, ParamValues, PortValue, RasterBuf, TileId,
};
use ezu::paint::host::{
bind_dem_sources, bind_raster_sources, build_dem_sources, build_raster_sources, pixmap_to_webp,
raster_to_png, raster_to_webp, requested_neighbor_offsets, BrushBankLoader, DemSourceRegistry,
RasterSourceRegistry, TileLoader,
};
use ezu::paint::nodes::default_registry;
use ezu::style::{Document, SourceDecl};
use futures::future::try_join_all;
use futures::stream::{StreamExt, TryStreamExt};
use tiny_skia::{Pixmap, PixmapPaint, Transform};
use tracing_subscriber::EnvFilter;
use crate::source::{SourceSpec, TileSource};
#[derive(Parser, Debug)]
#[command(name = "ezu", about = "Render Ezu Style documents to PNG")]
struct Cli {
#[arg(long, short = 'v', global = true)]
verbose: bool,
#[command(subcommand)]
cmd: Cmd,
}
#[derive(Subcommand, Debug)]
enum Cmd {
Tile(TileCmd),
Bbox(BboxCmd),
Tiles(TilesCmd),
Check(CheckCmd),
Graph(GraphCmd),
Legend(LegendCmd),
Translate(TranslateCmd),
Serve(serve::ServeCmd),
Schema(SchemaCmd),
}
#[derive(Args, Debug)]
struct SchemaCmd {
#[arg(long)]
out: Option<PathBuf>,
}
#[derive(Args, Debug)]
struct CommonArgs {
#[arg(long)]
style: String,
#[arg(long)]
assets_dir: Option<PathBuf>,
#[arg(long, conflicts_with = "mvt")]
pmtiles: Option<String>,
#[arg(long, conflicts_with = "pmtiles")]
mvt: Option<String>,
#[arg(long, default_value_t = 4)]
overzoom_levels: u8,
#[arg(long = "param", value_name = "NAME=VALUE")]
params: Vec<String>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
enum OutputFormat {
Png,
Webp,
}
impl OutputFormat {
fn extension(self) -> &'static str {
match self {
OutputFormat::Png => "png",
OutputFormat::Webp => "webp",
}
}
fn from_path(path: &Path) -> Self {
match path.extension().and_then(|s| s.to_str()) {
Some(s) if s.eq_ignore_ascii_case("webp") => OutputFormat::Webp,
_ => OutputFormat::Png,
}
}
}
#[derive(Args, Debug)]
struct CheckCmd {
style: String,
#[arg(long)]
assets_dir: Option<PathBuf>,
#[arg(long)]
no_fetch: bool,
#[arg(long)]
json: bool,
}
#[derive(Args, Debug)]
struct GraphCmd {
style: String,
#[arg(long)]
out: Option<PathBuf>,
}
#[derive(Args, Debug)]
struct LegendCmd {
style: String,
#[arg(long)]
zoom: Option<u8>,
#[arg(long)]
out: Option<PathBuf>,
#[arg(long)]
pretty: bool,
#[arg(long)]
swatch_dir: Option<PathBuf>,
#[arg(long, default_value = "48x32", value_parser = parse_wxh)]
swatch_size: (u32, u32),
#[arg(long)]
assets_dir: Option<PathBuf>,
}
fn parse_wxh(s: &str) -> Result<(u32, u32), String> {
let parse = |v: &str| {
v.trim()
.parse::<u32>()
.map_err(|_| format!("`{s}`: expected WIDTHxHEIGHT in whole pixels"))
.and_then(|n| (n > 0).then_some(n).ok_or_else(|| format!("`{s}`: zero")))
};
match s.split_once(['x', 'X']) {
Some((w, h)) => Ok((parse(w)?, parse(h)?)),
None => {
let n = parse(s)?;
Ok((n, n))
}
}
}
#[derive(Args, Debug)]
struct TranslateCmd {
style: String,
#[arg(long)]
out: Option<PathBuf>,
#[arg(long, default_value_t = 512)]
tile_size: u32,
#[arg(long, default_value_t = 16)]
pad: u32,
#[arg(long)]
keep_hidden: bool,
#[arg(long = "font", value_name = "NAME=SOURCE")]
fonts: Vec<String>,
#[arg(long)]
pretty: bool,
}
#[derive(Args, Debug)]
struct TileCmd {
#[command(flatten)]
common: CommonArgs,
#[arg(long, value_parser = parse_zxy)]
tile: CoreTileId,
#[arg(long, default_value = "out.png")]
out: PathBuf,
#[arg(long, value_enum)]
format: Option<OutputFormat>,
}
#[derive(Args, Debug)]
struct BboxCmd {
#[command(flatten)]
common: CommonArgs,
#[arg(long, value_parser = parse_bbox)]
bbox: BBox,
#[arg(long)]
zoom: u8,
#[arg(long, default_value = "out.png")]
out: PathBuf,
#[arg(long, value_enum)]
format: Option<OutputFormat>,
}
#[derive(Args, Debug)]
struct TilesCmd {
#[command(flatten)]
common: CommonArgs,
#[arg(long, value_parser = parse_bbox)]
bbox: Option<BBox>,
#[arg(long)]
min_zoom: u8,
#[arg(long)]
max_zoom: u8,
#[arg(long, default_value = "tiles")]
out: PathBuf,
#[arg(long, value_enum, default_value_t = OutputFormat::Png)]
format: OutputFormat,
#[arg(long)]
concurrency: Option<usize>,
}
fn default_concurrency() -> usize {
std::thread::available_parallelism()
.map(|n| n.get())
.unwrap_or(4)
}
#[derive(Clone, Copy, Debug)]
struct BBox {
min_lng: f64,
min_lat: f64,
max_lng: f64,
max_lat: f64,
}
#[cfg(feature = "heap-profile")]
#[global_allocator]
static ALLOC: dhat::Alloc = dhat::Alloc;
#[tokio::main]
async fn main() {
#[cfg(feature = "heap-profile")]
let _dhat = dhat::Profiler::new_heap();
if let Err(e) = run().await {
let top = e.to_string();
eprintln!("error: {top}");
let mut shown = top;
let mut src = e.source();
while let Some(cause) = src {
let text = cause.to_string();
if !shown.contains(&text) {
eprintln!(" caused by: {text}");
}
shown = text;
src = cause.source();
}
std::process::exit(1);
}
}
async fn run() -> Result<(), Box<dyn std::error::Error>> {
let cli = Cli::parse();
let filter = if cli.verbose {
EnvFilter::new("info,ezu_graph::eval=debug")
} else {
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"))
};
let fmt = tracing_subscriber::fmt().with_env_filter(filter);
match &cli.cmd {
Cmd::Check(a) if a.json => fmt.with_writer(std::io::stderr).init(),
_ => fmt.init(),
}
match cli.cmd {
Cmd::Tile(args) => run_tile(args).await,
Cmd::Bbox(args) => run_bbox(args).await,
Cmd::Tiles(args) => run_tiles(args).await,
Cmd::Check(args) => run_check(args).await,
Cmd::Graph(args) => run_graph(args).await,
Cmd::Legend(args) => run_legend(args).await,
Cmd::Translate(args) => run_translate(args).await,
Cmd::Serve(args) => serve::run(args).await,
Cmd::Schema(args) => run_schema(args),
}
}
struct Prepared {
graph: Arc<Graph>,
cache: Arc<Cache>,
loader: Arc<BrushBankLoader>,
source: Option<Arc<TileSource>>,
source_name: Option<Arc<str>>,
dem_sources: Arc<DemSourceRegistry>,
raster_sources: Arc<RasterSourceRegistry>,
canvas: CanvasInfo,
overzoom_levels: u8,
params: Arc<ParamValues>,
}
async fn prepare(common: &CommonArgs) -> Result<Prepared, Box<dyn std::error::Error>> {
let style_text = fetch_text(&common.style).await?;
let doc = Document::from_json(&style_text)?;
tracing::info!(
"style: {} v{} ({} nodes, tile={})",
doc.name,
doc.version,
doc.nodes.len(),
doc.tile_size,
);
let assets_dir = common.assets_dir.clone().unwrap_or_else(|| {
if is_url(&common.style) {
PathBuf::from(".")
} else {
Path::new(&common.style)
.parent()
.map(|p| p.to_path_buf())
.unwrap_or_else(|| PathBuf::from("."))
}
});
let loader = Arc::new(build_asset_loader(&doc, &assets_dir).await?);
let registry = default_registry();
let graph = Arc::new(build_graph(&doc, ®istry)?);
report_pad(&graph, &doc);
let cache = Arc::new(Cache::with_limits(
ezu::graph::cache::DEFAULT_CAPACITY,
cache_budget_bytes(),
));
let canvas = CanvasInfo::square(doc.tile_size, canvas_pad(&graph, &doc));
let cli_override = match (&common.pmtiles, &common.mvt) {
(Some(p), None) => Some((SourceSpec::PmTiles(p.clone()), "--pmtiles flag")),
(None, Some(u)) => Some((SourceSpec::Mvt(u.clone()), "--mvt flag")),
(None, None) => None,
_ => return Err("--pmtiles and --mvt are mutually exclusive".into()),
};
let pick = feature_source_from_doc(&doc);
let (source, source_name): (Option<Arc<TileSource>>, Option<Arc<str>>) = match (
pick,
cli_override,
) {
(Some(p), Some((spec, origin))) => {
tracing::info!("opening source ({origin}, bound as `{}`): {spec:?}", p.name);
(
Some(Arc::new(TileSource::open(&spec).await?)),
Some(Arc::from(p.name)),
)
}
(Some(p), None) => {
tracing::info!("opening source ({}): {:?}", p.origin, p.spec);
(
Some(Arc::new(TileSource::open(&p.spec).await?)),
Some(Arc::from(p.name)),
)
}
(None, Some((spec, origin))) => {
return Err(format!(
"{origin} ({spec:?}) requires the style to declare a matching `mvt`/`pmtiles` source, but the document has none — `features` nodes have no source to reference"
)
.into());
}
(None, None) => {
tracing::info!("no MVT source — `features` bindings will be empty");
(None, None)
}
};
let dem_sources = Arc::new(build_dem_sources(&doc));
if !dem_sources.is_empty() {
let names: Vec<&str> = dem_sources.names().collect();
tracing::info!("dem sources: {}", names.join(", "));
}
let raster_sources = Arc::new(build_raster_sources(&doc, Some(assets_dir.clone())));
if !raster_sources.is_empty() {
let names: Vec<&str> = raster_sources.names().collect();
tracing::info!("raster sources: {}", names.join(", "));
}
Ok(Prepared {
graph,
cache,
loader,
source,
source_name,
dem_sources,
raster_sources,
canvas,
overzoom_levels: common.overzoom_levels,
params: Arc::new(parse_cli_params(&common.params, &doc)?),
})
}
fn parse_cli_params(
flags: &[String],
doc: &Document,
) -> Result<ParamValues, Box<dyn std::error::Error>> {
let mut values = ParamValues::new();
for flag in flags {
let (name, raw) = flag
.split_once('=')
.ok_or_else(|| format!("--param `{flag}`: expected `name=value`"))?;
let v = ezu::graph::parse_param_value(&doc.params, name, raw)?;
values.set(name.to_string(), v);
}
Ok(values)
}
fn run_schema(args: SchemaCmd) -> Result<(), Box<dyn std::error::Error>> {
let schema = default_registry().document_schema();
let mut text = serde_json::to_string_pretty(&schema)?;
text.push('\n');
match args.out {
Some(path) => std::fs::write(&path, text)?,
None => print!("{text}"),
}
Ok(())
}
pub(crate) fn canvas_pad(graph: &Graph, doc: &Document) -> u32 {
match graph.required_pad() {
Ok(needed) => doc.pad.max(needed),
Err(_) => doc.pad,
}
}
fn report_pad(graph: &Graph, doc: &Document) {
let needed = match graph.required_pad() {
Ok(needed) => needed,
Err(e) => {
tracing::warn!("pad: {e}");
return;
}
};
if needed > doc.pad {
tracing::info!(
"pad: {} declared, {needed} needed — rendering with {needed}",
doc.pad,
);
} else {
tracing::info!("pad: {} declared, {needed} needed", doc.pad);
}
}
#[derive(serde::Serialize)]
struct CheckReport<'a> {
name: &'a str,
version: &'a str,
nodes: usize,
sources: usize,
pad: PadReport,
#[serde(skip_serializing_if = "Vec::is_empty")]
attribution: Vec<&'a str>,
params: serde_json::Value,
assets_resolved: bool,
#[serde(skip_serializing_if = "Vec::is_empty")]
warnings: Vec<String>,
}
#[derive(serde::Serialize)]
struct PadReport {
declared: u32,
#[serde(skip_serializing_if = "Option::is_none")]
needed: Option<u32>,
}
async fn run_check(args: CheckCmd) -> Result<(), Box<dyn std::error::Error>> {
let text = fetch_text(&args.style).await?;
let doc = Document::from_json(&text)?;
let registry = default_registry();
let graph = build_graph(&doc, ®istry)?;
let mut warnings = Vec::new();
let needed = match graph.required_pad() {
Ok(needed) => Some(needed),
Err(e) => {
warnings.push(format!("pad: {e}"));
None
}
};
let attributions = doc.attributions();
if !args.json {
match needed {
Some(needed) if needed > doc.pad => tracing::info!(
"pad: {} declared, {needed} needed — rendering with {needed}",
doc.pad,
),
Some(needed) => tracing::info!("pad: {} declared, {needed} needed", doc.pad),
None => tracing::warn!("{}", warnings[0]),
}
if !attributions.is_empty() {
tracing::info!("attribution: {}", attributions.join(" | "));
}
}
let doc_scoped_count = count_doc_scoped_sources(&doc);
if !args.no_fetch && doc_scoped_count > 0 {
let base_dir = args.assets_dir.clone().unwrap_or_else(|| {
if is_url(&args.style) {
PathBuf::from(".")
} else {
Path::new(&args.style)
.parent()
.map(|p| p.to_path_buf())
.unwrap_or_else(|| PathBuf::from("."))
}
});
let mut loader = BrushBankLoader::new()
.with_dir(base_dir.clone())
.with_images_dir(base_dir.clone());
ezu::paint::host::prefetch_doc_assets(&doc, &base_dir, &mut loader).await?;
}
if args.json {
let report = CheckReport {
name: &doc.name,
version: &doc.version,
nodes: graph.len(),
sources: doc.sources.len(),
pad: PadReport {
declared: doc.pad,
needed,
},
attribution: attributions,
params: doc.params_schema(),
assets_resolved: !args.no_fetch,
warnings,
};
println!("{}", serde_json::to_string_pretty(&report)?);
return Ok(());
}
tracing::info!(
"ok: {} v{} ({} nodes, {} sources){}",
doc.name,
doc.version,
graph.len(),
doc.sources.len(),
if args.no_fetch {
" [parse + graph only]"
} else {
""
},
);
Ok(())
}
fn count_doc_scoped_sources(doc: &Document) -> usize {
doc.sources
.values()
.filter(|d| {
matches!(
d,
SourceDecl::Brush(_)
| SourceDecl::Image(_)
| SourceDecl::Font(_)
| SourceDecl::Glyphs(_)
)
})
.count()
}
async fn run_graph(args: GraphCmd) -> Result<(), Box<dyn std::error::Error>> {
let text = fetch_text(&args.style).await?;
let doc = Document::from_json(&text)?;
let mermaid = render_mermaid(&doc);
match &args.out {
Some(p) => {
std::fs::write(p, &mermaid)?;
tracing::info!("wrote {} ({} bytes)", p.display(), mermaid.len());
}
None => print!("{mermaid}"),
}
Ok(())
}
async fn run_legend(args: LegendCmd) -> Result<(), Box<dyn std::error::Error>> {
let text = fetch_text(&args.style).await?;
let doc = Document::from_json(&text)?;
build_graph(&doc, &default_registry())?;
let Some(legend) = &doc.legend else {
return Err(format!("{} declares no `legend` block", args.style).into());
};
let filtered = args.zoom.map(|z| ezu::style::LegendDecl {
title: legend.title.clone(),
note: legend.note.clone(),
entries: legend.entries_at(z).cloned().collect(),
});
let legend = filtered.as_ref().unwrap_or(legend);
let swatches = match &args.swatch_dir {
Some(dir) => draw_swatches(&doc, legend, dir, &args).await?,
None => vec![None; legend.entries.len()],
};
let out = LegendOut {
title: legend.title.as_deref(),
note: legend.note.as_deref(),
entries: legend
.entries
.iter()
.zip(&swatches)
.map(|(e, swatch)| EntryOut {
label: &e.label,
from: &e.from,
properties: &e.properties,
note: e.note.as_deref(),
min_zoom: e.min_zoom,
max_zoom: e.max_zoom,
geometry: e.geometry,
swatch: swatch.as_deref(),
})
.collect(),
};
let json = if args.pretty {
serde_json::to_string_pretty(&out)?
} else {
serde_json::to_string(&out)?
};
match &args.out {
Some(p) => {
std::fs::write(p, &json)?;
tracing::info!("wrote {} ({} bytes)", p.display(), json.len());
}
None => println!("{json}"),
}
Ok(())
}
#[derive(serde::Serialize)]
#[serde(rename_all = "kebab-case")]
struct LegendOut<'a> {
#[serde(skip_serializing_if = "Option::is_none")]
title: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
note: Option<&'a str>,
entries: Vec<EntryOut<'a>>,
}
#[derive(serde::Serialize)]
#[serde(rename_all = "kebab-case")]
struct EntryOut<'a> {
label: &'a str,
from: &'a ezu::style::NodeRef,
#[serde(skip_serializing_if = "serde_json::Map::is_empty")]
properties: &'a serde_json::Map<String, serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
note: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
min_zoom: Option<u8>,
#[serde(skip_serializing_if = "Option::is_none")]
max_zoom: Option<u8>,
#[serde(skip_serializing_if = "Option::is_none")]
geometry: Option<ezu::style::LegendGeometry>,
#[serde(skip_serializing_if = "Option::is_none")]
swatch: Option<&'a str>,
}
async fn draw_swatches(
doc: &Document,
legend: &ezu::style::LegendDecl,
dir: &Path,
args: &LegendCmd,
) -> Result<Vec<Option<String>>, Box<dyn std::error::Error>> {
use ezu::paint::host::{crop_to_png, PngCompression};
use ezu::paint::legend::{render_swatch, SwatchOptions};
std::fs::create_dir_all(dir)?;
let base_dir = args.assets_dir.clone().unwrap_or_else(|| {
if is_url(&args.style) {
PathBuf::from(".")
} else {
Path::new(&args.style)
.parent()
.map(|p| p.to_path_buf())
.unwrap_or_else(|| PathBuf::from("."))
}
});
let assets = build_asset_loader(doc, &base_dir).await?;
let registry = default_registry();
let cache = Cache::new();
let params = ParamValues::new();
let (width, height) = args.swatch_size;
let opts = SwatchOptions {
width,
height,
zoom: args.zoom.unwrap_or(12),
pad: 0,
geometry: Default::default(),
};
let mut out = Vec::with_capacity(legend.entries.len());
for (i, entry) in legend.entries.iter().enumerate() {
let (raster, canvas) =
render_swatch(doc, entry, ®istry, &assets, ¶ms, &cache, &opts)?;
let png = crop_to_png(
&raster,
canvas.tile_w,
canvas.tile_h,
canvas.pad,
PngCompression::Default,
)?;
let path = dir.join(format!("{i}.png"));
std::fs::write(&path, &png)?;
tracing::info!("wrote {} ({} bytes)", path.display(), png.len());
out.push(Some(path.to_string_lossy().into_owned()));
}
Ok(out)
}
async fn run_translate(args: TranslateCmd) -> Result<(), Box<dyn std::error::Error>> {
let text = fetch_text(&args.style).await?;
let style: serde_json::Value = serde_json::from_str(&text)?;
let mut fonts = std::collections::HashMap::new();
for entry in &args.fonts {
let Some((name, url)) = entry.split_once('=') else {
return Err(format!("--font `{entry}`: expected NAME=URL").into());
};
fonts.insert(name.trim().to_string(), url.trim().to_string());
}
let opts = ezu::translate::maplibre::ConvertOptions {
tile_size: args.tile_size,
pad: args.pad,
keep_hidden: args.keep_hidden,
fonts,
};
let (recipe, report) = ezu::translate::maplibre::convert(&style, &opts)?;
for w in &report.warnings {
tracing::warn!("{w}");
}
let json = if args.pretty {
serde_json::to_string_pretty(&recipe)?
} else {
serde_json::to_string(&recipe)?
};
match &args.out {
Some(p) => {
std::fs::write(p, &json)?;
tracing::info!(
"wrote {} ({} bytes, {} warnings)",
p.display(),
json.len(),
report.warnings.len()
);
}
None => println!("{json}"),
}
Ok(())
}
fn render_mermaid(doc: &ezu::style::Document) -> String {
use std::collections::HashSet;
let mut s = String::new();
s.push_str("graph LR\n");
let mut doc_scoped_ids: Vec<&str> = Vec::new();
for (id, decl) in &doc.sources {
let kind = match decl {
SourceDecl::Brush(_) => "brush",
SourceDecl::Image(_) => "image",
SourceDecl::Mvt(_) => "mvt",
SourceDecl::Pmtiles(_) => "pmtiles",
SourceDecl::Dem(_) => "dem",
SourceDecl::Raster(_) => "raster",
SourceDecl::GeoJson(_) => "geojson",
SourceDecl::Sprite(_) => "sprite",
SourceDecl::Font(_) => "font",
SourceDecl::Glyphs(_) => "glyphs",
};
s.push_str(&format!(" {id}[/\"{id} (source:{kind})\"/]\n"));
if matches!(decl, SourceDecl::Brush(_) | SourceDecl::Image(_)) {
doc_scoped_ids.push(id);
}
}
let output_id = doc.output.as_str();
let mut source_ids: Vec<&str> = Vec::new();
for (id, spec) in &doc.nodes {
let is_source = spec.op == "features";
let suffix = if id == output_id { ":::output" } else { "" };
let op = if spec.op == "func" {
match spec.fields.get("fn").and_then(serde_json::Value::as_str) {
Some(f) => format!("func:{f}"),
None => spec.op.clone(),
}
} else {
spec.op.clone()
};
if is_source {
s.push_str(&format!(" {id}[(\"{id} ({op})\")]{suffix}\n"));
source_ids.push(id);
} else {
s.push_str(&format!(" {id}[\"{id} ({op})\"]{suffix}\n"));
}
}
s.push_str(" __output__([\"OUTPUT\"]):::sink\n");
s.push_str(&format!(" {output_id} ==> __output__\n"));
s.push('\n');
for (id, spec) in &doc.nodes {
let mut seen = HashSet::new();
for r in spec.refs() {
if !seen.insert(r.clone()) {
continue;
}
if doc.nodes.contains_key(&r) || doc.sources.contains_key(&r) {
s.push_str(&format!(" {r} --> {id}\n"));
}
}
}
s.push_str("\n classDef asset fill:#fff4d6,color:#3a2e00,stroke:#a88500;\n");
s.push_str(" classDef output fill:#ffe0e0,color:#4a1010,stroke:#cc3333,stroke-width:2px;\n");
s.push_str(" classDef sink fill:#cc3333,color:#ffffff,stroke:#7a1f1f,stroke-width:2px;\n");
s.push_str(" classDef source fill:#d9ecff,color:#0d2b45,stroke:#2a6fb0;\n");
if !doc_scoped_ids.is_empty() {
s.push_str(&format!(" class {} asset;\n", doc_scoped_ids.join(",")));
}
if !source_ids.is_empty() {
s.push_str(&format!(" class {} source;\n", source_ids.join(",")));
}
s
}
async fn run_tile(args: TileCmd) -> Result<(), Box<dyn std::error::Error>> {
let prep = prepare(&args.common).await?;
let format = args
.format
.unwrap_or_else(|| OutputFormat::from_path(&args.out));
let raster = render_one(
Arc::clone(&prep.graph),
Arc::clone(&prep.cache),
Arc::clone(&prep.loader),
prep.source.as_ref().map(Arc::clone),
prep.source_name.as_ref().map(Arc::clone),
Arc::clone(&prep.dem_sources),
Arc::clone(&prep.raster_sources),
prep.canvas,
args.tile,
prep.overzoom_levels,
Arc::clone(&prep.params),
)
.await
.map_err(|e| e.to_string())?;
let bytes = match format {
OutputFormat::Png => raster_to_png(&raster, prep.canvas.tile_w, prep.canvas.pad)?,
OutputFormat::Webp => raster_to_webp(&raster, prep.canvas.tile_w, prep.canvas.pad)?,
};
std::fs::write(&args.out, &bytes)?;
tracing::info!("wrote {} ({} bytes)", args.out.display(), bytes.len());
Ok(())
}
async fn run_bbox(args: BboxCmd) -> Result<(), Box<dyn std::error::Error>> {
let prep = prepare(&args.common).await?;
let format = args
.format
.unwrap_or_else(|| OutputFormat::from_path(&args.out));
let (x_range, y_range) = bbox_to_tiles(args.bbox, args.zoom);
let nx = x_range.end - x_range.start;
let ny = y_range.end - y_range.start;
tracing::info!(
"bbox covers {nx}×{ny} tiles at z={} ({}..{}, {}..{})",
args.zoom,
x_range.start,
x_range.end,
y_range.start,
y_range.end,
);
let mut tasks = Vec::with_capacity((nx * ny) as usize);
for ty in y_range.clone() {
for tx in x_range.clone() {
let tile = CoreTileId::new(args.zoom, tx, ty);
let graph = Arc::clone(&prep.graph);
let cache = Arc::clone(&prep.cache);
let loader = Arc::clone(&prep.loader);
let source = prep.source.as_ref().map(Arc::clone);
let source_name = prep.source_name.as_ref().map(Arc::clone);
let dem_sources = Arc::clone(&prep.dem_sources);
let raster_sources = Arc::clone(&prep.raster_sources);
let canvas = prep.canvas;
let overzoom_levels = prep.overzoom_levels;
let params = Arc::clone(&prep.params);
tasks.push(tokio::spawn(async move {
let raster = render_one(
graph,
cache,
loader,
source,
source_name,
dem_sources,
raster_sources,
canvas,
tile,
overzoom_levels,
params,
)
.await?;
Ok::<(CoreTileId, Arc<RasterBuf>), Box<dyn std::error::Error + Send + Sync>>((
tile, raster,
))
}));
}
}
let mut mosaic =
Pixmap::new(nx * prep.canvas.tile_w, ny * prep.canvas.tile_w).ok_or("mosaic alloc")?;
for handle in try_join_all(tasks).await? {
let (tile, raster) = handle.map_err(|e| e.to_string())?;
let dx = ((tile.x - x_range.start) * prep.canvas.tile_w) as i32;
let dy = ((tile.y - y_range.start) * prep.canvas.tile_w) as i32;
blit_padded_into(
&mut mosaic,
&raster,
dx,
dy,
prep.canvas.tile_w,
prep.canvas.pad,
)?;
}
let bytes = match format {
OutputFormat::Png => mosaic.encode_png().map_err(|e| e.to_string())?,
OutputFormat::Webp => pixmap_to_webp(&mosaic).map_err(|e| e.to_string())?,
};
std::fs::write(&args.out, &bytes)?;
tracing::info!("wrote {} ({} bytes)", args.out.display(), bytes.len());
Ok(())
}
async fn run_tiles(args: TilesCmd) -> Result<(), Box<dyn std::error::Error>> {
if args.min_zoom > args.max_zoom {
return Err("--min-zoom must be ≤ --max-zoom".into());
}
let concurrency = args.concurrency.unwrap_or_else(default_concurrency);
if concurrency == 0 {
return Err("--concurrency must be ≥ 1".into());
}
let prep = prepare(&args.common).await?;
let mut total: u64 = 0;
for z in args.min_zoom..=args.max_zoom {
let (x_range, y_range) = match args.bbox {
Some(b) => bbox_to_tiles(b, z),
None => {
let n = 1u32 << z;
(0..n, 0..n)
}
};
let nx = x_range.end - x_range.start;
let ny = y_range.end - y_range.start;
let count = nx as u64 * ny as u64;
total += count;
tracing::info!(
"z={z}: {nx}×{ny} = {count} tiles ({}..{}, {}..{})",
x_range.start,
x_range.end,
y_range.start,
y_range.end,
);
let xr = x_range.clone();
let coords = y_range
.clone()
.flat_map(move |ty| xr.clone().map(move |tx| (tx, ty)));
let prep = &prep;
let out = &args.out;
let format = args.format;
let t0 = std::time::Instant::now();
futures::stream::iter(coords)
.map(|(tx, ty)| async move {
let tile = CoreTileId::new(z, tx, ty);
let raster = render_one(
Arc::clone(&prep.graph),
Arc::clone(&prep.cache),
Arc::clone(&prep.loader),
prep.source.as_ref().map(Arc::clone),
prep.source_name.as_ref().map(Arc::clone),
Arc::clone(&prep.dem_sources),
Arc::clone(&prep.raster_sources),
prep.canvas,
tile,
prep.overzoom_levels,
Arc::clone(&prep.params),
)
.await?;
let bytes = tokio::task::spawn_blocking({
let canvas = prep.canvas;
move || match format {
OutputFormat::Png => raster_to_png(&raster, canvas.tile_w, canvas.pad),
OutputFormat::Webp => raster_to_webp(&raster, canvas.tile_w, canvas.pad),
}
})
.await
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { e.to_string().into() })?
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> {
e.to_string().into()
})?;
let dir = out.join(z.to_string()).join(tx.to_string());
tokio::fs::create_dir_all(&dir).await?;
let path = dir.join(format!("{ty}.{}", format.extension()));
tokio::fs::write(&path, bytes).await?;
Ok::<(), Box<dyn std::error::Error + Send + Sync>>(())
})
.buffer_unordered(concurrency)
.try_collect::<Vec<_>>()
.await
.map_err(|e| e.to_string())?;
tracing::info!("z={z}: done in {:.1}s", t0.elapsed().as_secs_f64());
}
tracing::info!("wrote {total} tiles → {}", args.out.display());
Ok(())
}
#[allow(clippy::too_many_arguments)]
async fn render_one(
graph: Arc<Graph>,
cache: Arc<Cache>,
loader: Arc<BrushBankLoader>,
source: Option<Arc<TileSource>>,
source_name: Option<Arc<str>>,
dem_sources: Arc<DemSourceRegistry>,
raster_sources: Arc<RasterSourceRegistry>,
canvas: CanvasInfo,
tile: CoreTileId,
overzoom_levels: u8,
params: Arc<ParamValues>,
) -> Result<Arc<RasterBuf>, Box<dyn std::error::Error + Send + Sync>> {
let fetched = match &source {
Some(s) => s.fetch_with_fallback(tile, overzoom_levels).await?,
None => None,
};
let neighbor_mvt: Vec<((i32, i32), (bytes::Bytes, CoreTileId))> = match (&source, &source_name)
{
(Some(s), Some(name)) => {
let offsets = requested_neighbor_offsets(&graph.asset_inputs(), name);
if offsets.is_empty() {
Vec::new()
} else {
s.fetch_neighbors(tile, &offsets, overzoom_levels).await?
}
}
_ => Vec::new(),
};
let tile_id = TileId {
z: tile.z,
x: tile.x,
y: tile.y,
};
let mut dem_bindings: Vec<(String, ezu::graph::ScalarField)> = Vec::new();
if !dem_sources.is_empty() {
let base_loader = BrushBankLoader::new();
let mut tmp = TileLoader::new(&base_loader, tile_id);
bind_dem_sources(&mut tmp, &dem_sources, tile_id, canvas).await?;
for name in dem_sources.names() {
if let Ok(ezu::graph::Asset::ScalarField(field)) =
ezu::graph::AssetLoader::load(&tmp, name)
{
dem_bindings.push((name.to_string(), (*field).clone()));
}
}
}
let mut raster_bindings: Vec<(String, RasterBuf)> = Vec::new();
if !raster_sources.is_empty() {
let base_loader = BrushBankLoader::new();
let mut tmp = TileLoader::new(&base_loader, tile_id);
bind_raster_sources(&mut tmp, &raster_sources, tile_id, canvas).await?;
for name in raster_sources.names() {
if let Ok(ezu::graph::Asset::Image(buf)) = ezu::graph::AssetLoader::load(&tmp, name) {
raster_bindings.push((name.to_string(), (*buf).clone()));
}
}
}
let raster = tokio::task::spawn_blocking(
move || -> Result<Arc<RasterBuf>, Box<dyn std::error::Error + Send + Sync>> {
let mut tile_loader = TileLoader::new(loader.as_ref(), tile_id);
if let (Some((bytes, src_tile)), Some(src_name)) = (fetched, &source_name) {
let mut decoded = mvt::decode(&bytes)?;
if src_tile != tile {
decoded = mvt::clip_to_descendant(&decoded, src_tile, tile)?;
}
tile_loader.bind_mvt(src_name, decoded);
}
if let Some(src_name) = &source_name {
for ((dx, dy), (bytes, src_tile)) in neighbor_mvt {
let ntile = CoreTileId::new(
tile.z,
(tile.x as i64 + dx as i64).rem_euclid(1i64 << tile.z) as u32,
(tile.y as i64 + dy as i64) as u32,
);
let mut decoded = mvt::decode(&bytes)?;
if src_tile != ntile {
decoded = mvt::clip_to_descendant(&decoded, src_tile, ntile)?;
}
tile_loader.bind_mvt_neighbor(src_name, dx, dy, decoded);
}
}
for (name, field) in dem_bindings {
tile_loader.bind_scalar_field(name, field);
}
for (name, buf) in raster_bindings {
tile_loader.bind_raster(name, buf);
}
let ev = Evaluator::new(&graph, &cache, &tile_loader);
let out = if serial_eval() {
ev.render(tile_id, canvas, ¶ms, tile_seed(tile))?
} else {
ev.render_parallel(tile_id, canvas, ¶ms, tile_seed(tile))?
};
if ezu::graph::mem::enabled() {
report_glyph_memory(loader.as_ref());
}
match out {
PortValue::Raster(r) => Ok(r),
other => Err(format!("expected Raster output, got {:?}", other.kind()).into()),
}
},
)
.await??;
Ok(raster)
}
fn report_glyph_memory(loader: &BrushBankLoader) {
let stacks = loader.glyphs.read().expect("glyphs bank poisoned");
if stacks.is_empty() {
return;
}
let mut total = 0usize;
let mut lines = String::new();
for (key, stack) in stacks.iter() {
let (ranges, bytes) = stack.loaded_size();
total += bytes;
let name = key.rsplit('/').nth(1).unwrap_or(key);
lines.push_str(&format!(
" {name:<32} {:>8.1} MB over {ranges} range(s)\n",
bytes as f64 / (1024.0 * 1024.0)
));
}
eprintln!(
"glyph ranges: {:.1} MB\n{lines}",
total as f64 / (1024.0 * 1024.0)
);
}
fn cache_budget_bytes() -> usize {
std::env::var("EZU_CACHE_MB")
.ok()
.and_then(|v| v.parse::<usize>().ok())
.map(|mb| mb * 1024 * 1024)
.unwrap_or(ezu::graph::cache::DEFAULT_BYTE_BUDGET)
}
fn serial_eval() -> bool {
static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*ON.get_or_init(|| {
std::env::var("EZU_SERIAL")
.map(|v| v != "0" && !v.is_empty())
.unwrap_or(false)
})
}
async fn build_asset_loader(
doc: &Document,
base_dir: &Path,
) -> Result<BrushBankLoader, Box<dyn std::error::Error>> {
let mut loader = BrushBankLoader::new()
.with_dir(base_dir.to_path_buf())
.with_images_dir(base_dir.to_path_buf());
ezu::paint::host::prefetch_doc_assets(doc, base_dir, &mut loader).await?;
tracing::info!(
"loaded {} brushes + {} images (base={})",
loader.bank.len(),
loader.images.len(),
base_dir.display(),
);
Ok(loader)
}
pub(crate) async fn fetch_text(arg: &str) -> Result<String, Box<dyn std::error::Error>> {
if is_url(arg) {
let body = reqwest::get(arg).await?.error_for_status()?.text().await?;
Ok(body)
} else {
Ok(std::fs::read_to_string(arg)?)
}
}
pub(crate) struct FeatureSourcePick {
pub name: String,
pub spec: SourceSpec,
pub origin: &'static str,
}
pub(crate) fn feature_source_from_doc(doc: &Document) -> Option<FeatureSourcePick> {
let mut chosen: Option<FeatureSourcePick> = None;
for (name, decl) in &doc.sources {
let (spec, origin) = match decl {
SourceDecl::Mvt(s) => (SourceSpec::Mvt(s.url.clone()), "style sources (mvt)"),
SourceDecl::Pmtiles(s) => (
SourceSpec::PmTiles(s.url.clone()),
"style sources (pmtiles)",
),
SourceDecl::Brush(_)
| SourceDecl::Image(_)
| SourceDecl::Dem(_)
| SourceDecl::GeoJson(_)
| SourceDecl::Sprite(_)
| SourceDecl::Font(_)
| SourceDecl::Glyphs(_)
| SourceDecl::Raster(_) => continue,
};
if chosen.is_some() {
tracing::warn!("multiple feature sources in style; ignoring `{name}`");
continue;
}
chosen = Some(FeatureSourcePick {
name: name.clone(),
spec,
origin,
});
}
chosen
}
fn is_url(s: &str) -> bool {
s.starts_with("http://") || s.starts_with("https://")
}
fn blit_padded_into(
mosaic: &mut Pixmap,
raster: &RasterBuf,
dx: i32,
dy: i32,
tile_size: u32,
pad: u32,
) -> Result<(), Box<dyn std::error::Error>> {
let mut tile = Pixmap::new(tile_size, tile_size).ok_or("tile pixmap alloc")?;
let stride = (raster.width * 4) as usize;
let row_bytes = (tile_size * 4) as usize;
let dst = tile.data_mut();
for row in 0..tile_size {
let src_y = pad + row;
let src_off = src_y as usize * stride + (pad as usize) * 4;
let dst_off = row as usize * row_bytes;
dst[dst_off..dst_off + row_bytes]
.copy_from_slice(&raster.pixels[src_off..src_off + row_bytes]);
}
mosaic.draw_pixmap(
dx,
dy,
tile.as_ref(),
&PixmapPaint::default(),
Transform::identity(),
None,
);
Ok(())
}
fn bbox_to_tiles(b: BBox, z: u8) -> (std::ops::Range<u32>, std::ops::Range<u32>) {
let n = 2f64.powi(z as i32);
let xt = |lng: f64| {
(ezu::core::coord::lon_to_world_x(lng) * n)
.floor()
.clamp(0.0, n - 1.0) as u32
};
let yt = |lat: f64| {
(ezu::core::coord::lat_to_world_y(lat) * n)
.floor()
.clamp(0.0, n - 1.0) as u32
};
let x0 = xt(b.min_lng);
let x1 = xt(b.max_lng);
let y0 = yt(b.max_lat);
let y1 = yt(b.min_lat);
(x0..(x1 + 1), y0..(y1 + 1))
}
fn parse_zxy(s: &str) -> Result<CoreTileId, String> {
let parts: Vec<&str> = s.split('/').collect();
if parts.len() != 3 {
return Err(format!("expected `Z/X/Y`, got `{s}`"));
}
let z: u8 = parts[0].parse().map_err(|e| format!("bad z: {e}"))?;
let x: u32 = parts[1].parse().map_err(|e| format!("bad x: {e}"))?;
let y: u32 = parts[2].parse().map_err(|e| format!("bad y: {e}"))?;
Ok(CoreTileId::new(z, x, y))
}
fn parse_bbox(s: &str) -> Result<BBox, String> {
let parts: Vec<&str> = s.split(',').collect();
if parts.len() != 4 {
return Err(format!(
"expected `min_lng,min_lat,max_lng,max_lat`, got `{s}`"
));
}
let v: Vec<f64> = parts
.iter()
.map(|p| {
p.trim()
.parse::<f64>()
.map_err(|e| format!("bad number `{p}`: {e}"))
})
.collect::<Result<_, _>>()?;
let (min_lng, min_lat, max_lng, max_lat) = (v[0], v[1], v[2], v[3]);
if min_lng >= max_lng || min_lat >= max_lat {
return Err(format!(
"bbox min must be strictly less than max: {min_lng},{min_lat},{max_lng},{max_lat}"
));
}
Ok(BBox {
min_lng,
min_lat,
max_lng,
max_lat,
})
}
fn tile_seed(tile: CoreTileId) -> u64 {
let mut s = 0u64;
s = s
.wrapping_mul(0x9E37_79B9_7F4A_7C15)
.wrapping_add(tile.z as u64);
s = s
.wrapping_mul(0x9E37_79B9_7F4A_7C15)
.wrapping_add(tile.x as u64);
s = s
.wrapping_mul(0x9E37_79B9_7F4A_7C15)
.wrapping_add(tile.y as u64);
s
}