Skip to main content

ezu_graph/
registry.rs

1//! Node registry — maps op names to factory functions.
2//!
3//! The style parser (`ezu-style`) produces a [`spec::Document`] whose
4//! nodes carry an `op: String` and an opaque map of fields. The registry
5//! turns those entries into typed [`Node`] instances plus the list of
6//! input ports to wire up.
7//!
8//! Node implementations live in `ezu-paint` (and any downstream crate);
9//! they register themselves with a [`NodeRegistry`] which the application
10//! hands to [`build_graph`](crate::build_graph).
11
12use std::collections::HashMap;
13
14use ezu_style as spec;
15
16use crate::node::Node;
17
18/// One input port that a node wants connected, recorded by name.
19#[derive(Debug, Clone)]
20pub struct Connection {
21    /// Name of the input port on the node being built.
22    pub port: String,
23    /// Referenced node id (without the `@` prefix).
24    pub src: String,
25}
26
27/// What a [`NodeFactory`] returns: the constructed node plus its
28/// requested input wiring. The graph builder applies the connections
29/// after every node has been constructed.
30pub struct BuiltNode {
31    pub node: Box<dyn Node>,
32    pub connections: Vec<Connection>,
33}
34
35/// Read-only context handed to factories: lets them resolve `$param`
36/// and source references during construction.
37pub struct FactoryCtx<'a> {
38    pub params: &'a indexmap::IndexMap<String, spec::ParamDecl>,
39    pub sources: &'a indexmap::IndexMap<String, spec::SourceDecl>,
40}
41
42#[derive(Debug, thiserror::Error)]
43pub enum FactoryError {
44    #[error("missing required field `{0}`")]
45    MissingField(String),
46    #[error("field `{field}` has wrong type: {msg}")]
47    BadField { field: String, msg: String },
48    #[error("unknown param reference `${0}`")]
49    UnknownParam(String),
50    #[error("unknown asset reference `@{0}`")]
51    UnknownAsset(String),
52    #[error("{0}")]
53    Custom(String),
54}
55
56/// Trait every op implementation provides one of.
57///
58/// Factories are typically zero-sized structs. They inspect the JSON
59/// `fields` map, validate types, and return a [`BuiltNode`]. They MUST
60/// NOT execute any rendering — only construction.
61pub trait NodeFactory: Send + Sync {
62    /// Op name used as the registry key (e.g. `"solid"`, `"fill-solid"`).
63    fn op_name(&self) -> &'static str;
64
65    fn build(
66        &self,
67        fields: &serde_json::Map<String, serde_json::Value>,
68        ctx: &FactoryCtx<'_>,
69    ) -> Result<BuiltNode, FactoryError>;
70
71    /// JSON Schema fragment describing this op's field shape. Should
72    /// return a JSON object with `properties` and (optionally)
73    /// `required` keys — the `op` const is added by the registry.
74    ///
75    /// The default is permissive (any fields allowed). Override to opt
76    /// in to editor autocomplete and client-side validation.
77    fn schema(&self) -> serde_json::Value {
78        serde_json::json!({})
79    }
80}
81
82/// A factory submitted statically via [`inventory::submit!`]. Built-in
83/// node crates use this to self-register without touching a central
84/// list — see [`NodeRegistry::from_inventory`].
85pub struct StaticOp(pub &'static dyn NodeFactory);
86
87inventory::collect!(StaticOp);
88
89/// Submit a unit-struct [`NodeFactory`] to the global inventory so
90/// [`NodeRegistry::from_inventory`] picks it up.
91///
92/// ```ignore
93/// pub(super) struct SolidFactory;
94/// impl NodeFactory for SolidFactory { /* ... */ }
95/// ezu_graph::submit_node!(SolidFactory);
96/// ```
97#[macro_export]
98macro_rules! submit_node {
99    ($factory:ident) => {
100        $crate::inventory::submit! {
101            $crate::StaticOp(&$factory)
102        }
103    };
104}
105
106/// Catalog of registered ops, keyed by op name.
107#[derive(Default)]
108pub struct NodeRegistry {
109    ops: HashMap<&'static str, &'static dyn NodeFactory>,
110}
111
112impl NodeRegistry {
113    pub fn new() -> Self {
114        Self::default()
115    }
116
117    /// Build a registry from every [`StaticOp`] submitted via
118    /// [`inventory::submit!`] across the linked binary.
119    pub fn from_inventory() -> Self {
120        let mut r = Self::default();
121        for StaticOp(f) in inventory::iter::<StaticOp> {
122            r.register_static(*f);
123        }
124        r
125    }
126
127    /// Register a factory by leaking it into `'static`. Convenient for
128    /// dynamic registration; built-in ops should prefer
129    /// [`inventory::submit!`] + [`Self::from_inventory`].
130    pub fn register(&mut self, factory: impl NodeFactory + 'static) {
131        self.register_static(Box::leak(Box::new(factory)));
132    }
133
134    /// Register a `'static` factory reference (the form produced by
135    /// [`inventory::submit!`]).
136    pub fn register_static(&mut self, factory: &'static dyn NodeFactory) {
137        self.ops.insert(factory.op_name(), factory);
138    }
139
140    pub fn get(&self, op_name: &str) -> Option<&dyn NodeFactory> {
141        self.ops.get(op_name).copied()
142    }
143
144    /// All registered op names, sorted for deterministic output.
145    pub fn op_names(&self) -> Vec<&'static str> {
146        let mut names: Vec<_> = self.ops.keys().copied().collect();
147        names.sort_unstable();
148        names
149    }
150
151    /// Build a JSON Schema for a complete style document by gathering
152    /// each registered op's [`NodeFactory::schema`] under a `oneOf`. The
153    /// returned value is suitable for serving at
154    /// `/schemas/ezu-style.json` and feeding to editor tooling
155    /// (Monaco, vscode-json-languageservice, ajv, …).
156    pub fn document_schema(&self) -> serde_json::Value {
157        use serde_json::{json, Value};
158        let mut variants: Vec<Value> = Vec::with_capacity(self.ops.len());
159        for op in self.op_names() {
160            let factory = self
161                .ops
162                .get(op)
163                .expect("op_names yields keys present in self.ops");
164            let mut schema = factory.schema();
165            if !schema.is_object() {
166                schema = json!({});
167            }
168            let obj = schema
169                .as_object_mut()
170                .expect("schema was just normalized to an object");
171            obj.entry("type").or_insert_with(|| json!("object"));
172            // Add the discriminator field.
173            let props = obj
174                .entry("properties")
175                .or_insert_with(|| json!({}))
176                .as_object_mut()
177                .expect("`properties` was just inserted as a JSON object");
178            props.insert(
179                "op".to_string(),
180                json!({ "const": op, "description": format!("Selects the `{op}` operation.") }),
181            );
182            // Require `op`, preserving any other required fields.
183            let required = obj
184                .entry("required")
185                .or_insert_with(|| json!([]))
186                .as_array_mut()
187                .expect("`required` was just inserted as a JSON array");
188            if !required.iter().any(|v| v.as_str() == Some("op")) {
189                required.insert(0, json!("op"));
190            }
191            obj.insert("title".to_string(), json!(format!("op: {op}")));
192            variants.push(schema);
193        }
194
195        // Call into a user-defined function from the document's
196        // `functions` block. Arguments are function-specific, so the
197        // variant stays open beyond `op` / `fn`.
198        variants.push(json!({
199            "type": "object",
200            "title": "op: func",
201            "description": "Call a user-defined function declared in the document's `functions` block.",
202            "required": ["op", "fn"],
203            "properties": {
204                "op": { "const": "func" },
205                "fn": { "type": "string", "description": "Name of the function to call." }
206            },
207            "additionalProperties": true
208        }));
209
210        let func_kinds = json!([
211            "features",
212            "raster",
213            "sprite",
214            "brush",
215            "scalar",
216            "scalar-field"
217        ]);
218
219        json!({
220            "$schema": "https://json-schema.org/draft/2020-12/schema",
221            "title": "Ezu Style Spec",
222            "type": "object",
223            "required": ["name", "nodes", "output"],
224            "properties": {
225                "name": { "type": "string" },
226                "version": { "type": "string" },
227                "tile-size": { "type": "integer", "minimum": 1, "default": 512,
228                               "description": "Rendered tile edge in px. Every `*-px` field is measured against it, so it decides what a pixel is worth in ground units. Defaults to 512, MapLibre's vector-tile convention." },
229                "pad": { "type": "integer", "minimum": 0 },
230                "attribution": {
231                    "type": "string",
232                    "description": "Attribution for the style itself (HTML allowed). Merged with per-source and upstream attributions."
233                },
234                "params": {
235                    "type": "object",
236                    "additionalProperties": {
237                        "type": "object",
238                        "required": ["type", "default"],
239                        "properties": {
240                            "type": { "enum": ["color", "number", "bool"] },
241                            "default": {},
242                            "min": { "type": "number" },
243                            "max": { "type": "number" },
244                            "description": { "type": "string" }
245                        }
246                    }
247                },
248                "legend": {
249                    "type": "object",
250                    "description": "What the map's symbols mean. Never rendered into a tile — hosts read it to draw a legend beside the map.",
251                    "properties": {
252                        "title": { "type": "string", "description": "Heading — usually what is being mapped." },
253                        "note": { "type": "string", "description": "Prose below the entries: sources, classification method, what the map leaves out." },
254                        "entries": {
255                            "type": "array",
256                            "description": "Entries in reading order.",
257                            "items": {
258                                "type": "object",
259                                "required": ["label", "from"],
260                                "properties": {
261                                    "label": { "type": "string", "description": "What the reader sees." },
262                                    "from": { "type": "string", "description": "The node that draws this symbol; must produce a Raster." },
263                                    "properties": { "type": "object", "description": "Feature properties selecting this entry's case." },
264                                    "note": { "type": "string" },
265                                    "min-zoom": { "type": "integer", "minimum": 0 },
266                                    "max-zoom": { "type": "integer", "minimum": 0 },
267                                    "geometry": {
268                                        "enum": ["all", "polygon", "line", "point"],
269                                        "description": "Geometry the swatch's stand-in feature carries. Defaults to all three; name one when a geometry op between the source and this node would otherwise make the node draw twice."
270                                    }
271                                }
272                            }
273                        }
274                    }
275                },
276                "functions": {
277                    "type": "object",
278                    "description": "User-defined functions: reusable node subgraphs called via `op: func`.",
279                    "additionalProperties": {
280                        "type": "object",
281                        "required": ["output", "output-kind", "nodes"],
282                        "properties": {
283                            "description": { "type": "string" },
284                            "inputs": {
285                                "type": "object",
286                                "additionalProperties": {
287                                    "type": "object",
288                                    "required": ["kind"],
289                                    "properties": {
290                                        "kind": { "enum": func_kinds.clone() },
291                                        "default": {},
292                                        "description": { "type": "string" }
293                                    }
294                                }
295                            },
296                            "output": {
297                                "type": "string",
298                                "description": "Body node (with or without `@`) the call produces."
299                            },
300                            "output-kind": { "enum": func_kinds },
301                            "nodes": { "$ref": "#/properties/nodes" }
302                        }
303                    }
304                },
305                "sources": {
306                    "type": "object",
307                    "additionalProperties": {
308                        "oneOf": [
309                            {
310                                "type": "object",
311                                "required": ["type", "src"],
312                                "properties": {
313                                    "type": { "enum": ["brush", "image"] },
314                                    "src": { "type": "string" },
315                                    "attribution": { "type": "string" }
316                                }
317                            },
318                            {
319                                "type": "object",
320                                "required": ["type", "url"],
321                                "properties": {
322                                    "type": { "enum": ["mvt", "pmtiles"] },
323                                    "url": { "type": "string" },
324                                    "attribution": { "type": "string", "description": "Explicit attribution; inherits upstream TileJSON / PMTiles metadata when absent." }
325                                }
326                            },
327                            {
328                                "type": "object",
329                                "required": ["type", "url", "encoding"],
330                                "properties": {
331                                    "type": { "const": "dem" },
332                                    "url": { "type": "string", "description": "XYZ template or TileJSON URL." },
333                                    "encoding": { "enum": ["terrarium", "mapbox-rgb"] },
334                                    "tile-size": { "type": "integer", "minimum": 1, "default": 256,
335                                                   "description": "Edge of the source's own tiles in px — not the document's canvas, and defaulting to 256 rather than 512. Terrarium pyramids are usually 256, Mapbox-RGB ones often 512." },
336                                    "max-zoom": { "type": "integer", "minimum": 0, "description": "Deepest zoom the source serves. Past it, tiles are overzoomed from the covering ancestor." },
337                                    "neighbor-fetch": { "type": "boolean", "default": true },
338                                    "elevation-offset": { "type": "number" },
339                                    "on-missing": { "enum": ["empty", "upsample", "error"], "default": "empty", "description": "404 within zoom range: zero elevation, upsample a parent, or fail the tile." },
340                                    "attribution": { "type": "string" }
341                                }
342                            },
343                            {
344                                "type": "object",
345                                "required": ["type", "url"],
346                                "properties": {
347                                    "type": { "const": "raster" },
348                                    "url": { "type": "string", "description": "XYZ template, TileJSON URL, or PMTiles archive (`.pmtiles`). PNG/WebP/JPEG tiles." },
349                                    "max-zoom": { "type": "integer", "minimum": 0 },
350                                    "neighbor-fetch": { "type": "boolean" },
351                                    "on-missing": { "enum": ["empty", "upsample", "error"], "default": "empty", "description": "404 within zoom range: transparent pixels, upsample a parent, or fail the tile." },
352                                    "attribution": { "type": "string" }
353                                }
354                            }
355                        ]
356                    }
357                },
358                "nodes": {
359                    "type": "object",
360                    "additionalProperties": { "oneOf": variants }
361                },
362                "output": {
363                    "type": "string",
364                    "description": "Node id of the final raster (with or without `@`)."
365                }
366            }
367        })
368    }
369}
370
371/// Pre-built JSON Schema fragments commonly reused by `NodeFactory::schema`
372/// implementations.
373pub mod schema_frag {
374    use serde_json::{json, Value};
375
376    /// A reference to another node, written `@id`.
377    pub fn node_ref() -> Value {
378        json!({
379            "type": "string",
380            "pattern": "^@?[A-Za-z_][A-Za-z0-9_-]*$",
381            "description": "Reference to another node (`@name`)."
382        })
383    }
384
385    /// A reference to a registered asset (brush / image / etc.).
386    pub fn asset_ref() -> Value {
387        json!({
388            "type": "string",
389            "description": "Asset reference (`@name`) or literal path."
390        })
391    }
392
393    /// `#rrggbb` or `#rrggbbaa` color literal. Also allows `$param`
394    /// and `@node` (scalar port).
395    pub fn color() -> Value {
396        json!({
397            "type": "string",
398            "pattern": "^(#[0-9a-fA-F]{6}([0-9a-fA-F]{2})?|[$@][A-Za-z_][A-Za-z0-9_-]*)$",
399            "description": "sRGB hex color, `$param` reference, or `@node` scalar port."
400        })
401    }
402
403    /// `#rrggbb[aa]` literal or `$param` — a colour *inside* a composite
404    /// field (a ramp stop, a palette entry), where a `@node` scalar port
405    /// has no slot to attach to.
406    pub fn nested_color() -> Value {
407        json!({
408            "type": "string",
409            "pattern": "^(#[0-9a-fA-F]{6}([0-9a-fA-F]{2})?|\\$[A-Za-z_][A-Za-z0-9_-]*)$",
410            "description": "sRGB hex color or `$param` reference."
411        })
412    }
413
414    /// Wrap a numeric schema so it also accepts a `$param` string — a
415    /// number inside a composite field. Unlike [`in_number`] it admits
416    /// no `@node`, for the same reason as [`nested_color`].
417    pub fn nested_number(literal: Value) -> Value {
418        json!({
419            "oneOf": [
420                literal,
421                {
422                    "type": "string",
423                    "pattern": "^\\$[A-Za-z_][A-Za-z0-9_-]*$",
424                    "description": "`$param` reference."
425                }
426            ]
427        })
428    }
429
430    /// Number in `[0, 1]` — commonly opacity / fraction parameters.
431    pub fn unit_number() -> Value {
432        in_number(json!({ "type": "number", "minimum": 0.0, "maximum": 1.0 }))
433    }
434
435    /// Non-negative number in pixels.
436    pub fn px_number() -> Value {
437        in_number(json!({ "type": "number", "minimum": 0.0 }))
438    }
439
440    /// Unconstrained number field.
441    pub fn number() -> Value {
442        in_number(json!({ "type": "number" }))
443    }
444
445    /// Wrap a numeric schema so the field also accepts `$param` /
446    /// `@node` reference strings (an [`In<f64>`](crate::input::In)
447    /// field).
448    pub fn in_number(literal: Value) -> Value {
449        json!({
450            "oneOf": [
451                literal,
452                {
453                    "type": "string",
454                    "pattern": "^[$@][A-Za-z_][A-Za-z0-9_-]*$",
455                    "description": "`$param` reference or `@node` scalar port."
456                }
457            ]
458        })
459    }
460}
461
462/// Helper for factory authors: extract a `@node-ref` from a string field.
463///
464/// Returns the bare node id (no `@`). Errors if the field is missing,
465/// not a string, or not a node reference.
466pub fn take_input_ref(
467    fields: &serde_json::Map<String, serde_json::Value>,
468    name: &str,
469) -> Result<String, FactoryError> {
470    let v = fields
471        .get(name)
472        .ok_or_else(|| FactoryError::MissingField(name.to_string()))?;
473    let s = v.as_str().ok_or_else(|| FactoryError::BadField {
474        field: name.to_string(),
475        msg: "expected string node reference".into(),
476    })?;
477    match spec::FieldRef::classify(s) {
478        spec::FieldRef::Node(id) => Ok(id.to_string()),
479        _ => Err(FactoryError::BadField {
480            field: name.to_string(),
481            msg: format!("expected `@node-ref`, got `{s}`"),
482        }),
483    }
484}
485
486/// Like [`take_input_ref`] but returns `None` if the field is absent
487/// or JSON `null`. Use for optional input ports.
488pub fn take_optional_input_ref(
489    fields: &serde_json::Map<String, serde_json::Value>,
490    name: &str,
491) -> Result<Option<String>, FactoryError> {
492    match fields.get(name) {
493        None => Ok(None),
494        Some(v) if v.is_null() => Ok(None),
495        Some(_) => Ok(Some(take_input_ref(fields, name)?)),
496    }
497}