master
Colby Knox Merge PR #453: 1.0.106: silk batch verbs take kicad_silk_state's y-up frame; off-board text refused (closes #141) 11c4c49 2d ago
//! Stable-ID standalone silkscreen text plans. Geometry search belongs to the caller.
use serde_json::{json,Value};
use std::collections::HashSet;
use crate::{pcb::RoutingError,schematic::{Document,Node,parse_node,new_uuid}};
fn fail(s:impl Into<String>)->RoutingError{RoutingError::new("silk_text_refused",s).with("mutated",json!(false))}
#[derive(Clone,Debug)]pub struct Text {pub id:String,pub text:String,pub layer:String,pub x:f64,pub y:f64,pub rotation:f64,pub height:f64,pub width:f64,pub stroke:f64,pub align:String,pub mirrored:bool}
impl Text {
 pub(crate) fn parse(v:&Value,id:String)->Result<Self,RoutingError>{
  const KEYS:&[&str]=&["id","text","layer","x","y","rotation","height","width","stroke","align","mirrored"];
  let o=v.as_object().ok_or_else(||fail("text row must be an object"))?;
  if let Some(k)=o.keys().find(|k|!KEYS.contains(&k.as_str())){return Err(fail(format!("unsupported text field {k}; accepted fields: {}. (size is height and width, thickness is stroke, mirror is mirrored; bottom text is mirrored by default)", KEYS.join(", "))));}
  let num=|k:&str,default:Option<f64>|->Result<f64,RoutingError>{let n=match v.get(k){Some(x)=>x.as_f64(),None=>default}.ok_or_else(||fail(format!("{k} must be a number")))?;if !n.is_finite()||n.abs()>100000.{Err(fail(format!("{k} is outside supported finite range")))}else{Ok(n)}};
  let text=v["text"].as_str().filter(|s|!s.is_empty()&&s.len()<=4096).ok_or_else(||fail("text must contain 1..4096 bytes"))?.into();
  let layer=v["layer"].as_str().filter(|s|matches!(*s,"F.SilkS"|"B.SilkS")).ok_or_else(||fail("layer must be F.SilkS or B.SilkS"))?.to_string();
  let height=num("height",None)?;let width=num("width",Some(height))?;let stroke=num("stroke",None)?;
  if height<=0.||width<=0.||stroke<=0.||height>100.||width>100.||stroke>height.min(width){return Err(fail("positive text dimensions required; stroke must not exceed height/width"));}
  let align=match v.get("align"){None=>"center",Some(v)=>v.as_str().ok_or_else(||fail("align must be a string"))?};if !matches!(align,"left"|"center"|"right"){return Err(fail("align must be left, center or right"));}
  let mirrored=match v.get("mirrored"){Some(v)=>v.as_bool().ok_or_else(||fail("mirrored must be boolean"))?,None=>layer=="B.SilkS"};
  Ok(Self{id,text,layer,x:num("x",None)?,y:num("y",None)?,rotation:num("rotation",Some(0.))?,height,width,stroke,align:align.into(),mirrored})
 }
 pub fn node(&self)->Node {
  let quote=|s:&str|serde_json::to_string(s).unwrap();
  let justify=format!("{} {}",if self.align=="center"{""}else{&self.align},if self.mirrored{"mirror"}else{""});
  let justify=if justify.trim().is_empty(){String::new()}else{format!("(justify {})",justify.trim())};
  parse_node(&format!("(gr_text {} (at {} {} {}) (layer {}) (uuid {}) (effects (font (size {} {}) (thickness {})) {}))",quote(&self.text),self.x,self.y,self.rotation,quote(&self.layer),quote(&self.id),self.width,self.height,self.stroke,justify)).unwrap()
 }
}
pub struct Plan {pub candidate:String,pub create:Vec<Text>,pub update:Vec<Text>,pub remove:Vec<String>}
pub fn prepare(text:&str,args:&Value)->Result<Plan,RoutingError>{
 let mut doc=Document::parse(text).map_err(fail)?;let mut touched=HashSet::new();
 let existing:Vec<_>=doc.root.children().iter().filter(|n|n.head()==Some("gr_text")).cloned().collect();
 let all_ids:HashSet<_>=doc.root.children().iter().filter_map(|n|n.child_value("uuid")).collect();
 let editable=|id:&str|->Result<(),RoutingError>{let n=existing.iter().find(|n|n.child_value("uuid").as_deref()==Some(id)).ok_or_else(||fail(format!("{id} is not standalone board text")))?;
  if !matches!(n.child_value("layer").as_deref(),Some("F.SilkS"|"B.SilkS"))||n.child("locked").map(|n|n.arg(0).as_deref()!=Some("no")).unwrap_or(false){return Err(fail(format!("{id} is locked or is not silkscreen text")));}Ok(())};
 let rows=|key:&str|->Result<Vec<Value>,RoutingError>{match args.get(key){None=>Ok(vec![]),Some(v)=>v.as_array().cloned().filter(|a|a.len()<=2000).ok_or_else(||fail(format!("{key} must be an array with at most 2000 items")))}};
 let mut create=Vec::new();let mut update=Vec::new();let mut remove=Vec::new();
 for v in rows("create")? {if v.get("id").is_some(){return Err(fail("create IDs are assigned by the bridge; use update to preserve an existing ID"));}let id=new_uuid();if all_ids.contains(&id){return Err(fail("generated ID collision"));}create.push(Text::parse(&v,id)?);}
 for v in rows("update")? {let id=v["id"].as_str().ok_or_else(||fail("update requires a stable item id"))?;editable(id)?;if !touched.insert(id.to_string()){return Err(fail("duplicate update/remove ID"));}update.push(Text::parse(&v,id.into())?);}
 for v in rows("remove")? {let id=v.as_str().ok_or_else(||fail("remove entries must be stable item IDs"))?;editable(id)?;if !touched.insert(id.to_string()){return Err(fail("duplicate update/remove ID"));}remove.push(id.to_string());}
 if create.is_empty()&&update.is_empty()&&remove.is_empty(){return Err(fail("provide at least one create, update or remove"));}
 if args.get("allowOffBoard").and_then(Value::as_bool)!=Some(true){off_board(text,args,create.iter().chain(&update))?;}
 let cs=doc.root.children_mut().ok_or_else(||fail("invalid root"))?;cs.retain(|n|!n.child_value("uuid").map(|id|touched.contains(&id)).unwrap_or(false));cs.extend(create.iter().chain(&update).map(Text::node));
 Ok(Plan{candidate:doc.to_text(),create,update,remove})
}
/// Wiki #141: a text whose anchor lands outside the board outline is refused (errorCode text_off_board,
/// nothing changed) unless allowOffBoard:true. KiCad's DRC says nothing about silkscreen that lies wholly
/// off the board, so without this a y-up position from kicad_silk_state sent back in KiCad's y-down frame
/// moved the text off the board with success. When the mirrored y would land on the board, the refusal
/// says so and names frame:"standard".
fn off_board<'a>(text:&str,args:&Value,rows:impl Iterator<Item=&'a Text>)->Result<(),RoutingError>{
 let Some(d)=crate::pcb::parse_pcb_text(text,"").ok().and_then(|b|b.dimensions) else {return Ok(())};
 let inside=|x:f64,y:f64|x>=d.min_x-1e-6&&x<=d.max_x+1e-6&&y>=d.min_y-1e-6&&y<=d.max_y+1e-6;
 let off:Vec<&Text>=rows.filter(|t|!inside(t.x,t.y)).collect();
 if off.is_empty(){return Ok(());}
 let given_up=args.get("_fromFrame").and_then(Value::as_str)==Some("standard");
 let mirrored=!given_up&&off.iter().all(|t|t.y<0.0&&inside(t.x,-t.y));
 let hint=if mirrored{"These y values are negative and their mirror (-y) is on the board: they look like kicad_silk_state's y-up (standard frame) positions. Pass frame:\"standard\" with them, or read kicad_silk_state with frame:\"kicad\"."}
  else{"Give a position inside the board outline (outline below, KiCad's y-down frame unless you pass frame:\"standard\"), or allowOffBoard:true if the text really belongs off the board."};
 Err(RoutingError::new("text_off_board",format!("{} text position(s) lie outside the board outline; nothing changed",off.len()))
  .with("mutated",json!(false)).with("_hint",json!(hint)).with("looksLikeStandardFrame",json!(mirrored))
  .with("outline",json!({"minX":d.min_x,"minY":d.min_y,"maxX":d.max_x,"maxY":d.max_y}))
  .with("offBoard",json!(off.iter().map(|t|json!({"id":t.id,"text":t.text,"x":t.x,"y":t.y})).collect::<Vec<_>>())))
}
#[cfg(test)]mod tests {
 use super::*;
 #[test]fn duplicate_content_is_never_identity(){let text="(kicad_pcb (gr_text \"GND\" (at 1 2) (layer \"F.SilkS\") (uuid a)) (gr_text \"GND\" (at 3 4) (layer \"F.SilkS\") (uuid b)))";
 let p=prepare(text,&json!({"remove":["a"]})).unwrap();assert!(p.candidate.contains("uuid b"));assert!(!p.candidate.contains("uuid a"));
 assert!(prepare(text,&json!({"remove":["a","a"]})).is_err());assert!(prepare(text,&json!({"remove":["GND"]})).is_err());}
 #[test]fn bottom_mirror_alignment_and_id_survive(){let text="(kicad_pcb (gr_text \"old\" (layer \"B.SilkS\") (uuid x)))";
 let p=prepare(text,&json!({"update":[{"id":"x","text":"MC10\nDSHOT","layer":"B.SilkS","x":10,"y":20,"height":0.5,"stroke":0.07,"align":"left"}]})).unwrap();assert_eq!(p.update[0].id,"x");assert!(p.update[0].mirrored);assert!(p.candidate.contains("left mirror"));assert_eq!(p.candidate.matches("gr_text").count(),1);}
 /// Wiki #141: kicad_silk_state's y-up y sent back in KiCad's frame lands off the board and is refused.
 #[test]fn a_text_off_the_board_is_refused_and_a_mirrored_y_is_named(){
  let text="(kicad_pcb (gr_rect (start 100 100) (end 160 140) (layer \"Edge.Cuts\") (uuid e)) (gr_text \"J1\" (at 120 135.92) (layer \"F.SilkS\") (uuid t)))";
  let row=|y:f64|json!({"update":[{"id":"t","text":"J1","layer":"F.SilkS","x":120,"y":y,"height":1,"stroke":0.15}]});
  assert!(prepare(text,&row(135.92)).is_ok());
  let e=prepare(text,&row(-135.92)).err().unwrap();assert_eq!(e.code,"text_off_board");assert_eq!(e.detail["looksLikeStandardFrame"],true);assert_eq!(e.detail["mutated"],false);
  let far=prepare(text,&row(300.0)).err().unwrap();assert_eq!(far.code,"text_off_board");assert_eq!(far.detail["looksLikeStandardFrame"],false);
  let mut ok=row(300.0);ok["allowOffBoard"]=json!(true);assert!(prepare(text,&ok).is_ok());
  // a board with no outline has nothing to check against
  assert!(prepare("(kicad_pcb (gr_text \"J1\" (at 1 2) (layer \"F.SilkS\") (uuid t)))",&row(-5.0)).is_ok());
 }
 #[test]fn refuses_locked_non_silk_and_unknown_attributes(){for extra in ["(locked yes)","(layer F.Cu)"]{let text=format!("(kicad_pcb (gr_text x (uuid a) {extra}))");assert!(prepare(&text,&json!({"remove":["a"]})).is_err());}assert!(Text::parse(&json!({"text":"x","layer":"F.SilkS","x":0,"y":0,"height":1,"stroke":0.1,"font":"Arial"}),"a".into()).is_err());}
}