use std::collections::HashMap;
use std::sync::Arc;
use ezu_graph::{
schema_frag, BuiltNode, Connection, CoordSpace, EvalCtx, EvalError, FactoryCtx, FactoryError,
Node, NodeFactory, PortKind, PortSpec, PortValue, RasterBuf,
};
use serde_json::Value;
use tiny_skia::{PixmapPaint, PixmapRef, Transform};
use xxhash_rust::xxh3::Xxh3;
use crate::nodes::common::{canvas_into_raster, empty_raster, make_canvas};
use ezu_core::text::{
collide, draw, draw_line, FaceEntry, Font, GlyphPlacement, LabelCandidate, OutlineSdfCache,
SectionPaint, StackEntry, TextBlock, TextPaint,
};
pub(super) const ACCEPTS_LABELS: &[PortKind] = &[PortKind::Labels];
pub(super) struct LabelSet {
pub id: u64,
pub candidates: Vec<LabelCandidate>,
pub draws: Vec<LabelDraw>,
pub padding_px: f32,
pub collide: bool,
pub outline_sdf: bool,
}
pub(super) enum LabelDraw {
Point {
blocks: Vec<Arc<TextBlock>>,
variants: Vec<PointVariant>,
anchor: (f32, f32),
paint: TextPaint,
fonts: Arc<Vec<StackEntry>>,
paints: Arc<Vec<SectionPaint>>,
icons: Vec<Arc<IconDraw>>,
},
Line {
block: Arc<TextBlock>,
placements: Vec<GlyphPlacement>,
perp_px: f32,
paint: TextPaint,
fonts: Arc<Vec<StackEntry>>,
paints: Arc<Vec<SectionPaint>>,
},
}
pub(super) struct PointVariant {
pub block: Option<usize>,
pub icon: Option<usize>,
}
pub(super) struct IconDraw {
pub image: Arc<RasterBuf>,
pub offset: (f32, f32),
pub scale: f32,
pub rotation_deg: f32,
pub opacity: f32,
pub half: (f32, f32),
}
impl LabelSet {
pub(super) fn stacks(&self) -> impl Iterator<Item = &[StackEntry]> {
self.draws.iter().map(|d| match d {
LabelDraw::Point { fonts, .. } | LabelDraw::Line { fonts, .. } => fonts.as_slice(),
})
}
}
pub(super) fn set_id(param_hash: u64, candidates: &[LabelCandidate]) -> u64 {
let mut h = Xxh3::new();
h.update(¶m_hash.to_le_bytes());
for c in candidates {
h.update(&c.world_ax.to_le_bytes());
h.update(&c.world_ay.to_le_bytes());
h.update(c.text.as_bytes());
h.update(&[0]);
h.update(&c.style_id.to_le_bytes());
h.update(&c.sort_key.to_bits().to_le_bytes());
h.update(&c.rank.tile.0.to_le_bytes());
h.update(&c.rank.tile.1.to_le_bytes());
h.update(&c.rank.feature.to_le_bytes());
h.update(&c.rank.symbol.to_le_bytes());
}
h.digest()
}
pub(super) struct PlacedLabels {
by_set: HashMap<u64, Vec<collide::Placement>>,
}
impl PlacedLabels {
fn get(&self, id: u64) -> Option<&[collide::Placement]> {
self.by_set.get(&id).map(Vec::as_slice)
}
}
pub(super) struct FaceCache<'a> {
faces: HashMap<*const Font, rustybuzz::Face<'a>>,
}
impl<'a> FaceCache<'a> {
pub(super) fn from_stacks(stacks: impl Iterator<Item = &'a [StackEntry]>) -> FaceCache<'a> {
let mut faces: HashMap<*const Font, rustybuzz::Face<'a>> = HashMap::new();
for stack in stacks {
for entry in stack {
if let StackEntry::Outline(font) = entry {
faces
.entry(Arc::as_ptr(font))
.or_insert_with(|| font.face());
}
}
}
FaceCache { faces }
}
pub(super) fn view<'b>(&'b self, stack: &'b [StackEntry]) -> Vec<FaceEntry<'b>> {
stack
.iter()
.map(|entry| match entry {
StackEntry::Outline(font) => FaceEntry::Outline {
font,
face: self
.faces
.get(&Arc::as_ptr(font))
.cloned()
.unwrap_or_else(|| font.face()),
},
StackEntry::Sdf(s) => FaceEntry::Sdf(s),
})
.collect()
}
}
pub(super) fn draw_labels(
ctx: &EvalCtx<'_>,
set: &LabelSet,
placed: &[collide::Placement],
faces: &FaceCache<'_>,
) -> Result<PortValue, EvalError> {
if placed.is_empty() {
return Ok(empty_raster(ctx));
}
let mut canvas = make_canvas(ctx)?;
let pad = canvas.pad() as f32;
let padded_w = canvas.tile_width() as f32 + 2.0 * pad;
let padded_h = canvas.tile_height() as f32 + 2.0 * pad;
let sdf_cache = set.outline_sdf.then(OutlineSdfCache::new);
let pm = canvas.pixmap_mut();
let mut pm = pm.as_mut();
for p in placed {
let Some(LabelDraw::Point {
variants,
anchor,
icons,
..
}) = set.draws.get(p.cand)
else {
continue;
};
let Some(icon) = variants
.get(p.variant)
.and_then(|v| v.icon)
.and_then(|ix| icons.get(ix))
else {
continue;
};
let (ax, ay) = (
anchor.0 + pad + icon.offset.0,
anchor.1 + pad + icon.offset.1,
);
if ax + icon.half.0 < 0.0
|| ax - icon.half.0 > padded_w
|| ay + icon.half.1 < 0.0
|| ay - icon.half.1 > padded_h
{
continue;
}
let img = &icon.image;
let Some(img_ref) = PixmapRef::from_bytes(&img.pixels, img.width, img.height) else {
continue;
};
let t = Transform::from_translate(ax, ay)
.pre_rotate(icon.rotation_deg)
.pre_scale(icon.scale, icon.scale)
.pre_translate(img.width as f32 * -0.5, img.height as f32 * -0.5);
pm.draw_pixmap(
0,
0,
img_ref,
&PixmapPaint {
opacity: icon.opacity,
..PixmapPaint::default()
},
t,
None,
);
}
for p in placed {
let Some(d) = set.draws.get(p.cand) else {
continue;
};
match d {
LabelDraw::Point {
blocks,
variants,
anchor,
paint,
fonts,
paints,
..
} => {
let Some(block) = variants
.get(p.variant)
.and_then(|v| v.block)
.and_then(|ix| blocks.get(ix))
else {
continue;
};
let (ax, ay) = (anchor.0 + pad, anchor.1 + pad);
if set.collide {
let bb = block.bbox;
let s = paint.size_px;
let min_x = ax + bb.min_x * s - set.padding_px;
let max_x = ax + bb.max_x * s + set.padding_px;
let min_y = ay + bb.min_y * s - set.padding_px;
let max_y = ay + bb.max_y * s + set.padding_px;
if max_x < 0.0 || min_x > padded_w || max_y < 0.0 || min_y > padded_h {
continue;
}
}
let view = faces.view(fonts);
draw(
block,
&view,
&mut pm,
(ax, ay),
paint,
paints,
sdf_cache.as_ref(),
);
}
LabelDraw::Line {
block,
placements,
perp_px,
paint,
fonts,
paints,
} => {
let margin = paint.size_px + set.padding_px + perp_px.abs();
let (mut min_x, mut min_y, mut max_x, mut max_y) =
(f32::MAX, f32::MAX, f32::MIN, f32::MIN);
for g in placements {
min_x = min_x.min(g.x);
max_x = max_x.max(g.x);
min_y = min_y.min(g.y);
max_y = max_y.max(g.y);
}
if max_x + pad + margin < 0.0
|| min_x + pad - margin > padded_w
|| max_y + pad + margin < 0.0
|| min_y + pad - margin > padded_h
{
continue;
}
let shifted: Vec<GlyphPlacement> = placements
.iter()
.map(|g| GlyphPlacement {
x: g.x + pad,
y: g.y + pad,
angle: g.angle,
})
.collect();
let view = faces.view(fonts);
draw_line(
block,
&view,
&mut pm,
&shifted,
*perp_px,
paint,
paints,
sdf_cache.as_ref(),
);
}
}
}
log_outline_sdf_stats(sdf_cache.as_ref());
Ok(PortValue::Raster(Arc::new(canvas_into_raster(canvas))))
}
fn labels_input<T: Send + Sync + 'static>(
value: Option<&PortValue>,
port: &str,
) -> Result<Arc<T>, EvalError> {
let value = value.ok_or_else(|| EvalError::MissingInput(port.into()))?;
let PortValue::Labels(opaque) = value else {
return Err(EvalError::Other(format!(
"port `{port}`: expected labels, got {}",
value.kind()
)));
};
opaque
.clone()
.downcast::<T>()
.map_err(|_| EvalError::Other(format!("port `{port}`: unexpected labels payload")))
}
struct LabelPlacementNode {
ports: Vec<PortSpec>,
}
impl Node for LabelPlacementNode {
fn op_name(&self) -> &'static str {
"label-placement"
}
fn inputs(&self) -> &[PortSpec] {
&self.ports
}
fn output(&self, _input_kinds: &[Option<PortKind>]) -> PortKind {
PortKind::Labels
}
fn coord_space(&self) -> CoordSpace {
CoordSpace::World
}
fn eval(
&self,
_ctx: &EvalCtx<'_>,
inputs: &[Option<PortValue>],
) -> Result<PortValue, EvalError> {
let mut sets: Vec<Arc<LabelSet>> = Vec::with_capacity(inputs.len());
for (ix, slot) in inputs.iter().enumerate() {
sets.push(labels_input::<LabelSet>(
slot.as_ref(),
&format!("labels[{ix}]"),
)?);
}
let ordered: Vec<&[LabelCandidate]> =
sets.iter().rev().map(|s| s.candidates.as_slice()).collect();
let placed = collide::place_layers(&ordered, collide::COLLISION_CELL_PX);
let mut by_set: HashMap<u64, Vec<collide::Placement>> = HashMap::with_capacity(sets.len());
let mut total = 0usize;
for (set, placements) in sets.iter().rev().zip(placed) {
total += placements.len();
by_set.entry(set.id).or_insert(placements);
}
tracing::debug!(
layers = sets.len(),
candidates = sets.iter().map(|s| s.candidates.len()).sum::<usize>(),
placed = total,
"label-placement: shared collision index",
);
Ok(PortValue::Labels(Arc::new(PlacedLabels { by_set })))
}
fn param_hash(&self, h: &mut Xxh3) {
h.update(b"label-placement");
h.update(&(self.ports.len() as u64).to_le_bytes());
}
}
fn labels_port_name(ix: usize) -> &'static str {
static POOL: std::sync::OnceLock<std::sync::Mutex<Vec<&'static str>>> =
std::sync::OnceLock::new();
let mut pool = POOL
.get_or_init(|| std::sync::Mutex::new(Vec::new()))
.lock()
.expect("label-placement port-name pool poisoned");
while pool.len() <= ix {
let name: &'static str = Box::leak(format!("labels[{}]", pool.len()).into_boxed_str());
pool.push(name);
}
pool[ix]
}
pub(super) struct LabelPlacementFactory;
impl NodeFactory for LabelPlacementFactory {
fn op_name(&self) -> &'static str {
"label-placement"
}
fn build(
&self,
fields: &serde_json::Map<String, Value>,
_ctx: &FactoryCtx<'_>,
) -> Result<BuiltNode, FactoryError> {
let arr = fields
.get("labels")
.ok_or_else(|| FactoryError::MissingField("labels".into()))?
.as_array()
.ok_or_else(|| FactoryError::BadField {
field: "labels".into(),
msg: "expected an array of `@node-ref` strings".into(),
})?;
if arr.is_empty() {
return Err(FactoryError::BadField {
field: "labels".into(),
msg: "needs at least one label layer".into(),
});
}
let mut ports = Vec::with_capacity(arr.len());
let mut connections = Vec::with_capacity(arr.len());
for (ix, entry) in arr.iter().enumerate() {
let s = entry.as_str().ok_or_else(|| FactoryError::BadField {
field: "labels".into(),
msg: format!("entry {ix}: expected a `@node-ref` string"),
})?;
let id = match ezu_style::FieldRef::classify(s) {
ezu_style::FieldRef::Node(id) => id.to_string(),
_ => {
return Err(FactoryError::BadField {
field: "labels".into(),
msg: format!("entry {ix}: expected `@node-ref`, got `{s}`"),
})
}
};
let name = labels_port_name(ix);
ports.push(PortSpec::new(name, ACCEPTS_LABELS));
connections.push(Connection {
port: name.into(),
src: id,
});
}
Ok(BuiltNode {
node: Box::new(LabelPlacementNode { ports }),
connections,
})
}
fn schema(&self) -> Value {
serde_json::json!({
"description": "Place the labels of every `text-labels` layer against one shared collision index, the way maplibre-gl-js does — so a POI label can knock out an overlapping road name. `labels` lists the layers bottom-first (paint order, as in `stack`); priority is top-down, so the last entry places first and wins. Feed the result to each layer's `text-draw` node.",
"properties": {
"labels": {
"type": "array",
"minItems": 1,
"items": schema_frag::node_ref(),
"description": "`text-labels` layers, bottom first. The topmost layer places first."
}
},
"required": ["labels"],
})
}
}
ezu_graph::submit_node!(LabelPlacementFactory);
struct TextDrawNode {
max_extent_px: f32,
ports: Vec<PortSpec>,
}
impl Node for TextDrawNode {
fn op_name(&self) -> &'static str {
"text-draw"
}
fn inputs(&self) -> &[PortSpec] {
&self.ports
}
fn output(&self, _input_kinds: &[Option<PortKind>]) -> PortKind {
PortKind::Raster
}
fn coord_space(&self) -> CoordSpace {
CoordSpace::World
}
fn required_pad(&self, downstream: u32) -> u32 {
downstream + self.max_extent_px.max(0.0).ceil() as u32
}
fn eval(
&self,
ctx: &EvalCtx<'_>,
inputs: &[Option<PortValue>],
) -> Result<PortValue, EvalError> {
let set = labels_input::<LabelSet>(inputs[0].as_ref(), "labels")?;
let placement = labels_input::<PlacedLabels>(inputs[1].as_ref(), "placement")?;
let placed = placement.get(set.id).ok_or_else(|| {
EvalError::Other(
"text-draw: the `labels` layer is absent from the `placement` node — wire the \
same `text-labels` node into both"
.into(),
)
})?;
let faces = FaceCache::from_stacks(set.stacks());
draw_labels(ctx, &set, placed, &faces)
}
fn param_hash(&self, h: &mut Xxh3) {
h.update(b"text-draw");
h.update(&self.max_extent_px.to_bits().to_le_bytes());
}
}
pub(super) struct TextDrawFactory;
impl NodeFactory for TextDrawFactory {
fn op_name(&self) -> &'static str {
"text-draw"
}
fn build(
&self,
fields: &serde_json::Map<String, Value>,
_ctx: &FactoryCtx<'_>,
) -> Result<BuiltNode, FactoryError> {
let mut connections = Vec::with_capacity(2);
for port in ["labels", "placement"] {
let value = fields
.get(port)
.ok_or_else(|| FactoryError::MissingField(port.into()))?;
let src = match value.as_str().map(ezu_style::FieldRef::classify) {
Some(ezu_style::FieldRef::Node(id)) => id.to_string(),
_ => {
return Err(FactoryError::BadField {
field: port.into(),
msg: "expected a `@node-ref` string".into(),
})
}
};
connections.push(Connection {
port: port.into(),
src,
});
}
let max_extent_px = fields
.get("max-extent-px")
.and_then(Value::as_f64)
.unwrap_or(super::text::DEFAULT_MAX_EXTENT_PX as f64)
as f32;
Ok(BuiltNode {
node: Box::new(TextDrawNode {
max_extent_px,
ports: vec![
PortSpec::new("labels", ACCEPTS_LABELS),
PortSpec::new("placement", ACCEPTS_LABELS),
],
}),
connections,
})
}
fn schema(&self) -> Value {
serde_json::json!({
"description": "Draw one label layer's placed labels: `labels` is the layer's `text-labels` node, `placement` the shared `label-placement` node that decided it (wire the same `text-labels` node into both). Styling and layout live on `text-labels`; this node only rasterizes the winners.",
"properties": {
"labels": schema_frag::node_ref(),
"placement": schema_frag::node_ref(),
"max-extent-px": { "type": "number", "minimum": 0,
"description": "Canvas pad (px) this layer's labels need to cross a tile border un-clipped. Mirrors the `text-labels` field of the same name." },
},
"required": ["labels", "placement"],
})
}
}
ezu_graph::submit_node!(TextDrawFactory);
fn log_outline_sdf_stats(cache: Option<&OutlineSdfCache>) {
if let Some(cache) = cache {
let s = cache.stats();
if s.built > 0 || s.hits > 0 {
tracing::debug!(
built = s.built,
hits = s.hits,
bitmap_bytes = s.bitmap_bytes,
"text: outline glyphs rendered through the SDF path"
);
}
}
}