Skip to main content

pite_core/
schema.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! Schema definitions for engine node types, properties, inheritance, and validation.
3
4use std::collections::HashSet;
5
6use crate::props::PropValue;
7
8/// Static representation of a property's default value defined in a node schema.
9#[derive(Clone, Copy, PartialEq, Debug)]
10pub enum DefaultPropVal {
11    Num(f64),
12    Int(i64),
13    Bool(bool),
14    Vec2(f64, f64),
15    Str(&'static str),
16    Color([u8; 4]),
17}
18
19impl DefaultPropVal {
20    /// Converts this schema default into a runtime [`PropValue`].
21    pub fn to_prop_value(&self) -> PropValue {
22        match self {
23            Self::Num(f) => PropValue::Num(*f),
24            Self::Int(i) => PropValue::Int(*i),
25            Self::Bool(b) => PropValue::Bool(*b),
26            Self::Vec2(x, y) => PropValue::Vec2(*x, *y),
27            Self::Str(s) => PropValue::Str((*s).to_string()),
28            Self::Color([r, g, b, a]) => PropValue::Color(*r, *g, *b, *a),
29        }
30    }
31
32    /// Checks if a given runtime [`PropValue`] matches this default value.
33    pub fn matches(&self, val: &PropValue) -> bool {
34        match (self, val) {
35            (Self::Num(a), PropValue::Num(b)) => (a - b).abs() < f64::EPSILON,
36            (Self::Int(a), PropValue::Int(b)) => a == b,
37            (Self::Bool(a), PropValue::Bool(b)) => a == b,
38            (Self::Vec2(x1, y1), PropValue::Vec2(x2, y2)) => {
39                (x1 - x2).abs() < f64::EPSILON && (y1 - y2).abs() < f64::EPSILON
40            }
41            (Self::Str(a), PropValue::Str(b)) => *a == b.as_str(),
42            (Self::Color([r1, g1, b1, a1]), PropValue::Color(r2, g2, b2, a2)) => {
43                r1 == r2 && g1 == g2 && b1 == b2 && a1 == a2
44            }
45            _ => false,
46        }
47    }
48}
49
50/// UI presentation and editing hint for inspector property widgets.
51#[derive(Clone, Copy, PartialEq, Debug)]
52pub enum PropHint {
53    Vec2 { speed: f64 },
54    AngleDeg,
55    Num { speed: f64, min: f64, max: f64 },
56    Int { speed: f64, min: i64, max: i64 },
57    Bool,
58    Text,
59    Enum(&'static [&'static str]),
60    Color,
61    Asset { filter: &'static [&'static str] },
62}
63
64/// Schema metadata defining an individual node property.
65#[derive(Clone, Copy, Debug)]
66pub struct PropSchema {
67    pub key: &'static str,
68    pub label: &'static str,
69    /// One-line description surfaced as a hover tooltip in the inspector
70    /// and as the Description column in the generated node reference
71    /// (`docs/reference/nodes/*.md`). Required (no default) so a prop
72    /// without docs fails to compile.
73    pub doc: &'static str,
74    pub default_value: DefaultPropVal,
75    pub hint: PropHint,
76    pub read_only: bool, // To Python
77}
78
79/// Schema metadata defining a node type and its inheritance relationships.
80#[derive(Clone, Copy, Debug)]
81pub struct NodeTypeSchema {
82    pub type_name: &'static str,
83    /// One-line description surfaced in the editor's add-node dialog,
84    /// tree-row hover tooltip, and generated node reference. Required.
85    pub description: &'static str,
86    pub inherits: Option<&'static str>,
87    pub props: &'static [PropSchema],
88    /// Abstract types exist in the schema chain so subclasses inherit their
89    /// props, but they are hidden from the editor's add-node menu and are
90    /// not intended to be instantiated directly.
91    pub abstract_class: bool,
92}
93
94/// All node schemas, keyed by name. Order is arbitrary; `find` matches by
95/// `type_name`, so lookups are O(n) over 16 entries — fine.
96pub use crate::nodes::ALL_NODES as NODE_SCHEMAS;
97
98/// Retrieves the schema definition for a given node type by name.
99pub fn node_type_schema(type_name: &str) -> Option<&'static NodeTypeSchema> {
100    NODE_SCHEMAS
101        .iter()
102        .copied()
103        .find(|s| s.type_name == type_name)
104}
105
106/// Computes the inheritance chain for a node type from leaf to root (e.g. `["Sprite2D", "Node2D", "Node"]`).
107pub fn inheritance_chain<'a>(type_name: &'a str) -> Vec<&'a str> {
108    let mut chain = Vec::new();
109    let mut current = Some(type_name);
110    while let Some(curr) = current {
111        chain.push(curr);
112        current = match node_type_schema(curr) {
113            Some(schema) => schema.inherits,
114            None => {
115                if curr != "Node" {
116                    Some("Node")
117                } else {
118                    None
119                }
120            }
121        };
122    }
123    chain
124}
125
126/// Returns the direct properties declared specifically on `type_name`.
127pub fn type_props(type_name: &str) -> &'static [PropSchema] {
128    node_type_schema(type_name).map(|s| s.props).unwrap_or(&[])
129}
130
131/// All props for `type_name`, walking the inheritance chain from leaf to
132/// root and deduplicating by key (leaf declarations shadow ancestors).
133/// The leaf's version of an overridden prop (its default, hint, `read_only`)
134/// wins, so `Button.color` sees `[51, 65, 85, 255]`, not `Control.color`'s
135/// white. Unknown types fall through to `Node` (which declares no props).
136pub fn all_props(type_name: &str) -> Vec<&'static PropSchema> {
137    let mut seen: HashSet<&'static str> = HashSet::new();
138    let mut out = Vec::new();
139    let mut current: Option<String> = Some(type_name.to_string());
140    while let Some(name) = current {
141        if let Some(schema) = node_type_schema(&name) {
142            for prop in schema.props {
143                if seen.insert(prop.key) {
144                    out.push(prop);
145                }
146            }
147            current = schema.inherits.map(String::from);
148        } else if name != "Node" {
149            current = Some("Node".to_string());
150        } else {
151            current = None;
152        }
153    }
154    out
155}
156
157/// True when `type_str` names a type a `[props]` entry may declare.
158/// Accepts aliases so hand-written manifests stay forgiving.
159pub fn is_valid_custom_prop_type(type_str: &str) -> bool {
160    matches!(
161        type_str,
162        "string"
163            | "str"
164            | "int"
165            | "integer"
166            | "number"
167            | "num"
168            | "float"
169            | "bool"
170            | "boolean"
171            | "vec2"
172            | "color"
173    )
174}
175
176/// Check a `PropValue` against a `[props]` declared type name. Errors name
177/// the expected and actual types so `pite check` and Python `set_custom_property`
178/// surface the same message.
179pub fn validate_custom_prop_type(type_str: &str, value: &PropValue) -> Result<(), String> {
180    let want = match type_str {
181        "string" | "str" => matches!(value, PropValue::Str(_)),
182        "int" | "integer" => matches!(value, PropValue::Int(_)),
183        "number" | "num" | "float" => matches!(value, PropValue::Num(_)),
184        "bool" | "boolean" => matches!(value, PropValue::Bool(_)),
185        "vec2" => matches!(value, PropValue::Vec2(_, _)),
186        "color" => matches!(value, PropValue::Color(_, _, _, _)),
187        other => {
188            return Err(format!(
189                "unknown custom prop type {:?} (want one of: {})",
190                other,
191                crate::CUSTOM_PROP_TYPES.join(", ")
192            ));
193        }
194    };
195    if want {
196        Ok(())
197    } else {
198        Err(format!(
199            "expected {}, got {}",
200            canonical_type_name(type_str),
201            value.type_name()
202        ))
203    }
204}
205
206fn canonical_type_name(type_str: &str) -> &'static str {
207    match type_str {
208        "string" | "str" => "string",
209        "int" | "integer" => "int",
210        "number" | "num" | "float" => "number",
211        "bool" | "boolean" => "bool",
212        "vec2" => "vec2",
213        "color" => "color",
214        _ => "unknown",
215    }
216}
217
218#[cfg(test)]
219mod tests {
220    use super::*;
221
222    #[test]
223    fn test_inheritance_chains() {
224        assert_eq!(
225            inheritance_chain("Sprite2D"),
226            vec!["Sprite2D", "Node2D", "Node"]
227        );
228        assert_eq!(
229            inheritance_chain("AnimatedSprite2D"),
230            vec!["AnimatedSprite2D", "Node2D", "Node"]
231        );
232        assert_eq!(
233            inheritance_chain("CharacterBody2D"),
234            vec![
235                "CharacterBody2D",
236                "PhysicsBody2D",
237                "CollisionObject2D",
238                "Node2D",
239                "Node"
240            ]
241        );
242        assert_eq!(
243            inheritance_chain("Button"),
244            vec!["Button", "Control", "Node2D", "Node"]
245        );
246        assert_eq!(inheritance_chain("Node"), vec!["Node"]);
247        assert_eq!(inheritance_chain("Custom"), vec!["Custom", "Node"]);
248    }
249
250    #[test]
251    fn test_type_props() {
252        let props = type_props("Sprite2D");
253        let keys: Vec<&str> = props.iter().map(|p| p.key).collect();
254        assert_eq!(keys, vec!["texture", "atlas", "frame"]);
255
256        let anim_props = type_props("AnimatedSprite2D");
257        let keys: Vec<&str> = anim_props.iter().map(|p| p.key).collect();
258        assert_eq!(
259            keys,
260            vec![
261                "atlas",
262                "animation",
263                "frame",
264                "speed_scale",
265                "playing",
266                "autoplay"
267            ]
268        );
269
270        // PhysicsBody2D declares friction/restitution; RigidBody2D declares only
271        // its own new props (mass, gravity_scale, lock_rotation).
272        let rigid = type_props("RigidBody2D");
273        let keys: Vec<&str> = rigid.iter().map(|p| p.key).collect();
274        assert_eq!(keys, vec!["mass", "gravity_scale", "lock_rotation"]);
275    }
276
277    #[test]
278    fn test_all_props_walks_chain() {
279        // Sprite2D → Node2D → Node
280        let keys: Vec<&str> = all_props("Sprite2D").iter().map(|p| p.key).collect();
281        assert_eq!(
282            keys,
283            vec!["texture", "atlas", "frame", "position", "rotation"]
284        );
285
286        // AnimatedSprite2D → Node2D → Node
287        let keys: Vec<&str> = all_props("AnimatedSprite2D")
288            .iter()
289            .map(|p| p.key)
290            .collect();
291        assert_eq!(
292            keys,
293            vec![
294                "atlas",
295                "animation",
296                "frame",
297                "speed_scale",
298                "playing",
299                "autoplay",
300                "position",
301                "rotation"
302            ]
303        );
304
305        // Button shadows Control's color and size; Control's color/size are
306        // filtered out after Button's versions are seen first; position and rotation
307        // come from Node2D.
308        let keys: Vec<&str> = all_props("Button").iter().map(|p| p.key).collect();
309        assert_eq!(
310            keys,
311            vec![
312                "text",
313                "color",
314                "size",
315                "disabled",
316                "font_size",
317                "position",
318                "rotation"
319            ]
320        );
321        // Button's own color default wins over Control's white.
322        let color = all_props("Button")
323            .into_iter()
324            .find(|p| p.key == "color")
325            .unwrap();
326        assert_eq!(
327            color.default_value,
328            super::DefaultPropVal::Color([51, 65, 85, 255])
329        );
330
331        // CharacterBody2D → PhysicsBody2D → CollisionObject2D → Node2D → Node
332        let keys: Vec<&str> = all_props("CharacterBody2D").iter().map(|p| p.key).collect();
333        assert_eq!(
334            keys,
335            vec![
336                "velocity",
337                "friction",
338                "restitution",
339                "collision_layer",
340                "collision_mask",
341                "position",
342                "rotation"
343            ]
344        );
345
346        // Unknown type falls through to Node (no props).
347        assert!(all_props("Custom").is_empty());
348    }
349
350    #[test]
351    fn custom_prop_types_validate() {
352        use crate::PropValue;
353        // Aliases are accepted.
354        assert!(is_valid_custom_prop_type("string"));
355        assert!(is_valid_custom_prop_type("str"));
356        assert!(is_valid_custom_prop_type("number"));
357        assert!(is_valid_custom_prop_type("float"));
358        assert!(is_valid_custom_prop_type("bool"));
359        assert!(is_valid_custom_prop_type("boolean"));
360        assert!(!is_valid_custom_prop_type("bytes"));
361        assert!(!is_valid_custom_prop_type(""));
362
363        // Value checks.
364        assert!(validate_custom_prop_type("string", &PropValue::Str("hi".into())).is_ok());
365        assert!(validate_custom_prop_type("string", &PropValue::Int(1)).is_err());
366        assert!(validate_custom_prop_type("int", &PropValue::Int(1)).is_ok());
367        assert!(validate_custom_prop_type("int", &PropValue::Num(1.5)).is_err());
368        assert!(validate_custom_prop_type("number", &PropValue::Num(1.5)).is_ok());
369        assert!(validate_custom_prop_type("number", &PropValue::Int(1)).is_err());
370        assert!(validate_custom_prop_type("bool", &PropValue::Bool(true)).is_ok());
371        assert!(validate_custom_prop_type("vec2", &PropValue::Vec2(1.0, 2.0)).is_ok());
372        assert!(validate_custom_prop_type("color", &PropValue::Color(1, 2, 3, 4)).is_ok());
373
374        // Unknown type name errors with the canonical list.
375        let err = validate_custom_prop_type("bytes", &PropValue::Str("".into())).unwrap_err();
376        assert!(err.contains("unknown custom prop type"), "got {err:?}");
377        assert!(
378            err.contains("string, int, number, bool, vec2, color"),
379            "got {err:?}"
380        );
381    }
382
383    #[test]
384    fn every_schema_has_description_and_every_prop_has_doc() {
385        for schema in crate::NODE_SCHEMAS {
386            assert!(
387                !schema.description.is_empty(),
388                "{} has no description",
389                schema.type_name
390            );
391            for prop in schema.props {
392                assert!(
393                    !prop.doc.is_empty(),
394                    "{}::{} has no doc",
395                    schema.type_name,
396                    prop.key
397                );
398                assert!(
399                    !prop.label.is_empty(),
400                    "{}::{} has no label",
401                    schema.type_name,
402                    prop.key
403                );
404            }
405        }
406    }
407}