use crate::schema;
use serde_json::{Map, Value};
use std::collections::HashSet;
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct Validation {
pub errors: Vec<String>,
pub warnings: Vec<String>,
}
impl Validation {
pub fn ok(&self) -> bool {
self.errors.is_empty()
}
}
pub fn merge_params(defaults: &Value, params: &Value) -> Value {
let mut out = defaults.as_object().cloned().unwrap_or_default();
if let Some(overlay) = params.as_object() {
for (k, v) in overlay {
match (out.get_mut(k), v) {
(Some(Value::Object(base)), Value::Object(patch)) => {
for (pk, pv) in patch {
base.insert(pk.clone(), pv.clone());
}
}
_ => {
out.insert(k.clone(), v.clone());
}
}
}
}
Value::Object(out)
}
pub fn validate(
feature_type: &str,
params: &Value,
known_names: Option<&HashSet<String>>,
) -> Validation {
let mut v = Validation::default();
let Some(entry) = schema::entry(feature_type) else {
v.errors.push(format!("unknown feature type `{feature_type}`"));
return v;
};
let Some(spec) = entry.get("inputParamsSchema").and_then(Value::as_object) else {
v.errors.push(format!("feature `{feature_type}` has no inputParamsSchema"));
return v;
};
let Some(given) = params.as_object() else {
v.errors.push("inputParams must be a JSON object".into());
return v;
};
let known: Vec<&str> = spec.keys().map(String::as_str).collect();
for (key, value) in given {
let Some(pspec) = spec.get(key) else {
let near = nearest(key, &known)
.map(|n| format!(" (did you mean `{n}`?)"))
.unwrap_or_default();
v.errors.push(format!("unknown parameter `{key}`{near}"));
continue;
};
check_kind(key, pspec, value, known_names, &mut v);
}
v
}
fn check_kind(
key: &str,
pspec: &Value,
value: &Value,
known_names: Option<&HashSet<String>>,
v: &mut Validation,
) {
let ty = pspec.get("type").and_then(Value::as_str).unwrap_or("");
match ty {
"number" => {
if !(value.is_number() || value.is_string() || value.is_null()) {
v.errors.push(format!("`{key}` must be a number or an expression string"));
}
}
"string" => {
if !(value.is_string() || value.is_null()) {
v.errors.push(format!("`{key}` must be a string"));
}
}
"boolean" => {
if !value.is_boolean() {
v.errors.push(format!("`{key}` must be a boolean"));
}
}
"options" => {
let options: Vec<&str> = pspec
.get("options")
.and_then(Value::as_array)
.map(|a| a.iter().filter_map(Value::as_str).collect())
.unwrap_or_default();
match value.as_str() {
Some(s) if options.contains(&s) => {}
_ => v.errors.push(format!(
"`{key}` must be one of {}",
options.iter().map(|o| format!("`{o}`")).collect::<Vec<_>>().join(", ")
)),
}
}
"reference_selection" => {
let multiple = pspec.get("multiple").and_then(Value::as_bool).unwrap_or(false);
let names: Vec<&str> = match value {
Value::Null => vec![],
Value::String(s) if !multiple => vec![s.as_str()],
Value::Array(a) if multiple => a.iter().filter_map(Value::as_str).collect(),
Value::String(_) => {
v.errors.push(format!("`{key}` takes an array of reference names"));
vec![]
}
Value::Array(_) => {
v.errors.push(format!("`{key}` takes a single reference name"));
vec![]
}
_ => {
v.errors.push(format!("`{key}` must be a reference name"));
vec![]
}
};
if let Some(known) = known_names {
for n in names {
if !known.contains(n) {
v.warnings.push(format!(
"`{key}` references `{n}`, which is not in the scene at this point in the history"
));
}
}
}
}
"boolean_operation" => match value.as_object() {
Some(obj) => {
if let Some(op) = obj.get("operation") {
match op.as_str() {
Some(s) if schema::BOOLEAN_OPS.contains(&s) => {}
_ => v.errors.push(format!(
"`{key}.operation` must be one of {}",
schema::BOOLEAN_OPS.join(", ")
)),
}
}
if let Some(t) = obj.get("targets") {
if !t.is_array() {
v.errors.push(format!("`{key}.targets` must be an array of solid names"));
}
}
for k in obj.keys() {
if !["operation", "targets", "mergeCoplanarFaces"].contains(&k.as_str()) {
v.errors.push(format!("unknown key `{key}.{k}`"));
}
}
}
None => v.errors.push(format!("`{key}` must be an object {{operation, targets, mergeCoplanarFaces}}")),
},
"transform" => match value.as_object() {
Some(obj) => {
let rigid = pspec
.get("default_value")
.map(|d| d.get("translate").is_some() || d.get("rotateEulerDeg").is_some())
.unwrap_or(false);
let allowed: &[&str] = if rigid {
&["translate", "rotateEulerDeg"]
} else {
&["position", "rotationEuler", "scale"]
};
for (k, val) in obj {
if !allowed.contains(&k.as_str()) {
v.errors.push(format!("unknown key `{key}.{k}` (allowed: {})", allowed.join(", ")));
} else if !is_vec3(val) {
v.errors.push(format!("`{key}.{k}` must be three numbers"));
}
}
}
None => v.errors.push(format!("`{key}` must be an object")),
},
_ => {
v.warnings.push(format!("`{key}` has type `{ty}`, which no tool can set; it is ignored"));
}
}
}
fn is_vec3(v: &Value) -> bool {
v.as_array().map(|a| a.len() == 3 && a.iter().all(Value::is_number)).unwrap_or(false)
}
fn nearest<'a>(key: &str, known: &[&'a str]) -> Option<&'a str> {
known
.iter()
.map(|k| (levenshtein(key, k), *k))
.filter(|(d, k)| *d <= 2 || (*d <= 3 && k.len() > 6))
.min_by_key(|(d, _)| *d)
.map(|(_, k)| k)
}
fn levenshtein(a: &str, b: &str) -> usize {
let a: Vec<char> = a.chars().collect();
let b: Vec<char> = b.chars().collect();
let mut prev: Vec<usize> = (0..=b.len()).collect();
for (i, ca) in a.iter().enumerate() {
let mut cur = vec![i + 1];
for (j, cb) in b.iter().enumerate() {
let cost = if ca.eq_ignore_ascii_case(cb) { 0 } else { 1 };
cur.push((prev[j] + cost).min(prev[j + 1] + 1).min(cur[j] + 1));
}
prev = cur;
}
prev[b.len()]
}
pub fn object(pairs: &[(&str, Value)]) -> Value {
let mut m = Map::new();
for (k, v) in pairs {
m.insert((*k).to_string(), v.clone());
}
Value::Object(m)
}